mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
fix(media-proxy): stop proxy paths carrying fragments or credentials (#1835)
This commit is contained in:
@@ -20,3 +20,6 @@ hmac = "0.13.0"
|
||||
sha2 = "0.11.0"
|
||||
thiserror = "2"
|
||||
urlencoding = "2.1.3"
|
||||
|
||||
[dev-dependencies]
|
||||
url = "2.5.8"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use base64::prelude::*;
|
||||
use thiserror::Error;
|
||||
|
||||
const V2_PREFIX: &str = "v2/";
|
||||
const OPAQUE_PREFIX: &str = "v2/";
|
||||
|
||||
#[derive(Debug, Error, Eq, PartialEq)]
|
||||
pub enum ExternalPathError {
|
||||
@@ -15,9 +15,9 @@ pub enum ExternalPathError {
|
||||
InvalidUtf8,
|
||||
}
|
||||
|
||||
pub fn build_v2_external_media_proxy_path(input_url: &str) -> String {
|
||||
pub fn build_opaque_external_media_proxy_path(input_url: &str) -> String {
|
||||
format!(
|
||||
"{V2_PREFIX}{}",
|
||||
"{OPAQUE_PREFIX}{}",
|
||||
BASE64_URL_SAFE_NO_PAD.encode(input_url.as_bytes())
|
||||
)
|
||||
}
|
||||
@@ -51,14 +51,22 @@ pub fn build_external_media_proxy_path(input_url: &str) -> Result<String, Extern
|
||||
if scheme.is_empty() || remainder.is_empty() {
|
||||
return Err(ExternalPathError::InvalidExternalPath);
|
||||
}
|
||||
let (authority_and_path, query) = match remainder.split_once('?') {
|
||||
Some((head, tail)) => (head, Some(tail)),
|
||||
None => (remainder, None),
|
||||
let fetchable = match remainder.split_once('#') {
|
||||
Some((head, _)) => head,
|
||||
None => remainder,
|
||||
};
|
||||
let (host, path) = match authority_and_path.split_once('/') {
|
||||
Some((host, path)) => (host, path),
|
||||
let (authority_and_path, query) = match fetchable.split_once('?') {
|
||||
Some((head, tail)) => (head, (!tail.is_empty()).then_some(tail)),
|
||||
None => (fetchable, None),
|
||||
};
|
||||
let (authority, path) = match authority_and_path.split_once('/') {
|
||||
Some((authority, path)) => (authority, path),
|
||||
None => (authority_and_path, ""),
|
||||
};
|
||||
let host = match authority.rsplit_once('@') {
|
||||
Some((_, after_credentials)) => after_credentials,
|
||||
None => authority,
|
||||
};
|
||||
if host.is_empty() {
|
||||
return Err(ExternalPathError::InvalidExternalPath);
|
||||
}
|
||||
@@ -79,9 +87,9 @@ pub fn build_external_media_proxy_path(input_url: &str) -> Result<String, Extern
|
||||
Ok(segments.join("/"))
|
||||
}
|
||||
|
||||
fn decode_v2(proxy_path: &str) -> Result<String, ExternalPathError> {
|
||||
fn decode_opaque(proxy_path: &str) -> Result<String, ExternalPathError> {
|
||||
let encoded = proxy_path
|
||||
.strip_prefix(V2_PREFIX)
|
||||
.strip_prefix(OPAQUE_PREFIX)
|
||||
.ok_or(ExternalPathError::InvalidExternalPath)?;
|
||||
if encoded.is_empty() {
|
||||
return Err(ExternalPathError::InvalidExternalPath);
|
||||
@@ -129,7 +137,7 @@ pub fn percent_decode_string(input: &str, plus_as_space: bool) -> String {
|
||||
String::from_utf8_lossy(&percent_decode(input, plus_as_space)).into_owned()
|
||||
}
|
||||
|
||||
fn legacy_protocol_index(parts: &[&str]) -> Option<usize> {
|
||||
fn segmented_protocol_index(parts: &[&str]) -> Option<usize> {
|
||||
for (index, part) in parts.iter().enumerate() {
|
||||
if part.is_empty() {
|
||||
continue;
|
||||
@@ -149,10 +157,10 @@ fn legacy_protocol_index(parts: &[&str]) -> Option<usize> {
|
||||
None
|
||||
}
|
||||
|
||||
fn reconstruct_legacy(proxy_path: &str) -> Result<String, ExternalPathError> {
|
||||
fn reconstruct_segmented(proxy_path: &str) -> Result<String, ExternalPathError> {
|
||||
let parts: Vec<&str> = proxy_path.split('/').collect();
|
||||
let protocol_index =
|
||||
legacy_protocol_index(&parts).ok_or(ExternalPathError::InvalidExternalPath)?;
|
||||
segmented_protocol_index(&parts).ok_or(ExternalPathError::InvalidExternalPath)?;
|
||||
if protocol_index + 1 >= parts.len() {
|
||||
return Err(ExternalPathError::InvalidExternalPath);
|
||||
}
|
||||
@@ -170,10 +178,10 @@ fn reconstruct_legacy(proxy_path: &str) -> Result<String, ExternalPathError> {
|
||||
}
|
||||
|
||||
pub fn reconstruct_original_url(proxy_path: &str) -> Result<String, ExternalPathError> {
|
||||
if proxy_path.starts_with(V2_PREFIX) {
|
||||
decode_v2(proxy_path)
|
||||
if proxy_path.starts_with(OPAQUE_PREFIX) {
|
||||
decode_opaque(proxy_path)
|
||||
} else {
|
||||
reconstruct_legacy(proxy_path)
|
||||
reconstruct_segmented(proxy_path)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,6 +228,56 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_the_fragment_out_of_the_signed_path() {
|
||||
assert_eq!(
|
||||
build_external_media_proxy_path("https://example.com/a.gif").unwrap(),
|
||||
build_external_media_proxy_path("https://example.com/a.gif#anchor").unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
build_external_media_proxy_path("https://example.com/a.gif?v=1").unwrap(),
|
||||
build_external_media_proxy_path("https://example.com/a.gif?v=1#anchor").unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_credentials_out_of_the_host_segment() {
|
||||
assert_eq!(
|
||||
"https/cdn.example.com/a.gif",
|
||||
build_external_media_proxy_path("https://reader:[email protected]/a.gif").unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
"https/cdn.example.com/a.gif",
|
||||
build_external_media_proxy_path("https://[email protected]/a.gif").unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_out_a_query_that_reconstructs_to_nothing() {
|
||||
assert_eq!(
|
||||
build_external_media_proxy_path("https://example.com/a.gif").unwrap(),
|
||||
build_external_media_proxy_path("https://example.com/a.gif?").unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_the_typescript_builder_on_the_shared_vectors() {
|
||||
let raw = include_str!("testdata/external_media_proxy_path_vectors.json");
|
||||
let vectors: serde_json::Value = serde_json::from_str(raw).expect("vectors parse as json");
|
||||
let vectors = vectors.as_array().expect("vectors are an array");
|
||||
assert!(!vectors.is_empty());
|
||||
for vector in vectors {
|
||||
let original = vector["url"].as_str().expect("vector carries a url");
|
||||
let expected = vector["path"].as_str().expect("vector carries a path");
|
||||
let normalized = url::Url::parse(original).expect("vector url parses");
|
||||
assert_eq!(
|
||||
expected,
|
||||
build_external_media_proxy_path(normalized.as_str()).expect("path builds"),
|
||||
"vector {original}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_a_non_default_port() {
|
||||
assert_eq!(
|
||||
@@ -269,7 +327,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn v2_path_roundtrip() {
|
||||
let path = build_v2_external_media_proxy_path("https://example.com/a b.png?x=1");
|
||||
let path = build_opaque_external_media_proxy_path("https://example.com/a b.png?x=1");
|
||||
let decoded = reconstruct_original_url(&path).unwrap();
|
||||
assert_eq!("https://example.com/a b.png?x=1", decoded);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
[
|
||||
{
|
||||
"url": "https://static.example.com/a.gif",
|
||||
"path": "https/static.example.com/a.gif"
|
||||
},
|
||||
{
|
||||
"url": "https://EXAMPLE.com/a.gif",
|
||||
"path": "https/example.com/a.gif"
|
||||
},
|
||||
{
|
||||
"url": "https://example.com:443/a.gif",
|
||||
"path": "https/example.com/a.gif"
|
||||
},
|
||||
{
|
||||
"url": "http://example.com:80/a.gif",
|
||||
"path": "http/example.com/a.gif"
|
||||
},
|
||||
{
|
||||
"url": "https://example.com:8443/a.gif",
|
||||
"path": "https/example.com:8443/a.gif"
|
||||
},
|
||||
{
|
||||
"url": "https://example.com/a/./b/../c.gif",
|
||||
"path": "https/example.com/a/c.gif"
|
||||
},
|
||||
{
|
||||
"url": "https://example.com/a b.gif",
|
||||
"path": "https/example.com/a%2520b.gif"
|
||||
},
|
||||
{
|
||||
"url": "https://example.com/café.gif",
|
||||
"path": "https/example.com/caf%25C3%25A9.gif"
|
||||
},
|
||||
{
|
||||
"url": "https://☃.example.com/a.gif",
|
||||
"path": "https/xn--n3h.example.com/a.gif"
|
||||
},
|
||||
{
|
||||
"url": "https://example.com/a.gif?v=1&x=2",
|
||||
"path": "%3Fv%3D1%26x%3D2/https/example.com/a.gif"
|
||||
},
|
||||
{
|
||||
"url": "https://example.com/a.gif#anchor",
|
||||
"path": "https/example.com/a.gif"
|
||||
},
|
||||
{
|
||||
"url": "https://example.com/a.gif?v=1#anchor",
|
||||
"path": "%3Fv%3D1/https/example.com/a.gif"
|
||||
},
|
||||
{
|
||||
"url": "https://reader:[email protected]/a.gif",
|
||||
"path": "https/cdn.example.com/a.gif"
|
||||
},
|
||||
{
|
||||
"url": "https://example.com/a.gif?",
|
||||
"path": "https/example.com/a.gif"
|
||||
},
|
||||
{
|
||||
"url": "https://[::1]:8443/a.gif",
|
||||
"path": "https/[::1]:8443/a.gif"
|
||||
},
|
||||
{
|
||||
"url": "https://example.com",
|
||||
"path": "https/example.com"
|
||||
},
|
||||
{
|
||||
"url": "https://example.com/p%2Fq.gif",
|
||||
"path": "https/example.com/p%252Fq.gif"
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user