Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
15 changes: 10 additions & 5 deletions docs/api/endpoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,16 +163,21 @@ GET /r/og-image.png?d=<base64url(JSON)>&sig=<hex hmac-sha256>

- `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

Expand Down
15 changes: 14 additions & 1 deletion src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,11 @@ pub enum Command {
#[arg(short, long)]
token: Option<String>,

/// 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<String>,

/// 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.
Expand Down Expand Up @@ -380,13 +385,15 @@ impl Cli {
port,
host,
token,
signing_key,
allow_private_images,
allow_local_image_paths,
} => {
// Resolve API key: --token flag takes priority, then COSY_API_KEY env
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,
Expand All @@ -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)
}
}
Expand Down
86 changes: 76 additions & 10 deletions src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<InputData, (StatusCode, String)> {
// 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
Expand Down Expand Up @@ -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<u64>,
) -> 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".
Expand All @@ -293,10 +340,23 @@ pub async fn run_on(
port: u16,
api_key: Option<String>,
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<String>,
signing_key: Option<String>,
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"))?;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
};
Expand Down Expand Up @@ -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::<u64>().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;
Expand Down Expand Up @@ -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,
)
Expand Down
88 changes: 87 additions & 1 deletion tests/api_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>) -> 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<String>, signing_key: Option<String>) -> 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();
Expand All @@ -26,9 +31,11 @@ fn start_server_with_key(api_key: Option<String>) -> 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();
Expand Down Expand Up @@ -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);
}
Loading