mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
feat(media-proxy): sign attachment URLs and gate origins (#2830)
This commit is contained in:
@@ -8,6 +8,7 @@ publish = false
|
||||
[dependencies]
|
||||
fluxer_common = { path = "../fluxer_common" }
|
||||
anyhow = "1.0.104"
|
||||
base64 = "0.23"
|
||||
chrono = { version = "0.4.45", default-features = false, features = ["serde"] }
|
||||
fluxer-svc = { path = "../fluxer_svc" }
|
||||
fluxer_markdown_parser = { path = "../packages/markdown_parser/rust" }
|
||||
|
||||
@@ -15,6 +15,7 @@ use crate::types::{
|
||||
MessageSnapshot, MessageStickerItem,
|
||||
};
|
||||
use crate::udt;
|
||||
use base64::prelude::{BASE64_STANDARD, Engine};
|
||||
use chrono::{DateTime, Utc};
|
||||
use fluxer_common::user_flags::{USER_FLAG_STAFF, visible_user_flags};
|
||||
use fluxer_svc::shard::ShardService;
|
||||
@@ -254,6 +255,7 @@ struct ResponseBuildOptions {
|
||||
can_read_message_history: bool,
|
||||
media_endpoint: String,
|
||||
media_proxy_secret_key: String,
|
||||
attachment_url_secret: Vec<u8>,
|
||||
include_reactions: bool,
|
||||
nonce: Option<String>,
|
||||
tts: bool,
|
||||
@@ -1199,15 +1201,18 @@ impl<T: Transport> MessagesShard<T> {
|
||||
) -> Option<ApiMessageAttachmentResponse> {
|
||||
let attachment_id = attachment.attachment_id?;
|
||||
let filename = attachment.filename.clone().unwrap_or_default();
|
||||
let url = make_attachment_cdn_url(
|
||||
&options.media_endpoint,
|
||||
channel_id,
|
||||
attachment_id,
|
||||
&filename,
|
||||
);
|
||||
let decay = context.attachment_decay.get(&attachment_id);
|
||||
let expired =
|
||||
decay.is_some_and(|expires_at| expires_at.timestamp_millis() <= now_epoch_millis());
|
||||
let url = (!expired).then(|| {
|
||||
attachment_cdn_url(
|
||||
channel_id,
|
||||
attachment_id,
|
||||
&filename,
|
||||
options,
|
||||
now_epoch_secs(),
|
||||
)
|
||||
});
|
||||
let content_type = attachment.content_type.clone().unwrap_or_else(|| {
|
||||
mime_guess::from_path(&filename)
|
||||
.first_or_octet_stream()
|
||||
@@ -1223,8 +1228,8 @@ impl<T: Transport> MessagesShard<T> {
|
||||
content_type: Some(content_type),
|
||||
content_hash: attachment.content_hash.clone(),
|
||||
size: assert_safe_byte_size(attachment.size.unwrap_or_default()),
|
||||
url: (!expired).then_some(url.clone()),
|
||||
proxy_url: (!expired).then_some(url),
|
||||
url: url.clone(),
|
||||
proxy_url: url,
|
||||
width: (!is_audio).then_some(attachment.width).flatten(),
|
||||
height: (!is_audio).then_some(attachment.height).flatten(),
|
||||
placeholder: attachment.placeholder.clone(),
|
||||
@@ -1324,24 +1329,24 @@ impl<T: Transport> MessagesShard<T> {
|
||||
embed_type: embed_type.unwrap_or_else(|| "rich".to_owned()),
|
||||
title,
|
||||
description,
|
||||
url,
|
||||
url: url.map(|url| sign_own_url(&url, options)),
|
||||
timestamp: timestamp.map(epoch_millis_to_iso),
|
||||
color,
|
||||
author: author.and_then(|author| {
|
||||
author.name.map(|name| ApiEmbedAuthorResponse {
|
||||
name,
|
||||
url: author.url,
|
||||
url: author.url.map(|url| sign_own_url(&url, options)),
|
||||
proxy_icon_url: author
|
||||
.icon_url
|
||||
.as_ref()
|
||||
.map(|url| external_media_proxy_url(url, options)),
|
||||
icon_url: author.icon_url,
|
||||
icon_url: author.icon_url.map(|url| sign_own_url(&url, options)),
|
||||
})
|
||||
}),
|
||||
provider: provider.and_then(|provider| {
|
||||
provider.name.map(|name| ApiEmbedProviderResponse {
|
||||
name,
|
||||
url: provider.url,
|
||||
url: provider.url.map(|url| sign_own_url(&url, options)),
|
||||
icon_url: None,
|
||||
proxy_icon_url: None,
|
||||
})
|
||||
@@ -1357,7 +1362,7 @@ impl<T: Transport> MessagesShard<T> {
|
||||
.icon_url
|
||||
.as_ref()
|
||||
.map(|url| external_media_proxy_url(url, options)),
|
||||
icon_url: footer.icon_url,
|
||||
icon_url: footer.icon_url.map(|url| sign_own_url(&url, options)),
|
||||
})
|
||||
}),
|
||||
fields: fields.map(|fields| fields.into_iter().map(map_embed_field_response).collect()),
|
||||
@@ -1376,7 +1381,7 @@ impl<T: Transport> MessagesShard<T> {
|
||||
let url = media.url?;
|
||||
Some(ApiEmbedMediaResponse {
|
||||
proxy_url: external_media_proxy_url(&url, options),
|
||||
url,
|
||||
url: sign_own_url(&url, options),
|
||||
width: media.width,
|
||||
height: media.height,
|
||||
duration: media.duration,
|
||||
@@ -2151,6 +2156,7 @@ impl<T: Transport> ShardService for MessagesShard<T> {
|
||||
can_read_message_history,
|
||||
media_endpoint,
|
||||
media_proxy_secret_key,
|
||||
attachment_url_secret_base64,
|
||||
include_reactions,
|
||||
nonce,
|
||||
tts,
|
||||
@@ -2173,6 +2179,9 @@ impl<T: Transport> ShardService for MessagesShard<T> {
|
||||
can_read_message_history,
|
||||
media_endpoint,
|
||||
media_proxy_secret_key,
|
||||
attachment_url_secret: decode_attachment_url_secret(
|
||||
attachment_url_secret_base64.as_deref(),
|
||||
),
|
||||
include_reactions: include_reactions.unwrap_or(true),
|
||||
nonce,
|
||||
tts: tts.unwrap_or(false),
|
||||
@@ -2192,6 +2201,7 @@ impl<T: Transport> ShardService for MessagesShard<T> {
|
||||
can_read_message_history,
|
||||
media_endpoint,
|
||||
media_proxy_secret_key,
|
||||
attachment_url_secret_base64,
|
||||
include_reactions,
|
||||
nonce,
|
||||
tts,
|
||||
@@ -2211,6 +2221,9 @@ impl<T: Transport> ShardService for MessagesShard<T> {
|
||||
can_read_message_history,
|
||||
media_endpoint,
|
||||
media_proxy_secret_key,
|
||||
attachment_url_secret: decode_attachment_url_secret(
|
||||
attachment_url_secret_base64.as_deref(),
|
||||
),
|
||||
include_reactions: include_reactions.unwrap_or(true),
|
||||
nonce,
|
||||
tts: tts.unwrap_or(false),
|
||||
@@ -2230,6 +2243,7 @@ impl<T: Transport> ShardService for MessagesShard<T> {
|
||||
can_read_message_history,
|
||||
media_endpoint,
|
||||
media_proxy_secret_key,
|
||||
attachment_url_secret_base64,
|
||||
include_reactions,
|
||||
} => {
|
||||
let viewer_user_id = parse_i64(&viewer_user_id, "viewer_user_id")?;
|
||||
@@ -2247,6 +2261,9 @@ impl<T: Transport> ShardService for MessagesShard<T> {
|
||||
can_read_message_history,
|
||||
media_endpoint,
|
||||
media_proxy_secret_key,
|
||||
attachment_url_secret: decode_attachment_url_secret(
|
||||
attachment_url_secret_base64.as_deref(),
|
||||
),
|
||||
include_reactions: include_reactions.unwrap_or(true),
|
||||
nonce: None,
|
||||
tts: false,
|
||||
@@ -2267,6 +2284,7 @@ impl<T: Transport> ShardService for MessagesShard<T> {
|
||||
can_read_message_history,
|
||||
media_endpoint,
|
||||
media_proxy_secret_key,
|
||||
attachment_url_secret_base64,
|
||||
include_reactions,
|
||||
} => {
|
||||
let channel_id = parse_i64(&channel_id, "channel_id")?;
|
||||
@@ -2301,6 +2319,9 @@ impl<T: Transport> ShardService for MessagesShard<T> {
|
||||
can_read_message_history,
|
||||
media_endpoint,
|
||||
media_proxy_secret_key,
|
||||
attachment_url_secret: decode_attachment_url_secret(
|
||||
attachment_url_secret_base64.as_deref(),
|
||||
),
|
||||
include_reactions: include_reactions.unwrap_or(true),
|
||||
nonce: None,
|
||||
tts: false,
|
||||
@@ -2331,6 +2352,13 @@ fn now_epoch_millis() -> i64 {
|
||||
.as_millis() as i64
|
||||
}
|
||||
|
||||
fn now_epoch_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
fn message_row_key(channel_id: i64, bucket: i32, message_id: i64) -> anyhow::Result<String> {
|
||||
postgres::kv_key(&[
|
||||
KeyPart::BigInt(channel_id),
|
||||
@@ -2816,6 +2844,10 @@ fn map_call(call: &MessageCall) -> ApiMessageCallResponse {
|
||||
}
|
||||
}
|
||||
|
||||
fn make_attachment_cdn_key(channel_id: i64, attachment_id: i64, filename: &str) -> String {
|
||||
format!("attachments/{channel_id}/{attachment_id}/{filename}")
|
||||
}
|
||||
|
||||
fn make_attachment_cdn_url(
|
||||
media_endpoint: &str,
|
||||
channel_id: i64,
|
||||
@@ -2823,27 +2855,173 @@ fn make_attachment_cdn_url(
|
||||
filename: &str,
|
||||
) -> String {
|
||||
format!(
|
||||
"{}/attachments/{}/{}/{}",
|
||||
"{}/{}",
|
||||
media_endpoint.trim_end_matches('/'),
|
||||
channel_id,
|
||||
make_attachment_cdn_key(channel_id, attachment_id, filename)
|
||||
)
|
||||
}
|
||||
|
||||
fn decode_attachment_url_secret(encoded: Option<&str>) -> Vec<u8> {
|
||||
let Some(encoded) = encoded.filter(|value| !value.is_empty()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
match BASE64_STANDARD.decode(encoded) {
|
||||
Ok(secret) => secret,
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "attachment url secret is not standard base64");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn attachment_anchor_secs(attachment_id: i64) -> u64 {
|
||||
snowflake_to_epoch_millis(attachment_id).max(0) as u64 / 1_000
|
||||
}
|
||||
|
||||
fn sign_attachment_url(
|
||||
url: &str,
|
||||
storage_key: &str,
|
||||
attachment_id: i64,
|
||||
now_secs: u64,
|
||||
secret: &[u8],
|
||||
) -> String {
|
||||
if secret.is_empty() {
|
||||
return url.to_owned();
|
||||
}
|
||||
fluxer_common::attachment_url_signature::with_signature(
|
||||
url,
|
||||
storage_key,
|
||||
attachment_anchor_secs(attachment_id),
|
||||
now_secs,
|
||||
secret,
|
||||
)
|
||||
}
|
||||
|
||||
fn attachment_cdn_url(
|
||||
channel_id: i64,
|
||||
attachment_id: i64,
|
||||
filename: &str,
|
||||
options: &ResponseBuildOptions,
|
||||
now_secs: u64,
|
||||
) -> String {
|
||||
let storage_key = make_attachment_cdn_key(channel_id, attachment_id, filename);
|
||||
let url = make_attachment_cdn_url(&options.media_endpoint, channel_id, attachment_id, filename);
|
||||
sign_attachment_url(
|
||||
&url,
|
||||
&storage_key,
|
||||
attachment_id,
|
||||
filename
|
||||
now_secs,
|
||||
&options.attachment_url_secret,
|
||||
)
|
||||
}
|
||||
|
||||
fn is_own_endpoint(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.starts_with('/'))
|
||||
}
|
||||
|
||||
fn raw_url_path(url: &str) -> &str {
|
||||
let Some((_, after_scheme)) = url.split_once("://") else {
|
||||
return "";
|
||||
};
|
||||
let Some(start) = after_scheme.find(['/', '?', '#']) else {
|
||||
return "";
|
||||
};
|
||||
let path = &after_scheme[start..];
|
||||
if !path.starts_with('/') {
|
||||
return "";
|
||||
}
|
||||
path.find(['?', '#']).map_or(path, |end| &path[..end])
|
||||
}
|
||||
|
||||
fn own_attachment_key(input_url: &str, media_endpoint: &str) -> Option<(String, i64)> {
|
||||
let endpoint_path = raw_url_path(media_endpoint).trim_end_matches('/');
|
||||
let path = raw_url_path(input_url).strip_prefix(endpoint_path)?;
|
||||
if !path.starts_with("/attachments/") {
|
||||
return None;
|
||||
}
|
||||
let storage_key = fluxer_common::attachment_url_signature::decode_key(path)?;
|
||||
let attachment_id = attachment_key_id(&storage_key)?;
|
||||
Some((storage_key, attachment_id))
|
||||
}
|
||||
|
||||
fn attachment_key_id(storage_key: &str) -> Option<i64> {
|
||||
let segments: Vec<&str> = storage_key.split('/').collect();
|
||||
let ["attachments", channel_id, attachment_id, filename @ ..] = segments.as_slice() else {
|
||||
return None;
|
||||
};
|
||||
if !is_snowflake_segment(channel_id)
|
||||
|| !is_snowflake_segment(attachment_id)
|
||||
|| filename.is_empty()
|
||||
|| filename
|
||||
.iter()
|
||||
.any(|part| matches!(*part, "" | "." | "..") || part.contains('\0'))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
attachment_id.parse().ok()
|
||||
}
|
||||
|
||||
fn is_snowflake_segment(segment: &str) -> bool {
|
||||
(1..=20).contains(&segment.len()) && segment.bytes().all(|byte| byte.is_ascii_digit())
|
||||
}
|
||||
|
||||
fn sign_own_media_url(
|
||||
input_url: &str,
|
||||
media_endpoint: &str,
|
||||
now_secs: u64,
|
||||
secret: &[u8],
|
||||
) -> String {
|
||||
let Some((storage_key, attachment_id)) = own_attachment_key(input_url, media_endpoint) else {
|
||||
return input_url.to_owned();
|
||||
};
|
||||
sign_attachment_url(input_url, &storage_key, attachment_id, now_secs, secret)
|
||||
}
|
||||
|
||||
fn sign_own_url(input_url: &str, options: &ResponseBuildOptions) -> String {
|
||||
sign_own_media_url(
|
||||
input_url,
|
||||
options.media_endpoint.trim_end_matches('/'),
|
||||
now_epoch_secs(),
|
||||
&options.attachment_url_secret,
|
||||
)
|
||||
}
|
||||
|
||||
fn external_media_proxy_url(input_url: &str, options: &ResponseBuildOptions) -> String {
|
||||
if input_url.starts_with(options.media_endpoint.trim_end_matches('/')) {
|
||||
return input_url.to_owned();
|
||||
}
|
||||
if options.media_proxy_secret_key.is_empty() {
|
||||
return input_url.to_owned();
|
||||
}
|
||||
media_proxy_url_at(input_url, options, now_epoch_secs())
|
||||
}
|
||||
|
||||
fn media_proxy_url_at(input_url: &str, options: &ResponseBuildOptions, now_secs: u64) -> String {
|
||||
let media_endpoint = options.media_endpoint.trim_end_matches('/');
|
||||
let parsed_url = match Url::parse(input_url) {
|
||||
Ok(url) => url,
|
||||
Err(_) => return input_url.to_owned(),
|
||||
};
|
||||
if is_own_endpoint(media_endpoint, &parsed_url) {
|
||||
return sign_own_media_url(
|
||||
input_url,
|
||||
media_endpoint,
|
||||
now_secs,
|
||||
&options.attachment_url_secret,
|
||||
);
|
||||
}
|
||||
if options.media_proxy_secret_key.is_empty() {
|
||||
return input_url.to_owned();
|
||||
}
|
||||
fluxer_common::external_media_path::build_external_media_proxy_url(
|
||||
options.media_endpoint.trim_end_matches('/'),
|
||||
media_endpoint,
|
||||
parsed_url.as_str(),
|
||||
options.media_proxy_secret_key.as_bytes(),
|
||||
)
|
||||
@@ -3536,6 +3714,7 @@ mod tests {
|
||||
can_read_message_history: true,
|
||||
media_endpoint: "https://media.example.com".to_owned(),
|
||||
media_proxy_secret_key: "secret".to_owned(),
|
||||
attachment_url_secret: Vec::new(),
|
||||
include_reactions: false,
|
||||
nonce: None,
|
||||
tts: false,
|
||||
@@ -3774,4 +3953,580 @@ mod tests {
|
||||
assert!(response.is_none());
|
||||
assert!(recorded_deletions(&deleted).is_empty());
|
||||
}
|
||||
|
||||
const SIGNATURE_VECTORS: &str =
|
||||
include_str!("../../fluxer_common/src/testdata/attachment_url_signature_vectors.json");
|
||||
const SIGNED_CHANNEL_ID: i64 = 1_544_725_486_800_732_163;
|
||||
const SIGNED_ATTACHMENT_ID: i64 = 1_544_971_349_200_470_016;
|
||||
const SIGNED_FILENAME: &str = "cat.gif";
|
||||
const SIGNED_ANCHOR_SECS: u64 = 1_788_420_273;
|
||||
|
||||
fn signing_options() -> ResponseBuildOptions {
|
||||
ResponseBuildOptions {
|
||||
media_endpoint: "https://media.test".to_owned(),
|
||||
attachment_url_secret: (0u8..32).collect(),
|
||||
..build_options()
|
||||
}
|
||||
}
|
||||
|
||||
fn signed_attachment() -> MessageAttachment {
|
||||
serde_json::from_value(json!({
|
||||
"attachment_id": SIGNED_ATTACHMENT_ID.to_string(),
|
||||
"filename": SIGNED_FILENAME,
|
||||
"size": 1024,
|
||||
}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn signed_storage_key() -> String {
|
||||
make_attachment_cdn_key(SIGNED_CHANNEL_ID, SIGNED_ATTACHMENT_ID, SIGNED_FILENAME)
|
||||
}
|
||||
|
||||
fn unsigned_attachment_url(options: &ResponseBuildOptions) -> String {
|
||||
make_attachment_cdn_url(
|
||||
&options.media_endpoint,
|
||||
SIGNED_CHANNEL_ID,
|
||||
SIGNED_ATTACHMENT_ID,
|
||||
SIGNED_FILENAME,
|
||||
)
|
||||
}
|
||||
|
||||
fn assert_signs(url: &str, unsigned_prefix: &str, options: &ResponseBuildOptions, now: u64) {
|
||||
assert_eq!(
|
||||
Some(format!("{unsigned_prefix}?")),
|
||||
url.split_once("ex=").map(|(head, _)| head.to_owned()),
|
||||
"{url}"
|
||||
);
|
||||
assert!(!url.contains("/external/"), "{url}");
|
||||
assert!(!url.ends_with('&'), "{url}");
|
||||
assert!(!url.contains("&&"), "{url}");
|
||||
let query = url.split_once('?').expect("a signed url carries a query").1;
|
||||
assert_eq!(
|
||||
fluxer_common::attachment_url_signature::Verdict::Valid,
|
||||
fluxer_common::attachment_url_signature::verify(
|
||||
&signed_storage_key(),
|
||||
Some(query),
|
||||
&[&options.attachment_url_secret],
|
||||
now,
|
||||
)
|
||||
.verdict,
|
||||
"{url}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_signing_anchor_is_the_attachment_snowflake_second() {
|
||||
assert_eq!(
|
||||
SIGNED_ANCHOR_SECS,
|
||||
attachment_anchor_secs(SIGNED_ATTACHMENT_ID)
|
||||
);
|
||||
assert_eq!(0, attachment_anchor_secs(i64::MIN));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_urls_match_the_shared_signature_fixture() {
|
||||
let fixture: serde_json::Value = serde_json::from_str(SIGNATURE_VECTORS).unwrap();
|
||||
let secret = BASE64_STANDARD
|
||||
.decode(fixture["secrets_base64"][0].as_str().unwrap())
|
||||
.unwrap();
|
||||
let options = ResponseBuildOptions {
|
||||
attachment_url_secret: secret,
|
||||
..signing_options()
|
||||
};
|
||||
let parsed = fixture["sign"].as_array().unwrap();
|
||||
let mut run = 0;
|
||||
let mut data_packages = 0;
|
||||
for case in parsed {
|
||||
let name = case["name"].as_str().unwrap();
|
||||
if case["uc"].as_str().unwrap() == "dp" {
|
||||
data_packages += 1;
|
||||
continue;
|
||||
}
|
||||
assert_eq!("", case["uc"].as_str().unwrap(), "{name} uc");
|
||||
let channel_id: i64 = case["channel_id"].as_str().unwrap().parse().unwrap();
|
||||
let attachment_id: i64 = case["attachment_id"].as_str().unwrap().parse().unwrap();
|
||||
let filename = case["filename"].as_str().unwrap();
|
||||
let now = case["now"].as_u64().unwrap();
|
||||
let url = case["url"].as_str().unwrap();
|
||||
let signed = case["signed"].as_str().unwrap();
|
||||
assert_eq!(
|
||||
case["anchor"].as_u64().unwrap(),
|
||||
attachment_anchor_secs(attachment_id),
|
||||
"{name} anchor"
|
||||
);
|
||||
assert_eq!(
|
||||
case["storage_key"].as_str().unwrap(),
|
||||
make_attachment_cdn_key(channel_id, attachment_id, filename),
|
||||
"{name} key"
|
||||
);
|
||||
let unsigned = make_attachment_cdn_url(
|
||||
&options.media_endpoint,
|
||||
channel_id,
|
||||
attachment_id,
|
||||
filename,
|
||||
);
|
||||
assert_eq!(
|
||||
Some(unsigned.as_str()),
|
||||
url.split(['?', '#']).next(),
|
||||
"{name} unsigned url"
|
||||
);
|
||||
if url == unsigned {
|
||||
assert_eq!(
|
||||
signed,
|
||||
attachment_cdn_url(channel_id, attachment_id, filename, &options, now),
|
||||
"{name} attachment url"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
signed,
|
||||
media_proxy_url_at(url, &options, now),
|
||||
"{name} embed url"
|
||||
);
|
||||
run += 1;
|
||||
}
|
||||
assert_eq!(parsed.len(), run + data_packages);
|
||||
assert!(run >= 5);
|
||||
assert!(data_packages >= 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_attachment_signs_url_and_proxy_url_identically() {
|
||||
let deleted = DeletedMessageKeys::default();
|
||||
let shard = recording_shard(&deleted);
|
||||
let options = signing_options();
|
||||
let mapped = shard
|
||||
.map_attachment(
|
||||
SIGNED_CHANNEL_ID,
|
||||
&signed_attachment(),
|
||||
&options,
|
||||
&ResponseContext::default(),
|
||||
)
|
||||
.expect("an attachment carrying an id maps");
|
||||
|
||||
let url = mapped.url.expect("a live attachment carries a url");
|
||||
assert_eq!(Some(url.clone()), mapped.proxy_url);
|
||||
assert_signs(
|
||||
&url,
|
||||
&unsigned_attachment_url(&options),
|
||||
&options,
|
||||
now_epoch_secs(),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_own_url_whose_filename_carries_a_slash_is_signed() {
|
||||
let options = signing_options();
|
||||
let now = SIGNED_ANCHOR_SECS + 10;
|
||||
let key = "attachments/1544725486800732163/1544971349200470016/a/b.gif";
|
||||
|
||||
for url in [
|
||||
format!("{}/{key}", options.media_endpoint),
|
||||
format!(
|
||||
"{}/attachments/1544725486800732163/1544971349200470016/a%2Fb.gif",
|
||||
options.media_endpoint
|
||||
),
|
||||
] {
|
||||
let signed = media_proxy_url_at(&url, &options, now);
|
||||
assert_eq!(
|
||||
Some(format!("{url}?")),
|
||||
signed.split_once("ex=").map(|(head, _)| head.to_owned()),
|
||||
"{url}"
|
||||
);
|
||||
let query = signed
|
||||
.split_once('?')
|
||||
.expect("a signed url carries a query")
|
||||
.1;
|
||||
assert_eq!(
|
||||
fluxer_common::attachment_url_signature::Verdict::Valid,
|
||||
fluxer_common::attachment_url_signature::verify(
|
||||
key,
|
||||
Some(query),
|
||||
&[&options.attachment_url_secret],
|
||||
now,
|
||||
)
|
||||
.verdict,
|
||||
"{url}"
|
||||
);
|
||||
}
|
||||
|
||||
for refused in [
|
||||
format!(
|
||||
"{}/attachments/1544725486800732163/1544971349200470016/a//b.gif",
|
||||
options.media_endpoint
|
||||
),
|
||||
format!(
|
||||
"{}/attachments/1544725486800732163/1544971349200470016/a/../b.gif",
|
||||
options.media_endpoint
|
||||
),
|
||||
format!(
|
||||
"{}/attachments/1544725486800732163/1544971349200470016/a/",
|
||||
options.media_endpoint
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
refused,
|
||||
media_proxy_url_at(&refused, &options, now),
|
||||
"{refused}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_decayed_attachment_is_never_signed() {
|
||||
let deleted = DeletedMessageKeys::default();
|
||||
let shard = recording_shard(&deleted);
|
||||
let options = signing_options();
|
||||
let context = ResponseContext {
|
||||
attachment_decay: [(
|
||||
SIGNED_ATTACHMENT_ID,
|
||||
DateTime::<Utc>::from_timestamp_millis(now_epoch_millis() - 1_000).unwrap(),
|
||||
)]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
..ResponseContext::default()
|
||||
};
|
||||
|
||||
let mapped = shard
|
||||
.map_attachment(SIGNED_CHANNEL_ID, &signed_attachment(), &options, &context)
|
||||
.expect("a decayed attachment still maps");
|
||||
|
||||
assert_eq!(None, mapped.url);
|
||||
assert_eq!(None, mapped.proxy_url);
|
||||
assert_eq!(Some(true), mapped.expired);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_attachment_backed_embed_signs_every_own_url_field() {
|
||||
let deleted = DeletedMessageKeys::default();
|
||||
let shard = recording_shard(&deleted);
|
||||
let options = signing_options();
|
||||
let unsigned = unsigned_attachment_url(&options);
|
||||
let embed: MessageEmbed = serde_json::from_value(json!({
|
||||
"type": "rich",
|
||||
"url": unsigned,
|
||||
"author": {"name": "author", "url": unsigned, "icon_url": unsigned},
|
||||
"provider": {"name": "provider", "url": unsigned},
|
||||
"footer": {"text": "footer", "icon_url": unsigned},
|
||||
"image": {"url": unsigned},
|
||||
"thumbnail": {"url": unsigned},
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let mapped = shard.map_embed(&embed, &options);
|
||||
|
||||
let now = now_epoch_secs();
|
||||
let base = mapped.base;
|
||||
let author = base.author.expect("the embed carries an author");
|
||||
let provider = base.provider.expect("the embed carries a provider");
|
||||
let footer = base.footer.expect("the embed carries a footer");
|
||||
let image = base.image.expect("the embed carries an image");
|
||||
let thumbnail = base.thumbnail.expect("the embed carries a thumbnail");
|
||||
for signed in [
|
||||
base.url.expect("the embed carries a url"),
|
||||
author.url.expect("the author carries a url"),
|
||||
author.icon_url.expect("the author carries an icon url"),
|
||||
author
|
||||
.proxy_icon_url
|
||||
.expect("the author carries a proxy icon url"),
|
||||
provider.url.expect("the provider carries a url"),
|
||||
footer.icon_url.expect("the footer carries an icon url"),
|
||||
footer
|
||||
.proxy_icon_url
|
||||
.expect("the footer carries a proxy icon url"),
|
||||
image.url.clone(),
|
||||
image.proxy_url.clone(),
|
||||
thumbnail.url,
|
||||
thumbnail.proxy_url,
|
||||
] {
|
||||
assert_signs(&signed, &unsigned, &options, now);
|
||||
}
|
||||
assert_eq!(image.url, image.proxy_url);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_foreign_embed_url_is_never_rewritten() {
|
||||
let deleted = DeletedMessageKeys::default();
|
||||
let shard = recording_shard(&deleted);
|
||||
let options = signing_options();
|
||||
let embed: MessageEmbed = serde_json::from_value(json!({
|
||||
"type": "link",
|
||||
"url": "https://example.com/article",
|
||||
"author": {"name": "author", "url": "https://example.com/author"},
|
||||
"provider": {"name": "provider", "url": "https://example.com"},
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let mapped = shard.map_embed(&embed, &options);
|
||||
|
||||
let base = mapped.base;
|
||||
assert_eq!(
|
||||
Some("https://example.com/article".to_owned()),
|
||||
base.url,
|
||||
"a page link never reaches the media proxy"
|
||||
);
|
||||
assert_eq!(
|
||||
Some("https://example.com/author".to_owned()),
|
||||
base.author.expect("the embed carries an author").url
|
||||
);
|
||||
assert_eq!(
|
||||
Some("https://example.com".to_owned()),
|
||||
base.provider.expect("the embed carries a provider").url
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_own_attachment_embed_url_is_signed_for_every_origin_spelling() {
|
||||
let options = signing_options();
|
||||
let now = SIGNED_ANCHOR_SECS;
|
||||
for prefix in [
|
||||
"https://media.test",
|
||||
"https://MEDIA.test",
|
||||
"https://Media.Test",
|
||||
"https://media.test:443",
|
||||
] {
|
||||
let input = format!("{prefix}/{}", signed_storage_key());
|
||||
let signed = media_proxy_url_at(&input, &options, now);
|
||||
assert_signs(&signed, &input, &options, now);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_own_attachment_embed_url_keeps_its_transform_parameters_and_fragment() {
|
||||
let options = signing_options();
|
||||
let now = SIGNED_ANCHOR_SECS;
|
||||
let input = format!(
|
||||
"https://media.test/{}?width=64&format=webp#frame",
|
||||
signed_storage_key()
|
||||
);
|
||||
|
||||
let signed = media_proxy_url_at(&input, &options, now);
|
||||
|
||||
assert!(signed.ends_with("&width=64&format=webp#frame"), "{signed}");
|
||||
assert!(!signed.contains("/external/"), "{signed}");
|
||||
let query = signed
|
||||
.split_once('?')
|
||||
.expect("a signed url carries a query")
|
||||
.1
|
||||
.split_once('#')
|
||||
.expect("the fragment is kept")
|
||||
.0;
|
||||
assert_eq!(
|
||||
fluxer_common::attachment_url_signature::Verdict::Valid,
|
||||
fluxer_common::attachment_url_signature::verify(
|
||||
&signed_storage_key(),
|
||||
Some(query),
|
||||
&[&options.attachment_url_secret],
|
||||
now,
|
||||
)
|
||||
.verdict
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_own_endpoint_url_outside_attachments_passes_through_unsigned() {
|
||||
let options = signing_options();
|
||||
let now = SIGNED_ANCHOR_SECS;
|
||||
for input in [
|
||||
"https://media.test/emojis/1544725486800732163.webp",
|
||||
"https://media.test/avatars/1/abc.png",
|
||||
"https://media.test/stickers/1/abc.png",
|
||||
"https://media.test/attachments/1544725486800732163",
|
||||
"https://media.test/attachments/1544725486800732163/1544971349200470016",
|
||||
"https://media.test/attachments/1544725486800732163/1544971349200470016/",
|
||||
"https://media.test/attachments/1544725486800732163/not-a-snowflake/cat.gif",
|
||||
] {
|
||||
assert_eq!(input, media_proxy_url_at(input, &options, now), "{input}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_foreign_url_is_still_wrapped_into_the_external_path() {
|
||||
let options = signing_options();
|
||||
let now = SIGNED_ANCHOR_SECS;
|
||||
for input in [
|
||||
"https://example.com/attachments/1544725486800732163/1544971349200470016/cat.gif",
|
||||
"https://media.test.example.com/attachments/1/2/cat.gif",
|
||||
"http://media.test/attachments/1/2/cat.gif",
|
||||
"https://media.test:8443/attachments/1/2/cat.gif",
|
||||
] {
|
||||
let wrapped = media_proxy_url_at(input, &options, now);
|
||||
assert!(
|
||||
wrapped.starts_with("https://media.test/external/"),
|
||||
"{input}"
|
||||
);
|
||||
assert!(!wrapped.contains("ex="), "{input}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_absent_attachment_secret_leaves_every_url_unsigned() {
|
||||
let deleted = DeletedMessageKeys::default();
|
||||
let shard = recording_shard(&deleted);
|
||||
let options = ResponseBuildOptions {
|
||||
attachment_url_secret: Vec::new(),
|
||||
..signing_options()
|
||||
};
|
||||
let unsigned = unsigned_attachment_url(&options);
|
||||
|
||||
let mapped = shard
|
||||
.map_attachment(
|
||||
SIGNED_CHANNEL_ID,
|
||||
&signed_attachment(),
|
||||
&options,
|
||||
&ResponseContext::default(),
|
||||
)
|
||||
.expect("an attachment carrying an id maps");
|
||||
|
||||
assert_eq!(Some(unsigned.clone()), mapped.url);
|
||||
assert_eq!(Some(unsigned.clone()), mapped.proxy_url);
|
||||
assert_eq!(
|
||||
unsigned,
|
||||
media_proxy_url_at(&unsigned, &options, SIGNED_ANCHOR_SECS)
|
||||
);
|
||||
assert_eq!(
|
||||
unsigned,
|
||||
attachment_cdn_url(
|
||||
SIGNED_CHANNEL_ID,
|
||||
SIGNED_ATTACHMENT_ID,
|
||||
SIGNED_FILENAME,
|
||||
&options,
|
||||
SIGNED_ANCHOR_SECS
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_self_hosted_path_prefix_only_matches_whole_segments() {
|
||||
let options = ResponseBuildOptions {
|
||||
media_endpoint: "https://self.test/media".to_owned(),
|
||||
..signing_options()
|
||||
};
|
||||
let now = SIGNED_ANCHOR_SECS;
|
||||
let own = format!("https://self.test/media/{}", signed_storage_key());
|
||||
|
||||
let signed = media_proxy_url_at(&own, &options, now);
|
||||
|
||||
assert_signs(&signed, &own, &options, now);
|
||||
let foreign = format!("https://self.test/mediaxyz/{}", signed_storage_key());
|
||||
assert!(
|
||||
media_proxy_url_at(&foreign, &options, now)
|
||||
.starts_with("https://self.test/media/external/")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_percent_encoded_filename_signs_the_decoded_key() {
|
||||
let options = signing_options();
|
||||
let now = SIGNED_ANCHOR_SECS;
|
||||
let encoded =
|
||||
"https://media.test/attachments/1544725486800732163/1544971349200470016/caf%C3%A9.gif";
|
||||
let decoded =
|
||||
"https://media.test/attachments/1544725486800732163/1544971349200470016/café.gif";
|
||||
let key = "attachments/1544725486800732163/1544971349200470016/café.gif";
|
||||
|
||||
for input in [encoded, decoded] {
|
||||
let signed = media_proxy_url_at(input, &options, now);
|
||||
let query = signed
|
||||
.split_once('?')
|
||||
.expect("a signed url carries a query")
|
||||
.1;
|
||||
assert_eq!(
|
||||
fluxer_common::attachment_url_signature::Verdict::Valid,
|
||||
fluxer_common::attachment_url_signature::verify(
|
||||
key,
|
||||
Some(query),
|
||||
&[&options.attachment_url_secret],
|
||||
now,
|
||||
)
|
||||
.verdict,
|
||||
"{input}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_percent_encoded_slash_signs_the_key_the_media_proxy_decodes() {
|
||||
let options = signing_options();
|
||||
let now = SIGNED_ANCHOR_SECS;
|
||||
for input in [
|
||||
"https://media.test/attachments/1544725486800732163%2F1544971349200470016/cat.gif",
|
||||
"https://media.test/attachments/1544725486800732163%2f1544971349200470016%2Fcat.gif",
|
||||
"https://media.test/attachments/%31544725486800732163/1544971349200470016/cat.gif",
|
||||
] {
|
||||
let raw_path = input.strip_prefix("https://media.test").unwrap();
|
||||
assert_eq!(
|
||||
Some(signed_storage_key()),
|
||||
fluxer_common::attachment_url_signature::decode_key(raw_path),
|
||||
"{input}"
|
||||
);
|
||||
let signed = media_proxy_url_at(input, &options, now);
|
||||
assert_signs(&signed, input, &options, now);
|
||||
}
|
||||
for input in [
|
||||
"https://media.test/attachments/1544725486800732163/1544971349200470016/%2F",
|
||||
"https://media.test/attachments/1544725486800732163/1544971349200470016/cat.gif%00",
|
||||
] {
|
||||
assert_eq!(input, media_proxy_url_at(input, &options, now), "{input}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dot_segment_is_never_signed_under_its_normalised_key() {
|
||||
let options = signing_options();
|
||||
let now = SIGNED_ANCHOR_SECS;
|
||||
for input in [
|
||||
"https://media.test/attachments/1544725486800732163/9/../1544971349200470016/cat.gif",
|
||||
"https://media.test/attachments/1544725486800732163/1544971349200470016/./cat.gif",
|
||||
"https://media.test/attachments/1544725486800732163/1544971349200470016/%2E/cat.gif",
|
||||
"https://media.test/attachments/1544725486800732163/9/%2e%2e/1544971349200470016/cat.gif",
|
||||
] {
|
||||
assert_eq!(
|
||||
format!("/{}", signed_storage_key()),
|
||||
Url::parse(input).unwrap().path(),
|
||||
"{input}"
|
||||
);
|
||||
assert_eq!(input, media_proxy_url_at(input, &options, now), "{input}");
|
||||
}
|
||||
for input in [
|
||||
"https://media.test/attachments/1544725486800732163/1544971349200470016/..",
|
||||
"https://media.test/attachments/+1544725486800732163/1544971349200470016/cat.gif",
|
||||
"https://media.test/attachments/1544725486800732163/-1544971349200470016/cat.gif",
|
||||
"https://media.test/attachments/1544725486800732163/1544971349200470016/caf%C3%28.gif",
|
||||
] {
|
||||
assert_eq!(input, media_proxy_url_at(input, &options, now), "{input}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_without_the_secret_field_still_decodes() {
|
||||
let request: MessageRequest = serde_json::from_value(json!({
|
||||
"op": "ListResponses",
|
||||
"channel_id": "10",
|
||||
"viewer_user_id": "1",
|
||||
"limit": 50,
|
||||
"can_read_message_history": true,
|
||||
"media_endpoint": "https://media.test",
|
||||
"media_proxy_secret_key": "secret",
|
||||
}))
|
||||
.expect("an older caller omits the attachment secret");
|
||||
|
||||
match request {
|
||||
MessageRequest::ListResponses {
|
||||
attachment_url_secret_base64,
|
||||
..
|
||||
} => assert_eq!(None, attachment_url_secret_base64),
|
||||
other => panic!("unexpected request {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_secret_that_is_not_base64_disables_signing() {
|
||||
assert!(decode_attachment_url_secret(None).is_empty());
|
||||
assert!(decode_attachment_url_secret(Some("")).is_empty());
|
||||
assert!(decode_attachment_url_secret(Some("not base64!")).is_empty());
|
||||
assert_eq!(
|
||||
(0u8..32).collect::<Vec<u8>>(),
|
||||
decode_attachment_url_secret(Some("AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +91,8 @@ pub enum MessageRequest {
|
||||
can_read_message_history: bool,
|
||||
media_endpoint: String,
|
||||
media_proxy_secret_key: String,
|
||||
#[serde(default)]
|
||||
attachment_url_secret_base64: Option<String>,
|
||||
include_reactions: Option<bool>,
|
||||
nonce: Option<String>,
|
||||
tts: Option<bool>,
|
||||
@@ -103,6 +105,8 @@ pub enum MessageRequest {
|
||||
can_read_message_history: bool,
|
||||
media_endpoint: String,
|
||||
media_proxy_secret_key: String,
|
||||
#[serde(default)]
|
||||
attachment_url_secret_base64: Option<String>,
|
||||
include_reactions: Option<bool>,
|
||||
nonce: Option<String>,
|
||||
tts: Option<bool>,
|
||||
@@ -115,6 +119,8 @@ pub enum MessageRequest {
|
||||
can_read_message_history: bool,
|
||||
media_endpoint: String,
|
||||
media_proxy_secret_key: String,
|
||||
#[serde(default)]
|
||||
attachment_url_secret_base64: Option<String>,
|
||||
include_reactions: Option<bool>,
|
||||
},
|
||||
ListResponses {
|
||||
@@ -129,6 +135,8 @@ pub enum MessageRequest {
|
||||
can_read_message_history: bool,
|
||||
media_endpoint: String,
|
||||
media_proxy_secret_key: String,
|
||||
#[serde(default)]
|
||||
attachment_url_secret_base64: Option<String>,
|
||||
include_reactions: Option<bool>,
|
||||
},
|
||||
ExtractMentions {
|
||||
|
||||
Reference in New Issue
Block a user