mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
refactor(media-proxy): split into modules and harden streaming (#2437)
This commit is contained in:
1 parent
16ae98e189
commit
d7d4e8da03
214 files changed
+47213
-18092
No files matched your search
@@ -0,0 +1,103 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
asset_hash::{self, AssetHash},
|
||||
disposition::{self, Decision, PUBLIC_MEDIA_MAX_HEADER_VALUE_BYTES},
|
||||
query::Query,
|
||||
test_fixtures::ADVERSARIAL_TEXT_INPUTS,
|
||||
upload_relay::token::token_from_query,
|
||||
};
|
||||
|
||||
const RELAY_TOKEN_BYTES_MAX: usize = 16 * 1024;
|
||||
|
||||
#[test]
|
||||
fn adversarial_text_never_escapes_the_asset_hash_digest_contract() {
|
||||
for text in ADVERSARIAL_TEXT_INPUTS {
|
||||
let parsed = AssetHash::parse(text);
|
||||
assert!(
|
||||
text.ends_with(parsed.digest()),
|
||||
"the digest is not a suffix of {text}"
|
||||
);
|
||||
assert_eq!(
|
||||
parsed.is_animated(),
|
||||
text.starts_with("a_"),
|
||||
"the animation flag disagrees with {text}"
|
||||
);
|
||||
assert_eq!(
|
||||
text.len() - parsed.digest().len(),
|
||||
if parsed.is_animated() { 2 } else { 0 },
|
||||
"the digest dropped bytes from {text}"
|
||||
);
|
||||
assert_eq!(parsed.digest(), asset_hash::strip_animation_prefix(text));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adversarial_query_text_yields_at_most_one_relay_token() {
|
||||
for text in ADVERSARIAL_TEXT_INPUTS {
|
||||
let query = Query::parse(text);
|
||||
for key in ["t", "token", ""] {
|
||||
let Ok(token) = token_from_query(query.get(key)) else {
|
||||
continue;
|
||||
};
|
||||
assert!(!token.is_empty(), "an empty token was accepted from {text}");
|
||||
assert!(
|
||||
!token.contains('&'),
|
||||
"a multi-parameter token was accepted from {text}"
|
||||
);
|
||||
assert!(
|
||||
token.len() <= RELAY_TOKEN_BYTES_MAX,
|
||||
"an unbounded token was accepted from {text}"
|
||||
);
|
||||
}
|
||||
if let Ok(token) = token_from_query(Some(text)) {
|
||||
assert!(!token.is_empty(), "an empty token was accepted from {text}");
|
||||
assert!(token.len() <= RELAY_TOKEN_BYTES_MAX);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adversarial_filenames_never_break_the_content_disposition_header() {
|
||||
for text in ADVERSARIAL_TEXT_INPUTS {
|
||||
for (decision, directive) in [
|
||||
(Decision::Inline, "inline"),
|
||||
(Decision::Attachment, "attachment"),
|
||||
] {
|
||||
let value = disposition::header(decision, Some(text))
|
||||
.expect("disposition header")
|
||||
.into_header_value();
|
||||
let rendered = value.to_str().expect("the disposition header is ascii");
|
||||
assert!(
|
||||
rendered.len() <= PUBLIC_MEDIA_MAX_HEADER_VALUE_BYTES,
|
||||
"unbounded disposition for {text}"
|
||||
);
|
||||
assert!(
|
||||
!rendered.contains(['\r', '\n', '\0']),
|
||||
"the disposition for {text} can split the header block"
|
||||
);
|
||||
assert!(
|
||||
rendered.starts_with(directive),
|
||||
"the disposition for {text} lost its directive"
|
||||
);
|
||||
if text.is_empty() {
|
||||
assert_eq!(rendered, directive);
|
||||
continue;
|
||||
}
|
||||
assert_eq!(
|
||||
rendered.matches("; filename=\"").count(),
|
||||
1,
|
||||
"the disposition for {text} repeated the filename parameter"
|
||||
);
|
||||
let quoted = rendered
|
||||
.strip_prefix(directive)
|
||||
.and_then(|rest| rest.strip_prefix("; filename=\""))
|
||||
.and_then(|rest| rest.split('"').next())
|
||||
.expect("a terminated quoted filename");
|
||||
assert!(
|
||||
!quoted.contains(['\\', '/']),
|
||||
"the ascii fallback for {text} kept a path or escape character"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
disposition::{self, Decision},
|
||||
http_headers,
|
||||
range::ByteRange,
|
||||
};
|
||||
use http::{HeaderMap, header};
|
||||
|
||||
fn disposition_string(decision: Decision, filename: Option<&str>) -> String {
|
||||
disposition::header(decision, filename)
|
||||
.expect("disposition header")
|
||||
.into_header_value()
|
||||
.to_str()
|
||||
.expect("disposition header is ascii")
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disposition_policy_blocks_scriptable_documents() {
|
||||
assert_eq!(disposition::decide("image/png", false), Decision::Inline);
|
||||
assert_eq!(
|
||||
disposition::decide("video/webm; codecs=vp9", false),
|
||||
Decision::Inline
|
||||
);
|
||||
assert_eq!(
|
||||
disposition::decide("image/svg+xml", false),
|
||||
Decision::Attachment
|
||||
);
|
||||
assert_eq!(
|
||||
disposition::decide("application/pdf; charset=binary", false),
|
||||
Decision::Attachment
|
||||
);
|
||||
assert_eq!(disposition::decide("image/png", true), Decision::Attachment);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disposition_filename_uses_ascii_and_rfc5987_forms() {
|
||||
assert_eq!(
|
||||
disposition_string(Decision::Attachment, Some("photo.png")),
|
||||
"attachment; filename=\"photo.png\""
|
||||
);
|
||||
assert_eq!(
|
||||
disposition_string(Decision::Attachment, Some("résumé/\".png")),
|
||||
"attachment; filename=\"r__sum____.png\"; filename*=UTF-8''r%C3%A9sum%C3%A9%2F%22.png"
|
||||
);
|
||||
assert_eq!(disposition_string(Decision::Inline, Some("")), "inline");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_media_headers_never_negotiate_a_conditional_validator() {
|
||||
let mut responses = Vec::new();
|
||||
for content_type in ["image/png", "video/mp4", "audio/mpeg", "image/svg+xml"] {
|
||||
for byte_range in [None, Some(ByteRange { start: 10, end: 19 })] {
|
||||
let mut headers = HeaderMap::new();
|
||||
http_headers::add_media_headers(&mut headers, 100, content_type, byte_range);
|
||||
responses.push(headers);
|
||||
}
|
||||
}
|
||||
let mut unsatisfiable = HeaderMap::new();
|
||||
http_headers::add_unsatisfiable_headers(&mut unsatisfiable, 4096);
|
||||
responses.push(unsatisfiable);
|
||||
let mut security_only = HeaderMap::new();
|
||||
http_headers::add_security_headers(&mut security_only);
|
||||
responses.push(security_only);
|
||||
|
||||
for headers in &responses {
|
||||
for negotiated in ["etag", "if-none-match", "if-modified-since", "age"] {
|
||||
assert!(
|
||||
headers.get(negotiated).is_none(),
|
||||
"{negotiated} was negotiated on a public media response"
|
||||
);
|
||||
}
|
||||
if let Some(vary) = headers.get(header::VARY) {
|
||||
assert_eq!(vary, "Accept-Encoding");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
mod adversarial;
|
||||
mod headers;
|
||||
mod parsers;
|
||||
@@ -0,0 +1,83 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
asset_hash::AssetHash,
|
||||
constants::{DEFAULT_IMAGE_SIZE, parse_image_size},
|
||||
image_quality::ImageQuality,
|
||||
image_transform::EncodeEffort,
|
||||
query::Query,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn asset_hash_parsing_stays_lenient_about_the_digest_shape() {
|
||||
for raw in [
|
||||
"",
|
||||
"deadbee",
|
||||
"deadbeef0",
|
||||
"DEADBEEF",
|
||||
"gggggggg",
|
||||
"0123abcd",
|
||||
"a-deadbeef",
|
||||
] {
|
||||
let parsed = AssetHash::parse(raw);
|
||||
assert_eq!(parsed.digest(), raw, "the digest rewrote {raw}");
|
||||
assert!(!parsed.is_animated(), "{raw} was read as animated");
|
||||
}
|
||||
for (raw, digest) in [
|
||||
("a_deadbeef", "deadbeef"),
|
||||
("a_DEADBEEF", "DEADBEEF"),
|
||||
("a_gggggggg", "gggggggg"),
|
||||
("a_", ""),
|
||||
] {
|
||||
let parsed = AssetHash::parse(raw);
|
||||
assert_eq!(parsed.digest(), digest, "the digest rewrote {raw}");
|
||||
assert!(parsed.is_animated(), "{raw} lost its animation prefix");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_query_values_fall_back_instead_of_failing_the_request() {
|
||||
for raw in ["", "-1", "1e3", "99999999999", "128px", " 128"] {
|
||||
assert_eq!(
|
||||
parse_image_size(Some(raw)),
|
||||
DEFAULT_IMAGE_SIZE,
|
||||
"size {raw} did not fall back"
|
||||
);
|
||||
}
|
||||
// A value that parses is snapped up the ladder, never dropped to the default: the old era
|
||||
// pinned this so an off-ladder request can never be served fewer pixels than it asked for.
|
||||
assert_eq!(parse_image_size(Some("0")), 16);
|
||||
assert_eq!(parse_image_size(Some("777")), 1024);
|
||||
assert_eq!(parse_image_size(Some("256")), 256);
|
||||
assert_eq!(parse_image_size(None), DEFAULT_IMAGE_SIZE);
|
||||
|
||||
for raw in ["", " ", "LOW", "lossy", "%FF", "high "] {
|
||||
assert_eq!(
|
||||
ImageQuality::parse_lenient(raw),
|
||||
ImageQuality::High,
|
||||
"quality {raw} did not fall back"
|
||||
);
|
||||
}
|
||||
assert_eq!(ImageQuality::parse_lenient("low"), ImageQuality::Low);
|
||||
|
||||
for raw in ["", "-1", "nine", "10.5", "256"] {
|
||||
assert_eq!(
|
||||
EncodeEffort::parse_lenient(raw),
|
||||
None,
|
||||
"effort {raw} was accepted"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
EncodeEffort::parse_lenient("250").map(EncodeEffort::get),
|
||||
Some(9)
|
||||
);
|
||||
|
||||
let query = Query::parse("animated=yes&download=TRUE&passthrough=1&empty=");
|
||||
assert!(!query.bool_value("animated", false));
|
||||
assert!(!query.bool_value("animated", true));
|
||||
assert!(query.bool_value("download", false));
|
||||
assert!(query.bool_value("passthrough", false));
|
||||
assert!(!query.bool_value("empty", true));
|
||||
assert!(query.bool_value("missing", true));
|
||||
assert!(!query.bool_value("missing", false));
|
||||
}
|
||||
Reference in new issue
Block a user