Files
fluxer/fluxer_media_proxy/src/server/routes/dispatch.rs
T

2249 lines
85 KiB
Rust

// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
config::{DeploymentMode, PolicyMode},
constants::AssetKind,
request_log::RequestId,
server::{
asset_path::{
StorageKeyDecodeError, decode_storage_key, parse_entrance_sound_path,
parse_guild_member_asset_path, parse_simple_asset_path, parse_standard_asset_path,
},
attachment_signature,
cors::{self, OriginCheck},
external::serve_external,
response::error::{text, text_with_source},
state::AppState,
stored,
},
};
use axum::{
body::Body,
extract::{Query, State},
http::{HeaderMap, Method, Request, StatusCode},
response::Response,
};
use fluxer_common::attachment_url_signature::{SIGNATURE_PARAMETER_NAMES, Verdict};
use std::{collections::HashMap, sync::Arc};
struct PublicRead<'a> {
method: Method,
path: &'a str,
query: Option<&'a str>,
params: &'a HashMap<String, String>,
headers: &'a HeaderMap,
request_id: Option<&'a RequestId>,
}
pub(in crate::server) async fn catch_all(
State(app): State<Arc<AppState>>,
Query(params): Query<HashMap<String, String>>,
request: Request<Body>,
) -> Response {
let method = request.method().clone();
if app.cfg.mode != DeploymentMode::Relay && cors::is_preflight(&method, request.headers()) {
return preflight(&app, request.uri().path(), request.headers());
}
if method != Method::GET && method != Method::HEAD {
return text(StatusCode::METHOD_NOT_ALLOWED, "Method Not Allowed");
}
if app.cfg.mode == DeploymentMode::Relay {
return text(StatusCode::NOT_FOUND, "Not Found");
}
let path = request.uri().path().to_owned();
if app.cfg.mode == DeploymentMode::Static {
let key = match decode_storage_key(&path) {
Ok(key) => key,
Err(err) => return storage_key_decode_response(err),
};
return stored::serve_stored_raw(
&app,
method,
&app.cfg.storage.bucket_static,
&key,
request.headers(),
)
.await;
}
let query = request.uri().query().map(ToOwned::to_owned);
let request_id = request.extensions().get::<RequestId>().cloned();
let read = PublicRead {
method: method.clone(),
path: &path,
query: query.as_deref(),
params: &params,
headers: request.headers(),
request_id: request_id.as_ref(),
};
if app.cfg.cors.mode == PolicyMode::Off {
return serve_public_read(&app, read).await;
}
let check = cors::check_origin(&app.cfg.cors.allowed_origins, request.headers());
if check == OriginCheck::Refused {
if app.cfg.cors.mode == PolicyMode::Enforce {
return cors::refused_response(request.headers());
}
cors::log_would_refuse(&method, &path, request.headers());
}
let mut response = serve_public_read(&app, read).await;
if app.cfg.cors.mode == PolicyMode::Enforce {
cors::narrow_response(&check, response.headers_mut());
}
response
}
fn preflight(app: &AppState, path: &str, headers: &HeaderMap) -> Response {
if app.cfg.cors.mode == PolicyMode::Off {
return cors::preflight_response(&OriginCheck::Absent);
}
let check = cors::check_origin(&app.cfg.cors.allowed_origins, headers);
if check != OriginCheck::Refused {
return cors::preflight_response(&check);
}
if app.cfg.cors.mode == PolicyMode::Enforce {
return cors::refused_response(headers);
}
cors::log_would_refuse(&Method::OPTIONS, path, headers);
cors::preflight_response(&check)
}
async fn serve_public_read(app: &Arc<AppState>, read: PublicRead<'_>) -> Response {
let PublicRead {
method,
path,
query,
params,
headers,
request_id,
} = read;
if let Some(rest) = path.strip_prefix("/external/") {
return serve_external(app, method, rest, params, headers).await;
}
if path.starts_with("/attachments/") {
let key = match decode_storage_key(path) {
Ok(key) => key,
Err(err) => return storage_key_decode_response(err),
};
let signature_mode = app.cfg.attachment_signature.mode;
let mut checked = None;
if signature_mode != PolicyMode::Off {
let verification =
attachment_signature::check(&app.cfg.attachment_signature, path, query);
app.metrics
.attachment_signature()
.record(verification.verdict);
if verification.verdict != Verdict::Valid {
if signature_mode == PolicyMode::Enforce {
return attachment_signature::denied_response(verification.verdict);
}
attachment_signature::log_would_deny(
&app.signature_log,
&method,
verification.verdict,
headers,
request_id,
);
}
checked = Some(verification);
}
let params = without_signature_parameters(params);
let mut response = stored::serve_attachment(app, method, &key, &params, headers).await;
if let Some(verification) = checked {
attachment_signature::limit_cache_lifetime(&mut response, verification);
}
return response;
}
if path.starts_with("/themes/") && path.ends_with(".css") {
let key = match decode_storage_key(path) {
Ok(key) => key,
Err(err) => return storage_key_decode_response(err),
};
return stored::serve_stored_with_override(
app,
method,
&app.cfg.storage.bucket_cdn,
&key,
"text/css; charset=utf-8",
headers,
)
.await;
}
if let Some(key) = parse_entrance_sound_path(path) {
return stored::serve_stored_raw(app, method, &app.cfg.storage.bucket_cdn, &key, headers)
.await;
}
if let Some(asset) = parse_guild_member_asset_path(path) {
return stored::serve_asset_image(app, method, asset, params, headers).await;
}
if let Some(asset) = parse_simple_asset_path(path, AssetKind::Emoji) {
return stored::serve_asset_image(app, method, asset, params, headers).await;
}
if let Some(asset) = parse_simple_asset_path(path, AssetKind::Sticker) {
return stored::serve_asset_image(app, method, asset, params, headers).await;
}
if let Some(asset) = parse_standard_asset_path(path) {
return stored::serve_asset_image(app, method, asset, params, headers).await;
}
text(StatusCode::NOT_FOUND, "Not Found")
}
fn storage_key_decode_response(err: StorageKeyDecodeError) -> Response {
text_with_source(
StatusCode::BAD_REQUEST,
"Bad Request",
"invalid_storage_key",
err,
)
}
fn without_signature_parameters(params: &HashMap<String, String>) -> HashMap<String, String> {
params
.iter()
.filter(|(name, _)| !SIGNATURE_PARAMETER_NAMES.contains(&name.as_str()))
.map(|(name, value)| (name.clone(), value.clone()))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{config::Config, constants::DESKTOP_APP_ORIGIN};
use axum::{
body::to_bytes,
http::{HeaderValue, header},
};
use base64::{Engine as _, engine::general_purpose};
use bytes::Bytes;
use fluxer_common::attachment_url_signature::{
ATTACHMENT_URL_TTL_SECS, UrlKind, issue_window, sign, with_data_package_signature,
with_signature,
};
use std::{
path::Path,
sync::Mutex,
time::{SystemTime, UNIX_EPOCH},
};
use tracing_subscriber::fmt::MakeWriter;
#[derive(Clone, Default)]
struct CapturedLog(Arc<Mutex<Vec<u8>>>);
impl CapturedLog {
fn text(&self) -> String {
String::from_utf8(self.0.lock().expect("captured log is not poisoned").clone())
.expect("captured log is utf-8")
}
}
impl std::io::Write for CapturedLog {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0
.lock()
.expect("captured log is not poisoned")
.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl<'a> MakeWriter<'a> for CapturedLog {
type Writer = Self;
fn make_writer(&'a self) -> Self::Writer {
self.clone()
}
}
const STORED_BYTES: &[u8] = b"raw-stored-bytes";
const THEME_BYTES: &[u8] = b"body{color:#fff}";
const WEB: &str = "https://web.fluxer.app";
const EVIL: &str = "https://evil.example";
const OPAQUE_ORIGIN: &[u8] = &[0xC3, 0x28];
const ORIGIN_VARY: &str = "Accept-Encoding, Origin";
const ATTACHMENT_PATH: &str = "/attachments/1/2/file.bin";
const THEME_PATH: &str = "/themes/dark.css";
const ENTRANCE_SOUND_PATH: &str = "/entrance-sounds/42/abc123def456.wav";
const ASSET_PATH: &str = "/avatars/123456789012345678/a1b2c3d4e5f6.png";
const BAD_SIGNATURE_PATH: &str = "/external/badsig/https/example.test/a.png";
const ENFORCE: &[(&str, &str)] = &[
("FLUXER_MEDIA_PROXY_CORS_MODE", "enforce"),
("FLUXER_MEDIA_PROXY_CORS_ALLOWED_ORIGINS", WEB),
];
const REPORT: &[(&str, &str)] = &[
("FLUXER_MEDIA_PROXY_CORS_MODE", "report"),
("FLUXER_MEDIA_PROXY_CORS_ALLOWED_ORIGINS", WEB),
];
fn dispatch_config(mode: &str, storage_root: &Path, extra: &[(&str, &str)]) -> Config {
let mut env = vec![
(
"FLUXER_MEDIA_PROXY_SECRET_KEY".to_owned(),
"secret".to_owned(),
),
("FLUXER_MEDIA_PROXY_MODE".to_owned(), mode.to_owned()),
(
"FLUXER_MEDIA_PROXY_STORAGE_BACKEND".to_owned(),
"local".to_owned(),
),
(
"FLUXER_MEDIA_PROXY_STORAGE_ROOT".to_owned(),
storage_root.display().to_string(),
),
(
"FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64".to_owned(),
general_purpose::STANDARD.encode([7u8; 32]),
),
];
env.extend(
extra
.iter()
.map(|(key, value)| ((*key).to_owned(), (*value).to_owned())),
);
Config::load_from_iter(env).expect("dispatch test config")
}
fn dispatch_app(mode: &str, storage_root: &Path, extra: &[(&str, &str)]) -> Arc<AppState> {
Arc::new(AppState::for_tests(dispatch_config(
mode,
storage_root,
extra,
)))
}
fn write_object(storage_root: &Path, bucket: &str, key: &str, body: &[u8]) {
let path = storage_root.join(bucket).join(key);
std::fs::create_dir_all(path.parent().expect("object parent")).expect("create bucket dir");
std::fs::write(path, body).expect("write object");
}
fn write_stored_representations(storage_root: &Path) {
write_object(
storage_root,
"cdn",
"attachments/1/2/file.bin",
STORED_BYTES,
);
write_object(storage_root, "cdn", "themes/dark.css", THEME_BYTES);
}
fn read_request(
method: Method,
path: &str,
origins: &[&[u8]],
range: Option<&str>,
) -> Request<Body> {
let mut builder = Request::builder().method(method).uri(path);
for origin in origins {
builder = builder.header(
header::ORIGIN,
HeaderValue::from_bytes(origin).expect("origin header value"),
);
}
if let Some(range) = range {
builder = builder.header(header::RANGE, range);
}
builder.body(Body::empty()).expect("dispatch request")
}
async fn dispatch_request(app: &Arc<AppState>, request: Request<Body>) -> Response {
catch_all(State(Arc::clone(app)), Query(HashMap::new()), request).await
}
async fn dispatch(app: &Arc<AppState>, method: Method, path: &str) -> Response {
dispatch_request(app, read_request(method, path, &[], None)).await
}
async fn dispatch_body(app: &Arc<AppState>, path: &str) -> Bytes {
let response = dispatch(app, Method::GET, path).await;
assert_eq!(StatusCode::OK, response.status(), "{path} must be served");
to_bytes(response.into_body(), usize::MAX)
.await
.expect("dispatch body")
}
async fn body_of(response: Response) -> Bytes {
to_bytes(response.into_body(), usize::MAX)
.await
.expect("response body")
}
fn vary_values(response: &Response) -> Vec<String> {
response
.headers()
.get_all(header::VARY)
.iter()
.map(|value| value.to_str().expect("vary is ascii").to_owned())
.collect()
}
fn refused_origin_shapes() -> [(&'static str, Vec<&'static [u8]>); 4] {
[
("foreign", vec![EVIL.as_bytes()]),
("null", vec![b"null".as_slice()]),
("duplicated", vec![WEB.as_bytes(), WEB.as_bytes()]),
("malformed", vec![OPAQUE_ORIGIN]),
]
}
fn stored_representation_cases() -> [(Method, &'static str, Option<&'static str>, StatusCode); 7]
{
[
(Method::GET, ATTACHMENT_PATH, None, StatusCode::OK),
(
Method::GET,
ATTACHMENT_PATH,
Some("bytes=0-3"),
StatusCode::PARTIAL_CONTENT,
),
(
Method::GET,
ATTACHMENT_PATH,
Some("bytes=100-200"),
StatusCode::RANGE_NOT_SATISFIABLE,
),
(Method::HEAD, ATTACHMENT_PATH, None, StatusCode::OK),
(
Method::HEAD,
ATTACHMENT_PATH,
Some("bytes=0-3"),
StatusCode::PARTIAL_CONTENT,
),
(Method::GET, THEME_PATH, None, StatusCode::OK),
(
Method::GET,
THEME_PATH,
Some("bytes=999-"),
StatusCode::RANGE_NOT_SATISFIABLE,
),
]
}
#[tokio::test]
async fn static_mode_short_circuits_every_path_to_the_static_bucket() {
let tmp = tempfile::tempdir().expect("storage root");
let root = tmp.path().canonicalize().expect("canonical storage root");
let root = root.as_path();
write_object(root, "static", "external/v2/signed.png", STORED_BYTES);
write_object(
root,
"static",
"avatars/1216100949629702144/a1b2c3d4e5f6.png",
STORED_BYTES,
);
write_object(root, "cdn", "themes/only-in-cdn.css", STORED_BYTES);
let app = dispatch_app("static", root, &[]);
assert_eq!(
STORED_BYTES,
dispatch_body(&app, "/external/v2/signed.png").await
);
assert_eq!(
STORED_BYTES,
dispatch_body(&app, "/avatars/1216100949629702144/a1b2c3d4e5f6.png").await
);
assert_eq!(
StatusCode::NOT_FOUND,
dispatch(&app, Method::GET, "/themes/only-in-cdn.css")
.await
.status(),
"static mode never reaches the cdn bucket"
);
assert_eq!(
StatusCode::NOT_FOUND,
dispatch(&app, Method::GET, "/attachments/1/2/file.bin")
.await
.status()
);
}
const STATIC_CACHE_CONTROL: &str = "public, max-age=31536000";
const STATIC_KEYS: [&str; 4] = [
"avatars/0.png",
"web/favicon.ico",
"emoji/1f600.svg",
"web/NOTICE.md",
];
fn static_mode_app(root: &Path, extra: &[(&str, &str)]) -> Arc<AppState> {
for key in STATIC_KEYS {
write_object(root, "static", key, STORED_BYTES);
}
dispatch_app("static", root, extra)
}
fn header_value(response: &Response, name: impl header::AsHeaderName) -> Option<String> {
response
.headers()
.get(name)
.map(|value| value.to_str().expect("header is ascii").to_owned())
}
#[tokio::test]
async fn static_mode_caches_every_asset_forever() {
let tmp = tempfile::tempdir().expect("storage root");
let root = tmp.path().canonicalize().expect("canonical storage root");
let app = static_mode_app(root.as_path(), &[]);
for key in STATIC_KEYS {
let response = dispatch(&app, Method::GET, &format!("/{key}")).await;
assert_eq!(StatusCode::OK, response.status(), "key={key}");
assert_eq!(
Some(STATIC_CACHE_CONTROL.to_owned()),
header_value(&response, header::CACHE_CONTROL),
"key={key}"
);
}
}
#[tokio::test]
async fn static_mode_omits_expires_and_relies_on_cache_control() {
let tmp = tempfile::tempdir().expect("storage root");
let root = tmp.path().canonicalize().expect("canonical storage root");
let app = static_mode_app(root.as_path(), &[]);
let response = dispatch(&app, Method::GET, "/avatars/0.png").await;
assert_eq!(StatusCode::OK, response.status());
assert_eq!(None, header_value(&response, header::EXPIRES));
assert_eq!(
Some(STATIC_CACHE_CONTROL.to_owned()),
header_value(&response, "CDN-Cache-Control")
);
}
#[tokio::test]
async fn media_mode_matches_the_v1_route_order() {
let tmp = tempfile::tempdir().expect("storage root");
let root = tmp.path().canonicalize().expect("canonical storage root");
let root = root.as_path();
write_object(root, "cdn", "themes/dark.css", THEME_BYTES);
write_object(
root,
"cdn",
"entrance-sounds/42/abc123def456.wav",
STORED_BYTES,
);
write_object(root, "static", "themes/dark.css", STORED_BYTES);
let app = dispatch_app("mp", root, &[]);
let response = dispatch(&app, Method::GET, "/themes/dark.css").await;
assert_eq!(StatusCode::OK, response.status());
assert_eq!(
"text/css; charset=utf-8",
response
.headers()
.get(header::CONTENT_TYPE)
.expect("content type")
.to_str()
.expect("ascii content type")
);
assert_eq!(
THEME_BYTES,
to_bytes(response.into_body(), usize::MAX)
.await
.expect("theme body")
);
assert_eq!(
STORED_BYTES,
dispatch_body(&app, "/entrance-sounds/42/abc123def456.wav").await
);
assert_eq!(
StatusCode::NOT_FOUND,
dispatch(&app, Method::GET, "/themes/dark.png")
.await
.status(),
"the themes branch only matches .css"
);
assert_eq!(
StatusCode::NOT_FOUND,
dispatch(&app, Method::GET, "/nothing/here").await.status()
);
assert_eq!(
StatusCode::METHOD_NOT_ALLOWED,
dispatch(&app, Method::POST, "/themes/dark.css")
.await
.status()
);
}
#[tokio::test]
async fn relay_mode_answers_every_read_path_with_404() {
let tmp = tempfile::tempdir().expect("storage root");
let root = tmp.path().canonicalize().expect("canonical storage root");
let root = root.as_path();
write_object(root, "cdn", "attachments/1/2/stream.js", STORED_BYTES);
write_object(root, "cdn", "themes/dark.css", THEME_BYTES);
write_object(
root,
"cdn",
"entrance-sounds/42/abc123def456.wav",
STORED_BYTES,
);
let app = dispatch_app("relay", root, &[]);
for path in [
"/attachments/1/2/stream.js",
"/external/sig/https/example.com/a.png",
THEME_PATH,
ENTRANCE_SOUND_PATH,
ASSET_PATH,
"/emojis/1.png",
"/stickers/1.webp",
"/",
"/nothing/here",
] {
for method in [Method::GET, Method::HEAD] {
let response = dispatch(&app, method.clone(), path).await;
assert_eq!(StatusCode::NOT_FOUND, response.status(), "{method} {path}");
assert_eq!(
Some("text/plain; charset=utf-8".to_owned()),
header_value(&response, header::CONTENT_TYPE),
"{method} {path}"
);
assert_eq!(
Some("no-store".to_owned()),
header_value(&response, header::CACHE_CONTROL),
"{method} {path}"
);
assert_eq!(
None,
header_value(&response, header::ACCESS_CONTROL_ALLOW_ORIGIN),
"{method} {path}"
);
}
}
assert!(
app.metrics
.render()
.contains("fluxer_media_proxy_storage_hits_total 0\n")
);
}
#[tokio::test]
async fn upload_mode_serves_what_relay_mode_refuses() {
let tmp = tempfile::tempdir().expect("storage root");
let root = tmp.path().canonicalize().expect("canonical storage root");
let root = root.as_path();
write_object(root, "cdn", "attachments/1/2/stream.js", STORED_BYTES);
let upload = dispatch_app("upload", root, &[]);
let relay = dispatch_app("relay", root, &[]);
assert_eq!(
STORED_BYTES,
dispatch_body(&upload, "/attachments/1/2/stream.js").await
);
assert_eq!(
StatusCode::NOT_FOUND,
dispatch(&relay, Method::GET, "/attachments/1/2/stream.js")
.await
.status()
);
}
#[tokio::test]
async fn relay_mode_rejects_a_bad_storage_key_as_404_not_400() {
let tmp = tempfile::tempdir().expect("storage root");
let root = tmp.path().canonicalize().expect("canonical storage root");
let root = root.as_path();
let upload = dispatch_app("upload", root, &[]);
let relay = dispatch_app("relay", root, &[]);
assert_eq!(
StatusCode::BAD_REQUEST,
dispatch(&upload, Method::GET, "/attachments/%2e%2e/x")
.await
.status()
);
assert_eq!(
StatusCode::NOT_FOUND,
dispatch(&relay, Method::GET, "/attachments/%2e%2e/x")
.await
.status()
);
}
#[tokio::test]
async fn relay_mode_keeps_405_for_other_methods() {
let tmp = tempfile::tempdir().expect("storage root");
let root = tmp.path().canonicalize().expect("canonical storage root");
let app = dispatch_app("relay", root.as_path(), &[]);
for path in ["/attachments/1/2/stream.js", "/nothing/here"] {
for method in [
Method::POST,
Method::PUT,
Method::DELETE,
Method::PATCH,
Method::OPTIONS,
] {
assert_eq!(
StatusCode::METHOD_NOT_ALLOWED,
dispatch(&app, method.clone(), path).await.status(),
"{method} {path}"
);
}
}
}
#[tokio::test]
async fn enforce_echoes_an_allowed_origin_on_every_stored_representation() {
let tmp = tempfile::tempdir().expect("storage root");
let root = tmp.path().canonicalize().expect("canonical storage root");
let root = root.as_path();
write_stored_representations(root);
let app = dispatch_app("mp", root, ENFORCE);
for (method, path, range, status) in stored_representation_cases() {
let label = format!("{method} {path} range={range:?}");
let response =
dispatch_request(&app, read_request(method, path, &[WEB.as_bytes()], range)).await;
assert_eq!(status, response.status(), "{label}");
assert_eq!(
Some(WEB.to_owned()),
header_value(&response, header::ACCESS_CONTROL_ALLOW_ORIGIN),
"{label}"
);
assert_eq!(
vec![ORIGIN_VARY.to_owned()],
vary_values(&response),
"{label}"
);
}
}
#[tokio::test]
async fn enforce_serves_origin_less_reads_without_allow_origin() {
let tmp = tempfile::tempdir().expect("storage root");
let root = tmp.path().canonicalize().expect("canonical storage root");
let root = root.as_path();
write_stored_representations(root);
let off = dispatch_app("mp", root, &[]);
let enforce = dispatch_app("mp", root, ENFORCE);
for (method, path, range, status) in stored_representation_cases() {
let label = format!("{method} {path} range={range:?}");
let expected =
dispatch_request(&off, read_request(method.clone(), path, &[], range)).await;
let actual = dispatch_request(&enforce, read_request(method, path, &[], range)).await;
assert_eq!(status, expected.status(), "{label}");
assert_eq!(status, actual.status(), "{label}");
assert_eq!(
None,
header_value(&actual, header::ACCESS_CONTROL_ALLOW_ORIGIN),
"{label}"
);
assert_eq!(
vec![ORIGIN_VARY.to_owned()],
vary_values(&actual),
"{label}"
);
assert_eq!(body_of(expected).await, body_of(actual).await, "{label}");
}
}
#[tokio::test]
async fn enforce_refuses_foreign_null_and_malformed_origins_before_any_read() {
let tmp = tempfile::tempdir().expect("storage root");
let root = tmp.path().canonicalize().expect("canonical storage root");
let root = root.as_path();
write_stored_representations(root);
write_object(
root,
"cdn",
"entrance-sounds/42/abc123def456.wav",
STORED_BYTES,
);
let asset = parse_standard_asset_path(ASSET_PATH).expect("asset path");
write_object(
root,
"cdn",
&asset.storage_key,
&crate::test_fixtures::synthetic_png(64, 64),
);
let app = dispatch_app("mp", root, ENFORCE);
for (shape, origins) in refused_origin_shapes() {
for path in [
ATTACHMENT_PATH,
THEME_PATH,
ENTRANCE_SOUND_PATH,
ASSET_PATH,
BAD_SIGNATURE_PATH,
"/nothing/here",
] {
for method in [Method::GET, Method::HEAD] {
let label = format!("{shape} {method} {path}");
let response =
dispatch_request(&app, read_request(method, path, &origins, None)).await;
assert_eq!(StatusCode::FORBIDDEN, response.status(), "{label}");
assert_eq!(
None,
header_value(&response, header::ACCESS_CONTROL_ALLOW_ORIGIN),
"{label}"
);
}
}
}
assert!(
app.metrics
.render()
.contains("fluxer_media_proxy_storage_hits_total 0\n")
);
}
#[tokio::test]
async fn enforce_leaves_read_errors_without_cors_but_varies_them_on_origin() {
let tmp = tempfile::tempdir().expect("storage root");
let root = tmp.path().canonicalize().expect("canonical storage root");
let app = dispatch_app("mp", root.as_path(), ENFORCE);
let with_origin: &[&[u8]] = &[WEB.as_bytes()];
let without_origin: &[&[u8]] = &[];
for origins in [with_origin, without_origin] {
for (path, status) in [
("/attachments/1/2/missing.bin", StatusCode::NOT_FOUND),
("/attachments/%2e%2e/x", StatusCode::BAD_REQUEST),
("/external/nosignature", StatusCode::BAD_REQUEST),
(BAD_SIGNATURE_PATH, StatusCode::UNAUTHORIZED),
] {
for method in [Method::GET, Method::HEAD] {
let label = format!("{method} {path} origins={}", origins.len());
let response =
dispatch_request(&app, read_request(method, path, origins, None)).await;
assert_eq!(status, response.status(), "{label}");
assert_eq!(
None,
header_value(&response, header::ACCESS_CONTROL_ALLOW_ORIGIN),
"{label}"
);
assert_eq!(
vec![ORIGIN_VARY.to_owned()],
vary_values(&response),
"{label}"
);
}
}
}
}
#[tokio::test]
async fn enforce_refuses_a_foreign_origin_on_a_transform_cache_hit() {
let tmp = tempfile::tempdir().expect("storage root");
let root = tmp.path().canonicalize().expect("canonical storage root");
let app = dispatch_app("mp", root.as_path(), ENFORCE);
let asset = parse_standard_asset_path(ASSET_PATH).expect("asset path");
app.store
.write_object(
&app.cfg.storage.bucket_cdn,
&asset.storage_key,
&crate::test_fixtures::synthetic_png(512, 512),
"image/png",
)
.await
.expect("write asset");
for _ in 0..2 {
let response = dispatch_request(
&app,
read_request(Method::GET, ASSET_PATH, &[WEB.as_bytes()], None),
)
.await;
assert_eq!(StatusCode::OK, response.status());
assert_eq!(
Some(WEB.to_owned()),
header_value(&response, header::ACCESS_CONTROL_ALLOW_ORIGIN)
);
assert_eq!(vec![ORIGIN_VARY.to_owned()], vary_values(&response));
assert!(!body_of(response).await.is_empty());
}
assert!(
app.metrics
.render()
.contains("fluxer_media_proxy_transform_cache_hits_total 1\n")
);
let response = dispatch_request(
&app,
read_request(Method::GET, ASSET_PATH, &[EVIL.as_bytes()], None),
)
.await;
assert_eq!(StatusCode::FORBIDDEN, response.status());
assert!(
app.metrics
.render()
.contains("fluxer_media_proxy_transform_cache_hits_total 1\n"),
"a refused origin never reaches the transform cache"
);
}
fn preflight_request(path: &str, origin: &str, requested: Option<&str>) -> Request<Body> {
let mut builder = Request::builder()
.method(Method::OPTIONS)
.uri(path)
.header(header::ORIGIN, origin);
if let Some(requested) = requested {
builder = builder.header(header::ACCESS_CONTROL_REQUEST_METHOD, requested);
}
builder.body(Body::empty()).expect("preflight request")
}
#[tokio::test]
async fn enforce_serves_the_desktop_app_origin_without_configuring_it() {
let tmp = tempfile::tempdir().expect("storage root");
let root = tmp.path().canonicalize().expect("canonical storage root");
let root = root.as_path();
write_stored_representations(root);
let app = dispatch_app("mp", root, ENFORCE);
for method in [Method::GET, Method::HEAD] {
let response = dispatch_request(
&app,
read_request(
method.clone(),
THEME_PATH,
&[DESKTOP_APP_ORIGIN.as_bytes()],
None,
),
)
.await;
assert_eq!(StatusCode::OK, response.status(), "{method}");
assert_eq!(
Some(DESKTOP_APP_ORIGIN.to_owned()),
header_value(&response, header::ACCESS_CONTROL_ALLOW_ORIGIN),
"{method}"
);
assert_eq!(vec![ORIGIN_VARY.to_owned()], vary_values(&response));
}
for lookalike in [
"fluxer-app://app/",
"fluxer-app://evil",
"fluxer-app://app.evil.example",
"fluxer-app://app:443",
"FLUXER-APP://APP",
"fluxer-app:app",
] {
let response = dispatch_request(
&app,
read_request(Method::GET, THEME_PATH, &[lookalike.as_bytes()], None),
)
.await;
assert_eq!(StatusCode::FORBIDDEN, response.status(), "{lookalike}");
assert_eq!(
None,
header_value(&response, header::ACCESS_CONTROL_ALLOW_ORIGIN),
"{lookalike}"
);
}
}
#[tokio::test]
async fn preflight_answers_allowed_origins_and_refuses_the_rest() {
let tmp = tempfile::tempdir().expect("storage root");
let root = tmp.path().canonicalize().expect("canonical storage root");
let root = root.as_path();
let enforce = dispatch_app("mp", root, ENFORCE);
for origin in [WEB, DESKTOP_APP_ORIGIN] {
for requested in ["GET", "HEAD"] {
let response = dispatch_request(
&enforce,
preflight_request(THEME_PATH, origin, Some(requested)),
)
.await;
let label = format!("{origin} {requested}");
assert_eq!(StatusCode::NO_CONTENT, response.status(), "{label}");
assert_eq!(
Some(origin.to_owned()),
header_value(&response, header::ACCESS_CONTROL_ALLOW_ORIGIN),
"{label}"
);
assert_eq!(
Some("GET, HEAD, OPTIONS".to_owned()),
header_value(&response, header::ACCESS_CONTROL_ALLOW_METHODS),
"{label}"
);
assert_eq!(
Some("Range".to_owned()),
header_value(&response, header::ACCESS_CONTROL_ALLOW_HEADERS),
"{label}"
);
assert_eq!(
vec![
"Origin, Access-Control-Request-Method, Access-Control-Request-Headers"
.to_owned()
],
vary_values(&response),
"{label}"
);
assert!(body_of(response).await.is_empty(), "{label}");
}
}
let refused =
dispatch_request(&enforce, preflight_request(THEME_PATH, EVIL, Some("GET"))).await;
assert_eq!(StatusCode::FORBIDDEN, refused.status());
assert_eq!(
None,
header_value(&refused, header::ACCESS_CONTROL_ALLOW_ORIGIN)
);
let not_a_preflight =
dispatch_request(&enforce, preflight_request(THEME_PATH, WEB, None)).await;
assert_eq!(StatusCode::METHOD_NOT_ALLOWED, not_a_preflight.status());
for (mode, extra) in [("mp", &[][..]), ("mp", REPORT), ("static", &[][..])] {
let app = dispatch_app(mode, root, extra);
let response =
dispatch_request(&app, preflight_request(THEME_PATH, EVIL, Some("GET"))).await;
assert_eq!(StatusCode::NO_CONTENT, response.status(), "{mode}");
assert_eq!(
Some("*".to_owned()),
header_value(&response, header::ACCESS_CONTROL_ALLOW_ORIGIN),
"{mode}"
);
}
let relay = dispatch_app("relay", root, &[]);
assert_eq!(
StatusCode::METHOD_NOT_ALLOWED,
dispatch_request(&relay, preflight_request(THEME_PATH, WEB, Some("GET")))
.await
.status()
);
}
#[tokio::test]
async fn enforce_applies_to_upload_mode_reads() {
let tmp = tempfile::tempdir().expect("storage root");
let root = tmp.path().canonicalize().expect("canonical storage root");
let root = root.as_path();
write_stored_representations(root);
let app = dispatch_app("upload", root, ENFORCE);
let allowed = dispatch_request(
&app,
read_request(Method::GET, ATTACHMENT_PATH, &[WEB.as_bytes()], None),
)
.await;
assert_eq!(StatusCode::OK, allowed.status());
assert_eq!(
Some(WEB.to_owned()),
header_value(&allowed, header::ACCESS_CONTROL_ALLOW_ORIGIN)
);
let foreign = dispatch_request(
&app,
read_request(Method::GET, ATTACHMENT_PATH, &[EVIL.as_bytes()], None),
)
.await;
assert_eq!(StatusCode::FORBIDDEN, foreign.status());
let origin_less = dispatch(&app, Method::GET, ATTACHMENT_PATH).await;
assert_eq!(StatusCode::OK, origin_less.status());
assert_eq!(
None,
header_value(&origin_less, header::ACCESS_CONTROL_ALLOW_ORIGIN)
);
assert_eq!(STORED_BYTES, body_of(origin_less).await);
}
#[tokio::test]
async fn report_mode_changes_no_response_byte() {
let tmp = tempfile::tempdir().expect("storage root");
let root = tmp.path().canonicalize().expect("canonical storage root");
let root = root.as_path();
write_stored_representations(root);
let off = dispatch_app("mp", root, &[]);
let report = dispatch_app("mp", root, REPORT);
let origin_shapes: [&[&[u8]]; 4] = [
&[EVIL.as_bytes()],
&[b"null".as_slice()],
&[WEB.as_bytes()],
&[],
];
for origins in origin_shapes {
for (method, path, range, status) in [
(Method::GET, ATTACHMENT_PATH, None, StatusCode::OK),
(
Method::GET,
ATTACHMENT_PATH,
Some("bytes=0-3"),
StatusCode::PARTIAL_CONTENT,
),
(
Method::GET,
ATTACHMENT_PATH,
Some("bytes=100-200"),
StatusCode::RANGE_NOT_SATISFIABLE,
),
(Method::HEAD, ATTACHMENT_PATH, None, StatusCode::OK),
(
Method::GET,
"/attachments/1/2/missing.bin",
None,
StatusCode::NOT_FOUND,
),
] {
let label = format!("{method} {path} range={range:?} origins={origins:?}");
let expected =
dispatch_request(&off, read_request(method.clone(), path, origins, range))
.await;
let actual =
dispatch_request(&report, read_request(method, path, origins, range)).await;
assert_eq!(status, expected.status(), "{label}");
assert_eq!(expected.status(), actual.status(), "{label}");
assert_eq!(expected.headers(), actual.headers(), "{label}");
assert_eq!(body_of(expected).await, body_of(actual).await, "{label}");
}
}
}
#[tokio::test]
async fn report_mode_logs_one_would_deny_line_for_each_refused_origin_only() {
let tmp = tempfile::tempdir().expect("storage root");
let root = tmp.path().canonicalize().expect("canonical storage root");
let root = root.as_path();
write_stored_representations(root);
let report = dispatch_app("mp", root, REPORT);
let cases: [(&[&[u8]], Option<&str>); 4] = [
(&[EVIL.as_bytes()], Some("origin=https://evil.example")),
(&[b"null".as_slice()], Some("origin=null")),
(&[WEB.as_bytes()], None),
(&[], None),
];
for (origins, expected_origin) in cases {
let label = format!("origins={origins:?}");
let captured = CapturedLog::default();
let subscriber = tracing_subscriber::fmt()
.with_writer(captured.clone())
.with_ansi(false)
.with_max_level(tracing::Level::TRACE)
.finish();
let response = {
let _guard = tracing::subscriber::set_default(subscriber);
dispatch_request(
&report,
read_request(Method::GET, ATTACHMENT_PATH, origins, None),
)
.await
};
assert_eq!(StatusCode::OK, response.status(), "{label}");
let log = captured.text();
let would_deny: Vec<&str> = log
.lines()
.filter(|line| line.contains("reason=\"cors_origin_would_deny\""))
.collect();
match expected_origin {
Some(origin) => {
assert_eq!(1, would_deny.len(), "{label}\n{log}");
assert!(would_deny[0].contains(" WARN "), "{label}\n{log}");
assert!(would_deny[0].trim_end().ends_with(origin), "{label}\n{log}");
}
None => assert!(would_deny.is_empty(), "{label}\n{log}"),
}
}
}
#[tokio::test]
async fn off_mode_never_inspects_origin() {
let tmp = tempfile::tempdir().expect("storage root");
let root = tmp.path().canonicalize().expect("canonical storage root");
let root = root.as_path();
write_stored_representations(root);
let app = dispatch_app("mp", root, &[]);
for (shape, origins) in refused_origin_shapes() {
let response = dispatch_request(
&app,
read_request(Method::GET, ATTACHMENT_PATH, &origins, None),
)
.await;
assert_eq!(StatusCode::OK, response.status(), "{shape}");
assert_eq!(
Some("*".to_owned()),
header_value(&response, header::ACCESS_CONTROL_ALLOW_ORIGIN),
"{shape}"
);
assert_eq!(
vec!["Accept-Encoding".to_owned()],
vary_values(&response),
"{shape}"
);
}
}
#[tokio::test]
async fn static_mode_is_unchanged_with_every_cors_key_set() {
let tmp = tempfile::tempdir().expect("storage root");
let root = tmp.path().canonicalize().expect("canonical storage root");
let app = static_mode_app(root.as_path(), ENFORCE);
let origin_shapes: [&[&[u8]]; 4] = [
&[EVIL.as_bytes()],
&[b"null".as_slice()],
&[WEB.as_bytes()],
&[],
];
for origins in origin_shapes {
let label = format!("origins={origins:?}");
let response = dispatch_request(
&app,
read_request(Method::GET, "/avatars/0.png", origins, None),
)
.await;
assert_eq!(StatusCode::OK, response.status(), "{label}");
assert_eq!(
Some("*".to_owned()),
header_value(&response, header::ACCESS_CONTROL_ALLOW_ORIGIN),
"{label}"
);
assert_eq!(
vec!["Accept-Encoding".to_owned()],
vary_values(&response),
"{label}"
);
assert_eq!(
Some(STATIC_CACHE_CONTROL.to_owned()),
header_value(&response, header::CACHE_CONTROL),
"{label}"
);
}
}
const SIGNATURE_SECRET: [u8; 32] = [11u8; 32];
const OTHER_SIGNATURE_SECRET: [u8; 32] = [12u8; 32];
const SIGNATURE_ENFORCE: &[(&str, &str)] = &[
("FLUXER_MEDIA_PROXY_ATTACHMENT_SIGNATURE_MODE", "enforce"),
(
"FLUXER_MEDIA_PROXY_ATTACHMENT_URL_SECRETS_BASE64",
"CwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCws=",
),
];
const SIGNATURE_REPORT: &[(&str, &str)] = &[
("FLUXER_MEDIA_PROXY_ATTACHMENT_SIGNATURE_MODE", "report"),
(
"FLUXER_MEDIA_PROXY_ATTACHMENT_URL_SECRETS_BASE64",
"CwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCws=",
),
];
const SIGNATURE_OFF_WITH_KEYS: &[(&str, &str)] = &[
("FLUXER_MEDIA_PROXY_ATTACHMENT_SIGNATURE_MODE", "off"),
(
"FLUXER_MEDIA_PROXY_ATTACHMENT_URL_SECRETS_BASE64",
"CwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCws=",
),
];
const UNAVAILABLE: &[u8] = b"This content is no longer available.";
const ATTACHMENT_KEY: &str = "attachments/1/2/file.bin";
const IMAGE_PATH: &str = "/attachments/1/2/pic.png";
const IMAGE_KEY: &str = "attachments/1/2/pic.png";
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("the clock is after the unix epoch")
.as_secs()
}
fn params_from(query: Option<&str>) -> HashMap<String, String> {
query
.map(|query| {
query
.split('&')
.filter(|field| !field.is_empty())
.filter_map(|field| field.split_once('='))
.map(|(name, value)| (name.to_owned(), value.to_owned()))
.collect()
})
.unwrap_or_default()
}
fn with_extra(signed: String, extra: &str) -> String {
if extra.is_empty() {
return signed;
}
format!("{signed}&{extra}")
}
fn signed_uri(path: &str, key: &str, extra: &str) -> String {
let now = now_secs();
with_extra(
with_signature(path, key, now, now, &SIGNATURE_SECRET),
extra,
)
}
fn data_package_uri(path: &str, key: &str, extra: &str) -> String {
with_extra(
with_data_package_signature(path, key, 1, 1, &SIGNATURE_SECRET),
extra,
)
}
fn foreign_secret_data_package_uri(path: &str, key: &str) -> String {
with_data_package_signature(path, key, 1, 1, &OTHER_SIGNATURE_SECRET)
}
fn expired_uri(path: &str, key: &str) -> String {
let expires = now_secs() - 1;
let issued = expires - ATTACHMENT_URL_TTL_SECS;
format!(
"{path}?ex={expires:08x}&is={issued:08x}&hm={}",
sign(key, expires, issued, UrlKind::Ordinary, &SIGNATURE_SECRET)
)
}
fn foreign_key_uri(path: &str) -> String {
let now = now_secs();
let (issued, expires) = issue_window(now, now);
format!(
"{path}?ex={expires:08x}&is={issued:08x}&hm={}",
sign(
"attachments/9/9/other.bin",
expires,
issued,
UrlKind::Ordinary,
&SIGNATURE_SECRET
)
)
}
fn foreign_secret_uri(path: &str, key: &str) -> String {
let now = now_secs();
let (issued, expires) = issue_window(now, now);
format!(
"{path}?ex={expires:08x}&is={issued:08x}&hm={}",
sign(
key,
expires,
issued,
UrlKind::Ordinary,
&OTHER_SIGNATURE_SECRET
)
)
}
fn malformed_uri(path: &str, key: &str) -> String {
let now = now_secs();
let (issued, expires) = issue_window(now, now);
format!(
"{path}?ex={expires:08x}&is={issued:08x}&hm={0}&hm={0}",
sign(key, expires, issued, UrlKind::Ordinary, &SIGNATURE_SECRET)
)
}
async fn dispatch_uri(
app: &Arc<AppState>,
method: Method,
uri: &str,
origins: &[&[u8]],
range: Option<&str>,
) -> Response {
let request = read_request(method, uri, origins, range);
let params = params_from(request.uri().query());
catch_all(State(Arc::clone(app)), Query(params), request).await
}
fn counter(app: &Arc<AppState>, name: &str) -> u64 {
app.metrics
.render()
.lines()
.find_map(|line| line.strip_prefix(name)?.trim().parse().ok())
.expect("the counter is rendered")
}
fn write_signed_representations(root: &Path) {
write_stored_representations(root);
write_object(
root,
"cdn",
IMAGE_KEY,
&crate::test_fixtures::synthetic_png(128, 128),
);
}
struct SignedReadCase {
method: Method,
path: &'static str,
key: &'static str,
range: Option<&'static str>,
extra: &'static str,
status: StatusCode,
}
fn signed_read_case(
method: Method,
path: &'static str,
key: &'static str,
range: Option<&'static str>,
extra: &'static str,
status: StatusCode,
) -> SignedReadCase {
SignedReadCase {
method,
path,
key,
range,
extra,
status,
}
}
fn signed_read_cases() -> [SignedReadCase; 7] {
[
signed_read_case(
Method::GET,
ATTACHMENT_PATH,
ATTACHMENT_KEY,
None,
"",
StatusCode::OK,
),
signed_read_case(
Method::GET,
ATTACHMENT_PATH,
ATTACHMENT_KEY,
Some("bytes=0-3"),
"",
StatusCode::PARTIAL_CONTENT,
),
signed_read_case(
Method::GET,
ATTACHMENT_PATH,
ATTACHMENT_KEY,
Some("bytes=100-200"),
"",
StatusCode::RANGE_NOT_SATISFIABLE,
),
signed_read_case(
Method::HEAD,
ATTACHMENT_PATH,
ATTACHMENT_KEY,
None,
"",
StatusCode::OK,
),
signed_read_case(
Method::HEAD,
ATTACHMENT_PATH,
ATTACHMENT_KEY,
Some("bytes=0-3"),
"",
StatusCode::PARTIAL_CONTENT,
),
signed_read_case(
Method::GET,
ATTACHMENT_PATH,
ATTACHMENT_KEY,
None,
"download=1",
StatusCode::OK,
),
signed_read_case(
Method::GET,
IMAGE_PATH,
IMAGE_KEY,
None,
"width=64",
StatusCode::OK,
),
]
}
fn without_cache_policy(headers: &HeaderMap) -> HeaderMap {
let mut headers = headers.clone();
headers.remove(header::CACHE_CONTROL);
headers.remove("cdn-cache-control");
headers
}
#[tokio::test]
async fn enforce_serves_a_signed_attachment_on_every_read_shape() {
let tmp = tempfile::tempdir().expect("storage root");
let root = tmp.path().canonicalize().expect("canonical storage root");
let root = root.as_path();
write_signed_representations(root);
let off = dispatch_app("mp", root, &[]);
let enforce = dispatch_app("mp", root, SIGNATURE_ENFORCE);
for case in signed_read_cases() {
let SignedReadCase {
method,
path,
key,
range,
extra,
status,
} = case;
let unsigned = if extra.is_empty() {
path.to_owned()
} else {
format!("{path}?{extra}")
};
for signed in [
signed_uri(path, key, extra),
data_package_uri(path, key, extra),
] {
let label = format!("{method} {signed} range={range:?}");
let expected = dispatch_uri(&off, method.clone(), &unsigned, &[], range).await;
let actual = dispatch_uri(&enforce, method.clone(), &signed, &[], range).await;
assert_eq!(status, expected.status(), "{label}");
assert_eq!(expected.status(), actual.status(), "{label}");
assert_eq!(
without_cache_policy(expected.headers()),
without_cache_policy(actual.headers()),
"{label}"
);
match (
header_value(&expected, header::CACHE_CONTROL),
header_value(&actual, header::CACHE_CONTROL),
) {
(None, None) => {}
(Some(unsigned_policy), Some(signed_policy)) => {
assert!(unsigned_policy.contains("max-age=31536000"), "{label}");
assert!(signed_policy.starts_with("public, max-age="), "{label}");
assert!(!signed_policy.contains("max-age=31536000"), "{label}");
assert!(
!header_value(&actual, "cdn-cache-control")
.expect("a signed read pairs the cdn cache policy")
.contains("max-age=31536000"),
"{label}"
);
}
mismatch => panic!("{label} {mismatch:?}"),
}
assert_eq!(body_of(expected).await, body_of(actual).await, "{label}");
}
}
}
#[tokio::test]
async fn enforce_answers_every_refusal_with_the_same_bytes() {
let tmp = tempfile::tempdir().expect("storage root");
let root = tmp.path().canonicalize().expect("canonical storage root");
let root = root.as_path();
write_signed_representations(root);
let app = dispatch_app("mp", root, SIGNATURE_ENFORCE);
let missing_object = signed_uri(
"/attachments/1/2/absent.bin",
"attachments/1/2/absent.bin",
"",
);
let package = data_package_uri(ATTACHMENT_PATH, ATTACHMENT_KEY, "");
let refusals: [(&str, String); 13] = [
(
"data package from another secret",
foreign_secret_data_package_uri(ATTACHMENT_PATH, ATTACHMENT_KEY),
),
(
"data package with uc removed",
package.replace("&uc=dp", ""),
),
(
"data package with a tampered uc",
package.replace("uc=dp", "uc=dq"),
),
(
"data package with a padded expiry",
package.replace("ex=0&", "ex=00000000&"),
),
(
"data package signature for another key",
data_package_uri(
"/attachments/9/9/other.bin",
"attachments/9/9/other.bin",
"",
)
.replace("/attachments/9/9/other.bin", ATTACHMENT_PATH),
),
("no signature", ATTACHMENT_PATH.to_owned()),
(
"other parameters only",
format!("{ATTACHMENT_PATH}?width=64"),
),
(
"partial set",
format!("{ATTACHMENT_PATH}?ex=6a9a7231&is=6a9920b1"),
),
(
"repeated parameter",
malformed_uri(ATTACHMENT_PATH, ATTACHMENT_KEY),
),
("expired", expired_uri(ATTACHMENT_PATH, ATTACHMENT_KEY)),
(
"signature for another key",
foreign_key_uri(ATTACHMENT_PATH),
),
(
"signature from another secret",
foreign_secret_uri(ATTACHMENT_PATH, ATTACHMENT_KEY),
),
("valid signature over a missing object", missing_object),
];
let mut seen: Vec<(StatusCode, HeaderMap, Bytes)> = Vec::new();
for (label, uri) in refusals {
for method in [Method::GET, Method::HEAD] {
for range in [None, Some("bytes=0-3"), Some("bytes=100-200")] {
for origins in [&[][..], &[WEB.as_bytes()][..]] {
let response =
dispatch_uri(&app, method.clone(), &uri, origins, range).await;
let status = response.status();
let headers = response.headers().clone();
let body = body_of(response).await;
assert_eq!(StatusCode::NOT_FOUND, status, "{label} {method}");
assert_eq!(UNAVAILABLE, body.as_ref(), "{label} {method}");
assert_eq!(36, body.len(), "{label} {method}");
assert_eq!(
Some("no-store"),
headers
.get(header::CACHE_CONTROL)
.map(|value| value.to_str().expect("ascii")),
"{label} {method}"
);
assert!(
!headers.contains_key(header::ACCESS_CONTROL_ALLOW_ORIGIN),
"{label} {method}"
);
seen.push((status, headers, body));
}
}
}
}
let first = seen.first().expect("at least one refusal").clone();
for (status, headers, body) in seen {
assert_eq!(first.0, status);
assert_eq!(first.1, headers);
assert_eq!(first.2, body);
}
}
#[tokio::test]
async fn enforce_keeps_a_bad_storage_key_a_bad_request() {
let tmp = tempfile::tempdir().expect("storage root");
let root = tmp.path().canonicalize().expect("canonical storage root");
let app = dispatch_app("mp", root.as_path(), SIGNATURE_ENFORCE);
let response = dispatch_uri(&app, Method::GET, "/attachments/%2e%2e/x", &[], None).await;
assert_eq!(StatusCode::BAD_REQUEST, response.status());
assert_eq!(b"Bad Request".as_slice(), body_of(response).await);
}
#[tokio::test]
async fn enforce_refuses_before_any_object_read() {
let tmp = tempfile::tempdir().expect("storage root");
let root = tmp.path().canonicalize().expect("canonical storage root");
let root = root.as_path();
write_signed_representations(root);
let app = dispatch_app("mp", root, SIGNATURE_ENFORCE);
for uri in [
ATTACHMENT_PATH.to_owned(),
expired_uri(ATTACHMENT_PATH, ATTACHMENT_KEY),
foreign_key_uri(ATTACHMENT_PATH),
format!("{IMAGE_PATH}?width=64"),
] {
let response = dispatch_uri(&app, Method::GET, &uri, &[], None).await;
assert_eq!(StatusCode::NOT_FOUND, response.status(), "{uri}");
}
assert_eq!(0, counter(&app, "fluxer_media_proxy_storage_hits_total"));
assert_eq!(
0,
counter(&app, "fluxer_media_proxy_transform_cache_misses_total")
);
}
#[tokio::test]
async fn a_refusal_never_reaches_a_warmed_transform_cache() {
let tmp = tempfile::tempdir().expect("storage root");
let root = tmp.path().canonicalize().expect("canonical storage root");
let root = root.as_path();
write_signed_representations(root);
let app = dispatch_app("mp", root, SIGNATURE_ENFORCE);
for _ in 0..2 {
let uri = signed_uri(IMAGE_PATH, IMAGE_KEY, "width=64");
let response = dispatch_uri(&app, Method::GET, &uri, &[], None).await;
assert_eq!(StatusCode::OK, response.status());
assert!(!body_of(response).await.is_empty());
}
assert_eq!(
1,
counter(&app, "fluxer_media_proxy_transform_cache_hits_total")
);
let hits = counter(&app, "fluxer_media_proxy_storage_hits_total");
assert!(hits > 0);
let expired = format!("{}&width=64", expired_uri(IMAGE_PATH, IMAGE_KEY));
let response = dispatch_uri(&app, Method::GET, &expired, &[], None).await;
assert_eq!(StatusCode::NOT_FOUND, response.status());
assert_eq!(UNAVAILABLE, body_of(response).await.as_ref());
assert_eq!(
1,
counter(&app, "fluxer_media_proxy_transform_cache_hits_total"),
"an expired url never reaches the transform cache"
);
assert_eq!(
hits,
counter(&app, "fluxer_media_proxy_storage_hits_total"),
"an expired url never reaches storage"
);
}
#[tokio::test]
async fn enforce_accepts_every_spelling_of_the_signed_key() {
let tmp = tempfile::tempdir().expect("storage root");
let root = tmp.path().canonicalize().expect("canonical storage root");
let root = root.as_path();
write_object(root, "cdn", "attachments/1/2/café.gif", STORED_BYTES);
write_object(root, "cdn", "attachments/1/2/a/b.gif", STORED_BYTES);
let app = dispatch_app("mp", root, SIGNATURE_ENFORCE);
for (path, key) in [
("/attachments/1/2/caf%C3%A9.gif", "attachments/1/2/café.gif"),
("/attachments/1/2/café.gif", "attachments/1/2/café.gif"),
("/attachments/1/2/a%2Fb.gif", "attachments/1/2/a/b.gif"),
("/attachments/1/2/a/b.gif", "attachments/1/2/a/b.gif"),
] {
let now = now_secs();
let (issued, expires) = issue_window(now, now);
let uri = format!(
"{path}?ex={expires:08x}&is={issued:08x}&hm={}",
sign(key, expires, issued, UrlKind::Ordinary, &SIGNATURE_SECRET)
);
for spelling in [uri.clone(), format!("{uri}&")] {
let response = dispatch_uri(&app, Method::GET, &spelling, &[], None).await;
assert_eq!(StatusCode::OK, response.status(), "{spelling}");
assert_eq!(STORED_BYTES, body_of(response).await, "{spelling}");
}
}
}
#[tokio::test]
async fn enforce_leaves_every_other_read_route_unsigned() {
let tmp = tempfile::tempdir().expect("storage root");
let root = tmp.path().canonicalize().expect("canonical storage root");
let root = root.as_path();
write_signed_representations(root);
write_object(
root,
"cdn",
"entrance-sounds/42/abc123def456.wav",
STORED_BYTES,
);
let asset = parse_standard_asset_path(ASSET_PATH).expect("asset path");
write_object(
root,
"cdn",
&asset.storage_key,
&crate::test_fixtures::synthetic_png(64, 64),
);
let off = dispatch_app("mp", root, &[]);
let enforce = dispatch_app("mp", root, SIGNATURE_ENFORCE);
for path in [THEME_PATH, ENTRANCE_SOUND_PATH, ASSET_PATH, "/nothing/here"] {
for method in [Method::GET, Method::HEAD] {
let label = format!("{method} {path}");
let expected = dispatch_uri(&off, method.clone(), path, &[], None).await;
let actual = dispatch_uri(&enforce, method, path, &[], None).await;
assert_eq!(expected.status(), actual.status(), "{label}");
assert_eq!(expected.headers(), actual.headers(), "{label}");
assert_eq!(body_of(expected).await, body_of(actual).await, "{label}");
}
}
}
#[tokio::test]
async fn static_and_relay_modes_are_unchanged_with_the_signature_keys_set() {
let tmp = tempfile::tempdir().expect("storage root");
let root = tmp.path().canonicalize().expect("canonical storage root");
let root = root.as_path();
write_object(root, "static", "avatars/0.png", STORED_BYTES);
write_object(root, "cdn", ATTACHMENT_KEY, STORED_BYTES);
let plain_static = dispatch_app("static", root, &[]);
let signed_static = dispatch_app("static", root, SIGNATURE_ENFORCE);
let expected = dispatch_uri(&plain_static, Method::GET, "/avatars/0.png", &[], None).await;
let actual = dispatch_uri(&signed_static, Method::GET, "/avatars/0.png", &[], None).await;
assert_eq!(StatusCode::OK, expected.status());
assert_eq!(expected.status(), actual.status());
assert_eq!(expected.headers(), actual.headers());
assert_eq!(body_of(expected).await, body_of(actual).await);
let relay = dispatch_app("relay", root, SIGNATURE_ENFORCE);
let response = dispatch_uri(&relay, Method::GET, ATTACHMENT_PATH, &[], None).await;
assert_eq!(StatusCode::NOT_FOUND, response.status());
assert_eq!(b"Not Found".as_slice(), body_of(response).await);
let upload = dispatch_app("upload", root, SIGNATURE_ENFORCE);
let refused = dispatch_uri(&upload, Method::GET, ATTACHMENT_PATH, &[], None).await;
assert_eq!(StatusCode::NOT_FOUND, refused.status());
assert_eq!(UNAVAILABLE, body_of(refused).await.as_ref());
let allowed = dispatch_uri(
&upload,
Method::GET,
&signed_uri(ATTACHMENT_PATH, ATTACHMENT_KEY, ""),
&[],
None,
)
.await;
assert_eq!(StatusCode::OK, allowed.status());
assert_eq!(STORED_BYTES, body_of(allowed).await);
}
#[tokio::test]
async fn a_cors_refusal_precedes_the_signature_check() {
let tmp = tempfile::tempdir().expect("storage root");
let root = tmp.path().canonicalize().expect("canonical storage root");
let root = root.as_path();
write_signed_representations(root);
let mut env: Vec<(&str, &str)> = ENFORCE.to_vec();
env.extend_from_slice(SIGNATURE_ENFORCE);
let app = dispatch_app("mp", root, &env);
for uri in [
ATTACHMENT_PATH.to_owned(),
signed_uri(ATTACHMENT_PATH, ATTACHMENT_KEY, ""),
expired_uri(ATTACHMENT_PATH, ATTACHMENT_KEY),
] {
let response = dispatch_uri(&app, Method::GET, &uri, &[EVIL.as_bytes()], None).await;
assert_eq!(StatusCode::FORBIDDEN, response.status(), "{uri}");
assert_eq!(b"Forbidden".as_slice(), body_of(response).await, "{uri}");
}
assert_eq!(0, counter(&app, "fluxer_media_proxy_storage_hits_total"));
}
#[tokio::test]
async fn report_mode_changes_no_response_byte_for_a_stored_object() {
let tmp = tempfile::tempdir().expect("storage root");
let root = tmp.path().canonicalize().expect("canonical storage root");
let root = root.as_path();
write_signed_representations(root);
let off = dispatch_app("mp", root, &[]);
let report = dispatch_app("mp", root, SIGNATURE_REPORT);
for uri in [
ATTACHMENT_PATH.to_owned(),
format!("{ATTACHMENT_PATH}?download=1"),
expired_uri(ATTACHMENT_PATH, ATTACHMENT_KEY),
foreign_key_uri(ATTACHMENT_PATH),
format!("{IMAGE_PATH}?width=64"),
] {
for method in [Method::GET, Method::HEAD] {
for range in [None, Some("bytes=0-3"), Some("bytes=100-200")] {
let label = format!("{method} {uri} range={range:?}");
let expected = dispatch_uri(&off, method.clone(), &uri, &[], range).await;
let actual = dispatch_uri(&report, method.clone(), &uri, &[], range).await;
assert_eq!(expected.status(), actual.status(), "{label}");
assert_eq!(expected.headers(), actual.headers(), "{label}");
assert_eq!(body_of(expected).await, body_of(actual).await, "{label}");
}
}
}
}
#[tokio::test]
async fn report_mode_changes_only_the_cache_lifetime_of_a_valid_signature() {
let tmp = tempfile::tempdir().expect("storage root");
let root = tmp.path().canonicalize().expect("canonical storage root");
let root = root.as_path();
write_signed_representations(root);
let off = dispatch_app("mp", root, &[]);
let report = dispatch_app("mp", root, SIGNATURE_REPORT);
for uri in [
signed_uri(ATTACHMENT_PATH, ATTACHMENT_KEY, ""),
data_package_uri(ATTACHMENT_PATH, ATTACHMENT_KEY, ""),
signed_uri(IMAGE_PATH, IMAGE_KEY, "width=64"),
] {
for method in [Method::GET, Method::HEAD] {
for range in [None, Some("bytes=0-3")] {
let label = format!("{method} {uri} range={range:?}");
let expected = dispatch_uri(&off, method.clone(), &uri, &[], range).await;
let actual = dispatch_uri(&report, method.clone(), &uri, &[], range).await;
assert_eq!(expected.status(), actual.status(), "{label}");
assert_eq!(
without_cache_policy(expected.headers()),
without_cache_policy(actual.headers()),
"{label}"
);
assert_eq!(
Some(STATIC_CACHE_CONTROL.to_owned()),
header_value(&expected, header::CACHE_CONTROL),
"{label}"
);
let capped = header_value(&actual, header::CACHE_CONTROL)
.expect("a report read of a valid signature states its own max age");
let max_age: u64 = capped
.strip_prefix("public, max-age=")
.expect("the capped policy is a plain max age")
.parse()
.expect("a max age is a number");
assert!(max_age <= ATTACHMENT_URL_TTL_SECS, "{label} {capped}");
assert_eq!(
Some(capped),
header_value(&actual, "cdn-cache-control"),
"{label}"
);
assert_eq!(body_of(expected).await, body_of(actual).await, "{label}");
}
}
}
}
#[tokio::test]
async fn report_leaves_a_refusable_read_caching_forever() {
let tmp = tempfile::tempdir().expect("storage root");
let root = tmp.path().canonicalize().expect("canonical storage root");
let root = root.as_path();
write_signed_representations(root);
let report = dispatch_app("mp", root, SIGNATURE_REPORT);
for uri in [
ATTACHMENT_PATH.to_owned(),
expired_uri(ATTACHMENT_PATH, ATTACHMENT_KEY),
foreign_key_uri(ATTACHMENT_PATH),
] {
let response = dispatch_uri(&report, Method::GET, &uri, &[], None).await;
assert_eq!(StatusCode::OK, response.status(), "{uri}");
assert_eq!(
Some(STATIC_CACHE_CONTROL.to_owned()),
header_value(&response, header::CACHE_CONTROL),
"{uri}"
);
}
}
#[tokio::test]
async fn only_enforce_masks_a_missing_object_so_it_matches_a_refusal() {
let tmp = tempfile::tempdir().expect("storage root");
let root = tmp.path().canonicalize().expect("canonical storage root");
let root = root.as_path();
write_signed_representations(root);
let off = dispatch_app("mp", root, &[]);
let report = dispatch_app("mp", root, SIGNATURE_REPORT);
let enforce = dispatch_app("mp", root, SIGNATURE_ENFORCE);
let uri = "/attachments/1/2/absent.bin";
let plain = dispatch_uri(&off, Method::GET, uri, &[], None).await;
assert_eq!(StatusCode::NOT_FOUND, plain.status());
assert_eq!(b"Not Found".as_slice(), body_of(plain).await);
let dry_run = dispatch_uri(&report, Method::GET, uri, &[], None).await;
assert_eq!(StatusCode::NOT_FOUND, dry_run.status());
assert_eq!(b"Not Found".as_slice(), body_of(dry_run).await);
let signed = signed_uri(uri, "attachments/1/2/absent.bin", "");
let masked = dispatch_uri(&enforce, Method::GET, &signed, &[], None).await;
assert_eq!(StatusCode::NOT_FOUND, masked.status());
assert_eq!(UNAVAILABLE, body_of(masked).await.as_ref());
}
#[tokio::test]
async fn an_enforced_attachment_is_never_cached_past_its_signature() {
let tmp = tempfile::tempdir().expect("storage root");
let root = tmp.path().canonicalize().expect("canonical storage root");
let root = root.as_path();
write_signed_representations(root);
let app = dispatch_app("mp", root, SIGNATURE_ENFORCE);
let response = dispatch_uri(
&app,
Method::GET,
&signed_uri(ATTACHMENT_PATH, ATTACHMENT_KEY, ""),
&[],
None,
)
.await;
assert_eq!(StatusCode::OK, response.status());
let policy = header_value(&response, header::CACHE_CONTROL).expect("cache control");
let max_age: u64 = policy
.strip_prefix("public, max-age=")
.expect("a signed attachment states its own max age")
.parse()
.expect("a max age is a number");
assert!(max_age <= ATTACHMENT_URL_TTL_SECS, "{policy}");
assert!(max_age + 60 >= ATTACHMENT_URL_TTL_SECS, "{policy}");
assert_eq!(Some(policy), header_value(&response, "cdn-cache-control"));
let package = dispatch_uri(
&app,
Method::GET,
&data_package_uri(ATTACHMENT_PATH, ATTACHMENT_KEY, ""),
&[],
None,
)
.await;
assert_eq!(StatusCode::OK, package.status());
assert_eq!(
Some(format!("public, max-age={ATTACHMENT_URL_TTL_SECS}")),
header_value(&package, header::CACHE_CONTROL)
);
}
#[tokio::test]
async fn an_unenforced_read_and_every_other_route_cache_forever() {
let tmp = tempfile::tempdir().expect("storage root");
let root = tmp.path().canonicalize().expect("canonical storage root");
let root = root.as_path();
write_signed_representations(root);
for env in [&[][..], SIGNATURE_OFF_WITH_KEYS] {
let app = dispatch_app("mp", root, env);
let response = dispatch_uri(
&app,
Method::GET,
&signed_uri(ATTACHMENT_PATH, ATTACHMENT_KEY, ""),
&[],
None,
)
.await;
assert_eq!(StatusCode::OK, response.status());
assert_eq!(
Some(STATIC_CACHE_CONTROL.to_owned()),
header_value(&response, header::CACHE_CONTROL)
);
}
let enforcing = dispatch_app("mp", root, SIGNATURE_ENFORCE);
let theme = dispatch_uri(&enforcing, Method::GET, THEME_PATH, &[], None).await;
assert_eq!(StatusCode::OK, theme.status());
assert_eq!(
Some(STATIC_CACHE_CONTROL.to_owned()),
header_value(&theme, header::CACHE_CONTROL)
);
}
#[tokio::test]
async fn report_mode_logs_one_line_for_each_refused_verdict_only() {
let tmp = tempfile::tempdir().expect("storage root");
let root = tmp.path().canonicalize().expect("canonical storage root");
let root = root.as_path();
write_signed_representations(root);
let report = dispatch_app("mp", root, SIGNATURE_REPORT);
let cases: [(&str, String); 8] = [
("missing", ATTACHMENT_PATH.to_owned()),
("malformed", malformed_uri(ATTACHMENT_PATH, ATTACHMENT_KEY)),
("mismatch", foreign_key_uri(ATTACHMENT_PATH)),
("expired", expired_uri(ATTACHMENT_PATH, ATTACHMENT_KEY)),
("", signed_uri(ATTACHMENT_PATH, ATTACHMENT_KEY, "")),
("", data_package_uri(ATTACHMENT_PATH, ATTACHMENT_KEY, "")),
(
"malformed",
data_package_uri(ATTACHMENT_PATH, ATTACHMENT_KEY, "").replace("&uc=dp", ""),
),
(
"mismatch",
foreign_secret_data_package_uri(ATTACHMENT_PATH, ATTACHMENT_KEY),
),
];
for (verdict, uri) in cases {
let captured = CapturedLog::default();
let subscriber = tracing_subscriber::fmt()
.with_writer(captured.clone())
.with_ansi(false)
.with_max_level(tracing::Level::TRACE)
.finish();
let response = {
let _guard = tracing::subscriber::set_default(subscriber);
dispatch_uri(&report, Method::GET, &uri, &[], None).await
};
assert_eq!(StatusCode::OK, response.status(), "{verdict} {uri}");
let log = captured.text();
let lines: Vec<&str> = log
.lines()
.filter(|line| line.contains("reason=\"attachment_signature_would_deny\""))
.collect();
if verdict.is_empty() {
assert!(lines.is_empty(), "{uri}\n{log}");
continue;
}
assert_eq!(1, lines.len(), "{verdict} {uri}\n{log}");
assert!(lines[0].contains(" WARN "), "{verdict}\n{log}");
assert!(
lines[0].contains(&format!("verdict=\"{verdict}\"")),
"{verdict}\n{log}"
);
}
}
#[tokio::test]
async fn off_mode_serves_a_signed_request_unchanged() {
let tmp = tempfile::tempdir().expect("storage root");
let root = tmp.path().canonicalize().expect("canonical storage root");
let root = root.as_path();
write_signed_representations(root);
let plain = dispatch_app("mp", root, &[]);
let staged = dispatch_app("mp", root, SIGNATURE_OFF_WITH_KEYS);
for uri in [
ATTACHMENT_PATH.to_owned(),
expired_uri(ATTACHMENT_PATH, ATTACHMENT_KEY),
foreign_key_uri(ATTACHMENT_PATH),
format!("{ATTACHMENT_PATH}?ex=zzz&is=&hm="),
"/attachments/1/2/absent.bin".to_owned(),
] {
let expected = dispatch_uri(&plain, Method::GET, &uri, &[], None).await;
let actual = dispatch_uri(&staged, Method::GET, &uri, &[], None).await;
assert_eq!(expected.status(), actual.status(), "{uri}");
assert_eq!(expected.headers(), actual.headers(), "{uri}");
assert_eq!(body_of(expected).await, body_of(actual).await, "{uri}");
}
}
#[tokio::test]
async fn off_mode_serves_a_signed_read_byte_identical_to_an_unsigned_read() {
let tmp = tempfile::tempdir().expect("storage root");
let root = tmp.path().canonicalize().expect("canonical storage root");
let root = root.as_path();
write_signed_representations(root);
for env in [&[][..], SIGNATURE_OFF_WITH_KEYS] {
let unsigned_app = dispatch_app("mp", root, &[]);
let signed_app = dispatch_app("mp", root, env);
for case in signed_read_cases() {
let SignedReadCase {
method,
path,
key,
range,
extra,
status,
} = case;
let separator = if extra.is_empty() { "" } else { "&" };
let unsigned = if extra.is_empty() {
path.to_owned()
} else {
format!("{path}?{extra}")
};
for signed in [
signed_uri(path, key, extra),
data_package_uri(path, key, extra),
with_extra(expired_uri(path, key), extra),
format!("{path}?ex=zzz&is=&hm=&uc=xx{separator}{extra}"),
format!("{path}?uc=dp{separator}{extra}"),
] {
let label = format!("{method} {signed} range={range:?} env={env:?}");
let expected =
dispatch_uri(&unsigned_app, method.clone(), &unsigned, &[], range).await;
let actual =
dispatch_uri(&signed_app, method.clone(), &signed, &[], range).await;
assert_eq!(status, expected.status(), "{label}");
assert_eq!(expected.status(), actual.status(), "{label}");
assert_eq!(expected.headers(), actual.headers(), "{label}");
assert_eq!(body_of(expected).await, body_of(actual).await, "{label}");
}
}
}
}
#[test]
fn signature_parameters_never_reach_the_attachment_reader() {
let params: HashMap<String, String> = [
("ex", "0"),
("is", "6a9920b1"),
("hm", "abc"),
("uc", "dp"),
("width", "64"),
("download", "1"),
("EX", "1"),
("%75c", "dp"),
("ucx", "1"),
]
.into_iter()
.map(|(name, value)| (name.to_owned(), value.to_owned()))
.collect();
let filtered = without_signature_parameters(&params);
let mut names: Vec<&str> = filtered.keys().map(String::as_str).collect();
names.sort_unstable();
assert_eq!(vec!["%75c", "EX", "download", "ucx", "width"], names);
assert_eq!(Some("64"), filtered.get("width").map(String::as_str));
assert_eq!(Some("1"), filtered.get("download").map(String::as_str));
}
const SIGNATURE_VERDICTS: &str = "fluxer_media_proxy_attachment_signature_verdicts_total";
fn verdict_counter(app: &Arc<AppState>, verdict: &str) -> u64 {
counter(
app,
&format!("{SIGNATURE_VERDICTS}{{verdict=\"{verdict}\"}}"),
)
}
fn verdict_cases() -> [(&'static str, String); 5] {
[
("missing", ATTACHMENT_PATH.to_owned()),
("malformed", malformed_uri(ATTACHMENT_PATH, ATTACHMENT_KEY)),
("mismatch", foreign_key_uri(ATTACHMENT_PATH)),
("expired", expired_uri(ATTACHMENT_PATH, ATTACHMENT_KEY)),
("valid", signed_uri(ATTACHMENT_PATH, ATTACHMENT_KEY, "")),
]
}
#[tokio::test]
async fn report_and_enforce_count_the_verdict_of_every_attachment_read() {
let tmp = tempfile::tempdir().expect("storage root");
let root = tmp.path().canonicalize().expect("canonical storage root");
let root = root.as_path();
write_signed_representations(root);
for env in [SIGNATURE_REPORT, SIGNATURE_ENFORCE] {
let app = dispatch_app("mp", root, env);
for (verdict, uri) in verdict_cases() {
dispatch_uri(&app, Method::GET, &uri, &[], None).await;
assert_eq!(1, verdict_counter(&app, verdict), "{verdict} {uri} {env:?}");
}
for (verdict, _) in verdict_cases() {
assert_eq!(1, verdict_counter(&app, verdict), "{verdict} {env:?}");
}
}
}
#[tokio::test]
async fn off_mode_counts_no_verdict_at_all() {
let tmp = tempfile::tempdir().expect("storage root");
let root = tmp.path().canonicalize().expect("canonical storage root");
let root = root.as_path();
write_signed_representations(root);
for env in [&[][..], SIGNATURE_OFF_WITH_KEYS] {
let app = dispatch_app("mp", root, env);
for (_, uri) in verdict_cases() {
dispatch_uri(&app, Method::GET, &uri, &[], None).await;
}
for (verdict, _) in verdict_cases() {
assert_eq!(0, verdict_counter(&app, verdict), "{verdict} {env:?}");
}
}
}
#[tokio::test]
async fn a_report_burst_bounds_its_log_lines_and_still_counts_every_read() {
const BURST: usize = 50;
let tmp = tempfile::tempdir().expect("storage root");
let root = tmp.path().canonicalize().expect("canonical storage root");
let root = root.as_path();
write_signed_representations(root);
let report = dispatch_app("mp", root, SIGNATURE_REPORT);
let captured = CapturedLog::default();
let subscriber = tracing_subscriber::fmt()
.with_writer(captured.clone())
.with_ansi(false)
.with_max_level(tracing::Level::TRACE)
.finish();
{
let _guard = tracing::subscriber::set_default(subscriber);
for _ in 0..BURST {
let response = dispatch_uri(&report, Method::GET, ATTACHMENT_PATH, &[], None).await;
assert_eq!(StatusCode::OK, response.status());
}
tokio::time::sleep(std::time::Duration::from_millis(1_100)).await;
dispatch_uri(&report, Method::GET, ATTACHMENT_PATH, &[], None).await;
}
let log = captured.text();
let lines: Vec<&str> = log
.lines()
.filter(|line| line.contains("reason=\"attachment_signature_would_deny\""))
.collect();
assert!(
lines.len() < BURST,
"{} lines for {BURST} refusable reads\n{log}",
lines.len()
);
assert_eq!(
(BURST + 1) as u64,
verdict_counter(&report, "missing"),
"every refusable read is counted"
);
let suppressed: u64 = lines
.last()
.expect("the burst logs at least one line")
.split("suppressed=")
.nth(1)
.expect("an emitted line reports the lines it stands in for")
.split_whitespace()
.next()
.expect("a suppressed count")
.parse()
.expect("a suppressed count is a number");
assert!(suppressed > 0, "{log}");
assert!(
lines.len() as u64 + suppressed <= (BURST + 1) as u64,
"{log}"
);
}
}