diff --git a/CHANGELOG.md b/CHANGELOG.md index cc4251f..5a9bde9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 disabled" instead of an unusable empty secret / empty signing key. ### Added +- Signed GET URLs (#129): dedicated `--signing-key` / `COSY_SIGNING_KEY` + (falls back to the API key) and optional signed `exp` expiry. - `cosy serve --host` to choose the bind address; a warning is logged when auth is disabled on a non-loopback address. diff --git a/docs/api/endpoints.md b/docs/api/endpoints.md index 176b3fc..a9435c6 100644 --- a/docs/api/endpoints.md +++ b/docs/api/endpoints.md @@ -163,16 +163,21 @@ GET /r/og-image.png?d=&sig= - `d` = base64url-encoded input JSON (`{"data": {...}}` or a bare brand+slides object); max 8 KB decoded → `413`. -- `sig` = HMAC-SHA256 hex over `{template}:{d}` using the API key as the - signing key. Wrong/missing signature → `403`. Signatures are bound to +- `exp` (optional) = unix seconds after which the URL answers `403`. When + present it is part of the signed payload (`{template}:{d}:{exp}`), so it + can't be added or stripped without invalidating `sig`. +- `sig` = HMAC-SHA256 hex over `{template}:{d}` (or `{template}:{d}:{exp}`) + using the signing key (`--signing-key` / `COSY_SIGNING_KEY`, falling back + to the API key). Wrong/missing signature → `403`. Signatures are bound to the template name, so a URL for one template can't render another. - `{ext}` selects the container: `.png` (default) or `.webp`. -- Responses carry `Cache-Control: public, max-age=3600` for re-crawls. -- Disabled with `404` when the server has no API key configured +- Responses carry `Cache-Control: public, max-age=3600` for re-crawls + (capped to the remaining lifetime when `exp` is set). +- Disabled with `404` when the server has no signing key or API key configured (dev mode keeps unsigned POST only). Generating a URL (pseudo-code): `d = base64url(json); sig = -hmac_sha256_hex(api_key, template + ":" + d)`. +hmac_sha256_hex(signing_key, template + ":" + d [+ ":" + exp])`. ### Responses diff --git a/src/cli.rs b/src/cli.rs index f16e5c2..cd02a3c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -136,6 +136,11 @@ pub enum Command { #[arg(short, long)] token: Option, + /// HMAC key for signed GET render URLs (`/r/...`). If not set, reads + /// COSY_SIGNING_KEY, then falls back to the API key. + #[arg(long)] + signing_key: Option, + /// Allow bg_image/logo URLs pointing at private/internal addresses. /// Off by default: the API renders attacker-controlled JSON, so /// image fetches to loopback/RFC1918/link-local targets are blocked. @@ -380,6 +385,7 @@ impl Cli { port, host, token, + signing_key, allow_private_images, allow_local_image_paths, } => { @@ -387,6 +393,7 @@ impl Cli { let api_key = token .or_else(|| std::env::var("COSY_API_KEY").ok()) .filter(|k| !k.is_empty()); + let signing_key = signing_key.or_else(|| std::env::var("COSY_SIGNING_KEY").ok()); let image_policy = crate::text::ImagePolicy { allow_private: allow_private_images, allow_local: allow_local_image_paths, @@ -400,7 +407,13 @@ impl Cli { println!("Starting Cosy API server on port {}...", port); // Tokio runtime for async server let runtime = tokio::runtime::Runtime::new()?; - runtime.block_on(crate::server::run_on(&host, port, api_key, image_policy))?; + runtime.block_on(crate::server::run_with( + &host, + port, + api_key, + signing_key, + image_policy, + ))?; Ok(ExitCode::SUCCESS) } } diff --git a/src/server.rs b/src/server.rs index 8166076..7777f49 100644 --- a/src/server.rs +++ b/src/server.rs @@ -191,22 +191,52 @@ fn constant_time_str_eq(a: &str, b: &str) -> bool { constant_time_eq(a.as_bytes(), b.as_bytes()) } +/// Canonical string that is signed: `{template}:{d}` (legacy, no expiry) or +/// `{template}:{d}:{exp}`. +fn signed_payload(template_id: &str, d: &str, exp: Option<&str>) -> String { + match exp { + Some(exp) => format!("{template_id}:{d}:{exp}"), + None => format!("{template_id}:{d}"), + } +} + +fn unix_now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + /// Parse and verify a signed GET render request. /// Returns the decoded input data, or an HTTP status + message. fn verify_signed_request( signing_key: &str, template_id: &str, query_d: &str, + query_exp: Option<&str>, query_sig: &str, ) -> Result { - // Payload = template + data so URLs can't be transplanted across - // templates (a signature for template A must not render template B). - let payload = format!("{template_id}:{query_d}"); + // Payload = template + data (+ expiry) so URLs can't be transplanted + // across templates (a signature for template A must not render template + // B) and an `exp` can neither be added nor stripped after signing. + let payload = signed_payload(template_id, query_d, query_exp); if !constant_time_str_eq(&sign_payload(signing_key, payload.as_bytes()), query_sig) { return Err((StatusCode::FORBIDDEN, "invalid signature".into())); } + if let Some(exp) = query_exp { + let exp: u64 = exp.parse().map_err(|_| { + ( + StatusCode::BAD_REQUEST, + "exp must be unix seconds".to_string(), + ) + })?; + if exp < unix_now() { + return Err((StatusCode::FORBIDDEN, "signed URL expired".into())); + } + } + // d = base64url(JSON) — decode leniently (padded or not). use base64::Engine; let json_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD @@ -262,12 +292,29 @@ pub fn signed_get_url( template: &str, data_json: &str, ext: &str, +) -> String { + signed_get_url_with_expiry(base, signing_key, template, data_json, ext, None) +} + +/// Like [`signed_get_url`], optionally adding an `exp` (unix seconds) after +/// which the server answers `403`. `exp` is part of the signed payload. +pub fn signed_get_url_with_expiry( + base: &str, + signing_key: &str, + template: &str, + data_json: &str, + ext: &str, + exp: Option, ) -> String { use base64::Engine; let d = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(data_json.as_bytes()); - let payload = format!("{template}:{d}"); + let exp_s = exp.map(|e| e.to_string()); + let payload = signed_payload(template, &d, exp_s.as_deref()); let sig = sign_payload(signing_key, payload.as_bytes()); - format!("{base}/r/{template}.{ext}?d={d}&sig={sig}") + match exp_s { + Some(e) => format!("{base}/r/{template}.{ext}?d={d}&exp={e}&sig={sig}"), + None => format!("{base}/r/{template}.{ext}?d={d}&sig={sig}"), + } } /// Treat an empty string as "not set". @@ -293,10 +340,23 @@ pub async fn run_on( port: u16, api_key: Option, image_policy: crate::text::ImagePolicy, +) -> anyhow::Result<()> { + run_with(host, port, api_key, None, image_policy).await +} + +/// Full-control entry point. `signing_key` is the HMAC key for `/r/...` +/// URLs; when `None` it falls back to the API key (legacy behavior). +pub async fn run_with( + host: &str, + port: u16, + api_key: Option, + signing_key: Option, + image_policy: crate::text::ImagePolicy, ) -> anyhow::Result<()> { // Blank values (docker-compose passes one when the variable is unset) // mean "not configured". let api_key = non_blank(api_key); + let signing_key = non_blank(signing_key).or_else(|| api_key.clone()); let ip: std::net::IpAddr = host .parse() .map_err(|_| anyhow::anyhow!("invalid --host '{host}': expected an IP address"))?; @@ -325,7 +385,7 @@ pub async fn run_on( render_slots: tokio::sync::Semaphore::new(max_concurrent), image_policy, api_key: api_key.clone(), - signing_key: api_key.clone(), + signing_key, }); // Protected routes require auth @@ -704,8 +764,9 @@ async fn signed_render_handler( ); }; - // 4. Verify signature + decode payload (also rejects >8 KB payloads) - let data = match verify_signed_request(&signing_key, &template_id, d, sig) { + // 4. Verify signature + expiry + decode payload (also rejects >8 KB payloads) + let exp = params.get("exp").map(String::as_str); + let data = match verify_signed_request(&signing_key, &template_id, d, exp, sig) { Ok(data) => data, Err((status, msg)) => return error_response(status, msg), }; @@ -746,6 +807,11 @@ async fn signed_render_handler( } // 6. Render first slide on the blocking pool + // Don't let a CDN cache an image past the URL's own expiry. + let max_age = exp + .and_then(|e| e.parse::().ok()) + .map(|e| e.saturating_sub(unix_now()).min(3600)) + .unwrap_or(3600); let font_db = Arc::clone(&state.font_db); let image_policy = state.image_policy; let scale = 1.0_f32; @@ -782,8 +848,8 @@ async fn signed_render_handler( ( StatusCode::OK, [ - (header::CONTENT_TYPE, image_format.mime_type()), - (header::CACHE_CONTROL, "public, max-age=3600"), + (header::CONTENT_TYPE, image_format.mime_type().to_string()), + (header::CACHE_CONTROL, format!("public, max-age={max_age}")), ], bytes, ) diff --git a/tests/api_server.rs b/tests/api_server.rs index d1d754c..ff7eb52 100644 --- a/tests/api_server.rs +++ b/tests/api_server.rs @@ -18,6 +18,11 @@ fn start_server() -> String { /// Start the API server with an optional API key for auth testing. fn start_server_with_key(api_key: Option) -> String { + start_server_with_keys(api_key, None) +} + +/// Start the API server with an API key and an optional separate signing key. +fn start_server_with_keys(api_key: Option, signing_key: Option) -> String { // Find a free port by binding a temporary listener let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let port = listener.local_addr().unwrap().port(); @@ -26,9 +31,11 @@ fn start_server_with_key(api_key: Option) -> String { thread::spawn(move || { let runtime = tokio::runtime::Runtime::new().unwrap(); runtime - .block_on(server::run( + .block_on(server::run_with( + "127.0.0.1", port, api_key, + signing_key, cosy::text::ImagePolicy::UNRESTRICTED, )) .unwrap(); @@ -981,3 +988,82 @@ fn test_empty_api_key_means_auth_disabled() { .unwrap(); assert_eq!(health["auth_enabled"], false); } + +// ─── Signing key + expiry (#129) ──────────────────────────────────── + +const SIGNED_DATA: &str = + r#"{"brand":{"brand_name":"T"},"slides":[{"stat_number":"1%","stat_label":"x","source":"x"}]}"#; + +fn now_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() +} + +#[test] +fn test_signed_get_separate_signing_key() { + let url = start_server_with_keys(Some("api-key".into()), Some("sign-key".into())); + // API key must NOT be able to sign when a dedicated signing key is set + let with_api = server::signed_get_url(&url, "api-key", "stat-card", SIGNED_DATA, "png"); + assert_eq!(http_client().get(&with_api).send().unwrap().status(), 403); + let with_sign = server::signed_get_url(&url, "sign-key", "stat-card", SIGNED_DATA, "png"); + assert_eq!(http_client().get(&with_sign).send().unwrap().status(), 200); +} + +#[test] +fn test_signed_get_enabled_by_signing_key_alone() { + let url = start_server_with_keys(None, Some("sign-key".into())); + let signed = server::signed_get_url(&url, "sign-key", "stat-card", SIGNED_DATA, "png"); + assert_eq!(http_client().get(&signed).send().unwrap().status(), 200); +} + +#[test] +fn test_signed_get_expiry() { + let key = "exp-key"; + let url = start_server_with_key(Some(key.into())); + let exp = now_secs() + 120; + let ok = + server::signed_get_url_with_expiry(&url, key, "stat-card", SIGNED_DATA, "png", Some(exp)); + let resp = http_client().get(&ok).send().unwrap(); + assert_eq!(resp.status(), 200); + let cc = resp.headers()["cache-control"] + .to_str() + .unwrap() + .to_string(); + let max_age: u64 = cc.trim_start_matches("public, max-age=").parse().unwrap(); + assert!(max_age <= 120, "cache must not outlive exp, got {cc}"); + + let expired = server::signed_get_url_with_expiry( + &url, + key, + "stat-card", + SIGNED_DATA, + "png", + Some(now_secs() - 5), + ); + assert_eq!(http_client().get(&expired).send().unwrap().status(), 403); +} + +#[test] +fn test_signed_get_exp_cannot_be_stripped_or_added() { + let key = "exp-key-2"; + let url = start_server_with_key(Some(key.into())); + // strip exp from an expiring URL (even an already-expired one) + let expired = server::signed_get_url_with_expiry( + &url, + key, + "stat-card", + SIGNED_DATA, + "png", + Some(now_secs() - 5), + ); + let stripped = expired.split("&exp=").next().unwrap().to_string() + + "&sig=" + + expired.split("&sig=").nth(1).unwrap(); + assert_eq!(http_client().get(&stripped).send().unwrap().status(), 403); + // add exp to a legacy (no-exp) signature + let legacy = server::signed_get_url(&url, key, "stat-card", SIGNED_DATA, "png"); + let injected = format!("{legacy}&exp={}", now_secs() + 3600); + assert_eq!(http_client().get(&injected).send().unwrap().status(), 403); +}