mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 13:35:42 +09:00
378 lines
12 KiB
Rust
378 lines
12 KiB
Rust
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
use base64::prelude::*;
|
|
use thiserror::Error;
|
|
|
|
const OPAQUE_PREFIX: &str = "v2/";
|
|
|
|
#[derive(Debug, Error, Eq, PartialEq)]
|
|
pub enum ExternalPathError {
|
|
#[error("invalid external path")]
|
|
InvalidExternalPath,
|
|
#[error("invalid base64")]
|
|
InvalidBase64,
|
|
#[error("invalid utf-8")]
|
|
InvalidUtf8,
|
|
}
|
|
|
|
pub fn build_opaque_external_media_proxy_path(input_url: &str) -> String {
|
|
format!(
|
|
"{OPAQUE_PREFIX}{}",
|
|
BASE64_URL_SAFE_NO_PAD.encode(input_url.as_bytes())
|
|
)
|
|
}
|
|
|
|
fn percent_encode_component(value: &str) -> String {
|
|
let mut out = String::with_capacity(value.len());
|
|
for byte in value.bytes() {
|
|
match byte {
|
|
b'A'..=b'Z'
|
|
| b'a'..=b'z'
|
|
| b'0'..=b'9'
|
|
| b'-'
|
|
| b'_'
|
|
| b'.'
|
|
| b'!'
|
|
| b'~'
|
|
| b'*'
|
|
| b'\''
|
|
| b'('
|
|
| b')' => out.push(byte as char),
|
|
_ => out.push_str(&format!("%{byte:02X}")),
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
pub fn build_external_media_proxy_path(input_url: &str) -> Result<String, ExternalPathError> {
|
|
let (scheme, remainder) = input_url
|
|
.split_once("://")
|
|
.ok_or(ExternalPathError::InvalidExternalPath)?;
|
|
if scheme.is_empty() || remainder.is_empty() {
|
|
return Err(ExternalPathError::InvalidExternalPath);
|
|
}
|
|
let fetchable = match remainder.split_once('#') {
|
|
Some((head, _)) => head,
|
|
None => remainder,
|
|
};
|
|
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);
|
|
}
|
|
let mut segments: Vec<String> = Vec::new();
|
|
if let Some(query) = query {
|
|
segments.push(percent_encode_component(&format!("?{query}")));
|
|
}
|
|
segments.push(scheme.to_owned());
|
|
segments.push(host.to_owned());
|
|
if !path.is_empty() {
|
|
segments.push(
|
|
path.split('/')
|
|
.map(percent_encode_component)
|
|
.collect::<Vec<_>>()
|
|
.join("/"),
|
|
);
|
|
}
|
|
Ok(segments.join("/"))
|
|
}
|
|
|
|
fn decode_opaque(proxy_path: &str) -> Result<String, ExternalPathError> {
|
|
let encoded = proxy_path
|
|
.strip_prefix(OPAQUE_PREFIX)
|
|
.ok_or(ExternalPathError::InvalidExternalPath)?;
|
|
if encoded.is_empty() {
|
|
return Err(ExternalPathError::InvalidExternalPath);
|
|
}
|
|
let bytes = BASE64_URL_SAFE_NO_PAD
|
|
.decode(encoded)
|
|
.map_err(|_| ExternalPathError::InvalidBase64)?;
|
|
String::from_utf8(bytes).map_err(|_| ExternalPathError::InvalidUtf8)
|
|
}
|
|
|
|
fn hex_val(c: u8) -> Option<u8> {
|
|
match c {
|
|
b'0'..=b'9' => Some(c - b'0'),
|
|
b'a'..=b'f' => Some(c - b'a' + 10),
|
|
b'A'..=b'F' => Some(c - b'A' + 10),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub fn percent_decode(input: &str, plus_as_space: bool) -> Vec<u8> {
|
|
let bytes = input.as_bytes();
|
|
let mut out = Vec::with_capacity(bytes.len());
|
|
let mut i = 0;
|
|
while i < bytes.len() {
|
|
let ch = bytes[i];
|
|
if ch == b'%'
|
|
&& i + 2 < bytes.len()
|
|
&& let (Some(hi), Some(lo)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2]))
|
|
{
|
|
out.push((hi << 4) | lo);
|
|
i += 3;
|
|
continue;
|
|
}
|
|
out.push(if plus_as_space && ch == b'+' {
|
|
b' '
|
|
} else {
|
|
ch
|
|
});
|
|
i += 1;
|
|
}
|
|
out
|
|
}
|
|
|
|
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 segmented_protocol_index(parts: &[&str]) -> Option<usize> {
|
|
for (index, part) in parts.iter().enumerate() {
|
|
if part.is_empty() {
|
|
continue;
|
|
}
|
|
if index == 0 && part.contains("%3D") {
|
|
continue;
|
|
}
|
|
let mut chars = part.bytes();
|
|
let first = chars.next()?;
|
|
if !first.is_ascii_alphabetic() {
|
|
continue;
|
|
}
|
|
if chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, b'+' | b'.' | b'-')) {
|
|
return Some(index);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn reconstruct_segmented(proxy_path: &str) -> Result<String, ExternalPathError> {
|
|
let parts: Vec<&str> = proxy_path.split('/').collect();
|
|
let protocol_index =
|
|
segmented_protocol_index(&parts).ok_or(ExternalPathError::InvalidExternalPath)?;
|
|
if protocol_index + 1 >= parts.len() {
|
|
return Err(ExternalPathError::InvalidExternalPath);
|
|
}
|
|
let protocol = parts[protocol_index];
|
|
let host_port = percent_decode_string(parts[protocol_index + 1], false);
|
|
let query_raw = parts[..protocol_index].join("/");
|
|
let path_raw = parts[protocol_index + 2..].join("/");
|
|
let query = percent_decode_string(&query_raw, false);
|
|
let path = percent_decode_string(&path_raw, false);
|
|
let normalized_query = query.strip_prefix('?').unwrap_or(&query);
|
|
Ok(format!(
|
|
"{protocol}://{host_port}/{path}{}{normalized_query}",
|
|
if normalized_query.is_empty() { "" } else { "?" }
|
|
))
|
|
}
|
|
|
|
pub fn reconstruct_original_url(proxy_path: &str) -> Result<String, ExternalPathError> {
|
|
if proxy_path.starts_with(OPAQUE_PREFIX) {
|
|
decode_opaque(proxy_path)
|
|
} else {
|
|
reconstruct_segmented(proxy_path)
|
|
}
|
|
}
|
|
|
|
type HmacSha256 = hmac::Hmac<sha2::Sha256>;
|
|
|
|
pub fn create_signature(input: &str, secret: &[u8]) -> String {
|
|
use hmac::{KeyInit, Mac};
|
|
let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts any key length");
|
|
mac.update(input.as_bytes());
|
|
BASE64_URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes())
|
|
}
|
|
|
|
pub fn build_external_media_proxy_url(
|
|
public_endpoint: &str,
|
|
input_url: &str,
|
|
secret: &[u8],
|
|
) -> Option<String> {
|
|
let path = build_external_media_proxy_path(input_url).ok()?;
|
|
let signature = create_signature(&path, secret);
|
|
Some(format!("{public_endpoint}/external/{signature}/{path}"))
|
|
}
|
|
|
|
use url::Url;
|
|
|
|
pub fn is_own_media_url(media_endpoint: &str, target: &Url) -> bool {
|
|
let Ok(base) = Url::parse(media_endpoint) else {
|
|
return false;
|
|
};
|
|
let same_host = match (base.host_str(), target.host_str()) {
|
|
(Some(base_host), Some(target_host)) => base_host.eq_ignore_ascii_case(target_host),
|
|
_ => false,
|
|
};
|
|
same_host
|
|
&& base.scheme().eq_ignore_ascii_case(target.scheme())
|
|
&& base.port_or_known_default() == target.port_or_known_default()
|
|
&& target
|
|
.path()
|
|
.strip_prefix(base.path().trim_end_matches('/'))
|
|
.is_some_and(|rest| rest.is_empty() || rest.starts_with('/'))
|
|
}
|
|
|
|
pub fn verify_signature(input: &str, provided: &str, secret: &[u8]) -> bool {
|
|
let expected = create_signature(input, secret);
|
|
if expected.len() != provided.len() {
|
|
return false;
|
|
}
|
|
expected
|
|
.bytes()
|
|
.zip(provided.bytes())
|
|
.fold(0u8, |diff, (a, b)| diff | (a ^ b))
|
|
== 0
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod signature_tests {
|
|
use super::*;
|
|
#[test]
|
|
fn signature_matches_node_hmac_base64url_behavior() {
|
|
let sig = create_signature("v2/aHR0cHM6Ly9leGFtcGxlLmNvbS8", b"secret");
|
|
assert_eq!("jkGeYYCiqJ67eS5T6lWJ4eQ77pwIaf6yoXXF5LncsHY", sig);
|
|
assert!(verify_signature(
|
|
"v2/aHR0cHM6Ly9leGFtcGxlLmNvbS8",
|
|
&sig,
|
|
b"secret"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn verify_rejects_tampered_signature() {
|
|
let sig = create_signature("input", b"secret");
|
|
let mut tampered = sig.clone().into_bytes();
|
|
tampered[0] = if tampered[0] == b'A' { b'B' } else { b'A' };
|
|
assert!(!verify_signature(
|
|
"input",
|
|
std::str::from_utf8(&tampered).unwrap(),
|
|
b"secret"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn verify_rejects_wrong_secret() {
|
|
let sig = create_signature("input", b"secret");
|
|
assert!(!verify_signature("input", &sig, b"different"));
|
|
}
|
|
|
|
#[test]
|
|
fn verify_rejects_wrong_input() {
|
|
let sig = create_signature("input-a", b"secret");
|
|
assert!(!verify_signature("input-b", &sig, b"secret"));
|
|
}
|
|
|
|
#[test]
|
|
fn verify_rejects_different_length_signature_without_panic() {
|
|
assert!(!verify_signature("input", "short", b"secret"));
|
|
assert!(!verify_signature(
|
|
"input",
|
|
"definitely-too-long-of-a-signature-for-hmac-sha256-base64url",
|
|
b"secret"
|
|
));
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn own_media_urls_require_the_same_origin_and_path_boundary() {
|
|
let endpoint = "https://chat.example.test/media/";
|
|
for target in [
|
|
"https://chat.example.test/media",
|
|
"https://CHAT.example.test:443/media/attachments/1/2/cat.gif",
|
|
] {
|
|
assert!(is_own_media_url(endpoint, &Url::parse(target).unwrap()));
|
|
}
|
|
for target in [
|
|
"https://chat.example.test/mediafiles/1.gif",
|
|
"https://static.klipy.com/a.webp",
|
|
"http://chat.example.test/media/a.gif",
|
|
"https://chat.example.test:444/media/a.gif",
|
|
] {
|
|
assert!(!is_own_media_url(endpoint, &Url::parse(target).unwrap()));
|
|
}
|
|
assert!(!is_own_media_url(
|
|
"invalid endpoint",
|
|
&Url::parse("https://chat.example.test/media").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 has a url");
|
|
let expected = vector["path"].as_str().expect("vector has 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 round_trips_every_shape() {
|
|
for url in [
|
|
"https://static.klipy.com/ii/c8/28/HkAKKCzZ.webp",
|
|
"https://static.klipy.com/ii/HkAKKCzZ.webp?v=query_param&goes=here",
|
|
"https://example.com:8443/a.png",
|
|
"https://avatars.githubusercontent.com/u/241303489?v=4",
|
|
"http://example.com/plain.gif",
|
|
"https://example.com/file.png?a=1&b=2&c=3",
|
|
] {
|
|
let path = build_external_media_proxy_path(url).unwrap();
|
|
assert_eq!(
|
|
url,
|
|
reconstruct_original_url(&path).unwrap(),
|
|
"round trip {url}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn does_not_double_the_question_mark() {
|
|
let decoded = reconstruct_original_url("%3Fa%3D1/https/example.com/x.png").unwrap();
|
|
assert_eq!("https://example.com/x.png?a=1", decoded);
|
|
assert!(!decoded.contains("??"));
|
|
}
|
|
|
|
#[test]
|
|
fn still_accepts_a_query_without_a_leading_question_mark() {
|
|
assert_eq!(
|
|
"https://example.com/x.png?a=1",
|
|
reconstruct_original_url("a%3D1/https/example.com/x.png").unwrap()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_a_url_without_a_scheme() {
|
|
assert!(build_external_media_proxy_path("example.com/a.png").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn v2_path_roundtrip() {
|
|
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);
|
|
}
|
|
}
|