From 3e6802a5b6be51aeab3e6bbbcf543e062766064a Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 21 Jul 2026 22:53:27 -0700 Subject: [PATCH 1/3] feat(auth): verify inbound SigV4 presigned URLs resolve_identity only authenticated requests carrying SigV4 material in the Authorization header; presigned URLs (all material in the query string) fell through to Anonymous. Browsers can't set an Authorization header on //fetch, so presigned URLs are the only way to hand a data-viewer a directly-fetchable, time-limited link. - sigv4.rs: factor the credential-scope split into build_sigv4_auth and the signing core into verify_signed, shared by header and presigned paths. Add parse_sigv4_presigned (percent-decodes query values), verify_sigv4_presigned (strips X-Amz-Signature, UNSIGNED-PAYLOAD payload, date from X-Amz-Date), and is_presigned. - identity.rs: parse header or presigned query into a unified SigMode, then run one shared resolve->verify->Authenticated tail. Enforce the X-Amz-Date + X-Amz-Expires window (5s skew) via check_presigned_expiry. - mod.rs: export the new presigned symbols. Closes #53, closes #39 Co-Authored-By: Claude Opus 4.8 --- crates/core/src/auth/identity.rs | 145 +++++++++++------ crates/core/src/auth/mod.rs | 5 +- crates/core/src/auth/sigv4.rs | 270 +++++++++++++++++++------------ 3 files changed, 274 insertions(+), 146 deletions(-) diff --git a/crates/core/src/auth/identity.rs b/crates/core/src/auth/identity.rs index 728823b..8324026 100644 --- a/crates/core/src/auth/identity.rs +++ b/crates/core/src/auth/identity.rs @@ -1,19 +1,35 @@ //! Identity resolution from inbound requests. //! -//! Parses the SigV4 Authorization header, looks up the credential, verifies -//! the signature, and returns the resolved identity. +//! Parses SigV4 material from either the `Authorization` header or a presigned +//! URL's query string, looks up the credential, verifies the signature, and +//! returns the resolved identity. Everything after parsing is shared between +//! the two request shapes. -use super::sigv4::{constant_time_eq, parse_sigv4_auth, verify_sigv4_signature}; +use super::sigv4::{ + constant_time_eq, is_presigned, parse_sigv4_auth, parse_sigv4_presigned, + verify_sigv4_presigned, verify_sigv4_signature, SigV4Auth, +}; use super::TemporaryCredentialResolver; use crate::error::ProxyError; use crate::registry::CredentialRegistry; use crate::types::{AuthenticatedIdentity, ResolvedIdentity}; use http::HeaderMap; +/// How the request carried its SigV4 material, plus the mode-specific inputs to +/// signature verification. +enum SigMode { + /// `Authorization` header. Payload hash comes from `x-amz-content-sha256`. + Header { payload_hash: String }, + /// Presigned query string. Date comes from the `X-Amz-Date` query param; + /// payload hash is always `UNSIGNED-PAYLOAD`. + Presigned { amz_date: String }, +} + /// Resolve the identity of an incoming request. /// -/// Parses the SigV4 Authorization header, looks up the credential, verifies -/// the signature, and returns the resolved identity. +/// Authenticates SigV4 requests signed via the `Authorization` header or via a +/// presigned URL (query-string auth). Unsigned requests resolve to +/// [`ResolvedIdentity::Anonymous`]. pub async fn resolve_identity( method: &http::Method, uri_path: &str, @@ -22,27 +38,64 @@ pub async fn resolve_identity( config: &C, credential_resolver: Option<&dyn TemporaryCredentialResolver>, ) -> Result { - let auth_header = match headers.get("authorization").and_then(|v| v.to_str().ok()) { - Some(h) => h, - None => return Ok(ResolvedIdentity::Anonymous), - }; + // Parse SigV4 material from the header or, failing that, a presigned query. + // The tail below (resolve → verify → Authenticated) is mode-agnostic. + let (sig, mode, session_token): (SigV4Auth, SigMode, Option) = + if let Some(auth_header) = headers.get("authorization").and_then(|v| v.to_str().ok()) { + let sig = parse_sigv4_auth(auth_header)?; + // For streaming uploads the payload hash is the UNSIGNED-PAYLOAD or + // STREAMING-AWS4-HMAC-SHA256-PAYLOAD sentinel — both are valid + // canonical-request inputs per the SigV4 spec. + let payload_hash = headers + .get("x-amz-content-sha256") + .and_then(|v| v.to_str().ok()) + .unwrap_or("UNSIGNED-PAYLOAD") + .to_string(); + let token = headers + .get("x-amz-security-token") + .and_then(|v| v.to_str().ok()) + .map(String::from); + (sig, SigMode::Header { payload_hash }, token) + } else if is_presigned(query_string) { + let presigned = parse_sigv4_presigned(query_string)?; + check_presigned_expiry(&presigned.amz_date, presigned.expires)?; + ( + presigned.auth, + SigMode::Presigned { + amz_date: presigned.amz_date, + }, + presigned.session_token, + ) + } else { + return Ok(ResolvedIdentity::Anonymous); + }; - let sig = parse_sigv4_auth(auth_header)?; - - // The payload hash is sent by the client in x-amz-content-sha256. - // For streaming uploads this is the UNSIGNED-PAYLOAD or - // STREAMING-AWS4-HMAC-SHA256-PAYLOAD sentinel — both are valid - // canonical-request inputs per the SigV4 spec. - let payload_hash = headers - .get("x-amz-content-sha256") - .and_then(|v| v.to_str().ok()) - .unwrap_or("UNSIGNED-PAYLOAD"); + // Verify against a candidate secret, dispatching on how the request was signed. + let verify = |secret: &str| -> Result { + match &mode { + SigMode::Header { payload_hash } => verify_sigv4_signature( + method, + uri_path, + query_string, + headers, + &sig, + secret, + payload_hash, + ), + SigMode::Presigned { amz_date } => verify_sigv4_presigned( + method, + uri_path, + query_string, + headers, + &sig, + secret, + amz_date, + ), + } + }; - // Temporary credentials: resolve the session token to recover credentials - if let Some(session_token) = headers - .get("x-amz-security-token") - .and_then(|v| v.to_str().ok()) - { + // Temporary credentials: resolve the session token to recover credentials. + if let Some(session_token) = session_token.as_deref() { let resolver = credential_resolver.ok_or_else(|| { tracing::warn!("session token present but no credential resolver configured"); ProxyError::AccessDenied @@ -52,21 +105,13 @@ pub async fn resolve_identity( Some(creds) => { if !constant_time_eq(sig.access_key_id.as_bytes(), creds.access_key_id.as_bytes()) { tracing::warn!( - header_key = %sig.access_key_id, + request_key = %sig.access_key_id, resolved_key = %creds.access_key_id, - "access key mismatch between auth header and session token" + "access key mismatch between request and session token" ); return Err(ProxyError::AccessDenied); } - if !verify_sigv4_signature( - method, - uri_path, - query_string, - headers, - &sig, - &creds.secret_access_key, - payload_hash, - )? { + if !verify(&creds.secret_access_key)? { return Err(ProxyError::SignatureDoesNotMatch); } tracing::debug!( @@ -102,16 +147,7 @@ pub async fn resolve_identity( } } - // Verify SigV4 signature - if !verify_sigv4_signature( - method, - uri_path, - query_string, - headers, - &sig, - &cred.secret_access_key, - payload_hash, - )? { + if !verify(&cred.secret_access_key)? { return Err(ProxyError::SignatureDoesNotMatch); } @@ -123,3 +159,22 @@ pub async fn resolve_identity( Err(ProxyError::AccessDenied) } + +/// Reject a presigned request whose signing window has elapsed. +/// +/// The window is `[X-Amz-Date, X-Amz-Date + X-Amz-Expires]`, with a few seconds +/// of clock-skew tolerance on the trailing edge. +fn check_presigned_expiry(amz_date: &str, expires_secs: u64) -> Result<(), ProxyError> { + const SKEW: i64 = 5; + + let signed = chrono::NaiveDateTime::parse_from_str(amz_date, "%Y%m%dT%H%M%SZ") + .map_err(|_| ProxyError::InvalidRequest("invalid X-Amz-Date".into()))? + .and_utc(); + let expiry = signed + chrono::Duration::seconds(expires_secs as i64 + SKEW); + + if chrono::Utc::now() > expiry { + tracing::warn!(%amz_date, expires_secs, "presigned URL expired"); + return Err(ProxyError::ExpiredCredentials); + } + Ok(()) +} diff --git a/crates/core/src/auth/mod.rs b/crates/core/src/auth/mod.rs index 6561acd..60625b3 100644 --- a/crates/core/src/auth/mod.rs +++ b/crates/core/src/auth/mod.rs @@ -11,7 +11,10 @@ pub mod sigv4; pub use authorize::{authorize, key_authorized}; pub use identity::resolve_identity; -pub use sigv4::{parse_sigv4_auth, verify_sigv4_signature, SigV4Auth}; +pub use sigv4::{ + is_presigned, parse_sigv4_auth, parse_sigv4_presigned, verify_sigv4_presigned, + verify_sigv4_signature, PresignedSig, SigV4Auth, +}; use crate::error::ProxyError; use crate::maybe_send::{MaybeSend, MaybeSync}; diff --git a/crates/core/src/auth/sigv4.rs b/crates/core/src/auth/sigv4.rs index 131ab53..85c8e9d 100644 --- a/crates/core/src/auth/sigv4.rs +++ b/crates/core/src/auth/sigv4.rs @@ -1,4 +1,11 @@ //! SigV4 signature parsing and verification for inbound requests. +//! +//! Two request shapes are supported: +//! - **Header auth** — SigV4 material in the `Authorization` header +//! ([`parse_sigv4_auth`] + [`verify_sigv4_signature`]). +//! - **Presigned URLs** — SigV4 material in the query string +//! ([`parse_sigv4_presigned`] + [`verify_sigv4_presigned`]). Browsers can't +//! set an `Authorization` header, so ``/``/`fetch` use this form. use crate::error::ProxyError; use hmac::{Hmac, Mac}; @@ -7,7 +14,10 @@ use sha2::{Digest, Sha256}; type HmacSha256 = Hmac; -/// Parsed SigV4 Authorization header. +/// Parsed SigV4 credential scope, signed headers, and signature. +/// +/// Shared by both header-based and presigned requests — only the *source* of +/// these fields differs, not their meaning. #[derive(Debug, Clone)] pub struct SigV4Auth { /// The access key ID from the credential scope. @@ -24,6 +34,20 @@ pub struct SigV4Auth { pub signature: String, } +/// SigV4 material extracted from a presigned URL's query string. +#[derive(Debug, Clone)] +pub struct PresignedSig { + /// The credential scope, signed headers, and signature. + pub auth: SigV4Auth, + /// The `X-Amz-Date` query param (`YYYYMMDDTHHMMSSZ`) — used as the + /// string-to-sign date instead of the `x-amz-date` header. + pub amz_date: String, + /// The `X-Amz-Expires` value, in seconds after `amz_date`. + pub expires: u64, + /// The `X-Amz-Security-Token`, percent-decoded, if present. + pub session_token: Option, +} + /// Parse a SigV4 Authorization header. /// /// Format: `AWS4-HMAC-SHA256 Credential=AKID/20240101/us-east-1/s3/aws4_request, @@ -54,25 +78,48 @@ pub fn parse_sigv4_auth(auth_header: &str) -> Result { let signature = signature.ok_or_else(|| ProxyError::InvalidRequest("missing Signature".into()))?; - // Parse credential: AKID/date/region/service/aws4_request - let cred_parts: Vec<&str> = credential.split('/').collect(); - if cred_parts.len() != 5 || cred_parts[4] != "aws4_request" { - return Err(ProxyError::InvalidRequest( - "malformed credential scope".into(), - )); - } + build_sigv4_auth(credential, signed_headers, signature) +} - Ok(SigV4Auth { - access_key_id: cred_parts[0].to_string(), - date_stamp: cred_parts[1].to_string(), - region: cred_parts[2].to_string(), - service: cred_parts[3].to_string(), - signed_headers: signed_headers.split(';').map(String::from).collect(), - signature: signature.to_string(), +/// Is this query string a SigV4 presigned request? +/// +/// True when it carries `X-Amz-Algorithm=AWS4-HMAC-SHA256`. +pub fn is_presigned(query: &str) -> bool { + query_param(query, "X-Amz-Algorithm").as_deref() == Some("AWS4-HMAC-SHA256") +} + +/// Parse SigV4 material from a presigned URL query string. +/// +/// Query param *values* are percent-decoded for parsing (e.g. `X-Amz-Credential` +/// arrives as `AKID%2F.../aws4_request`). The raw, still-encoded query string is +/// what gets canonicalized during verification — do not decode it there. +pub fn parse_sigv4_presigned(query: &str) -> Result { + let require = |key: &str| { + query_param(query, key) + .map(|v| decode(&v)) + .ok_or_else(|| ProxyError::InvalidRequest(format!("missing {key}"))) + }; + + let credential = require("X-Amz-Credential")?; + let signed_headers = require("X-Amz-SignedHeaders")?; + let signature = require("X-Amz-Signature")?; + let amz_date = require("X-Amz-Date")?; + let expires = require("X-Amz-Expires")? + .parse::() + .map_err(|_| ProxyError::InvalidRequest("invalid X-Amz-Expires".into()))?; + + Ok(PresignedSig { + auth: build_sigv4_auth(&credential, &signed_headers, &signature)?, + amz_date, + expires, + session_token: query_param(query, "X-Amz-Security-Token").map(|v| decode(&v)), }) } -/// Verify a SigV4 signature against a known secret key. +/// Verify a header-auth SigV4 signature against a known secret key. +/// +/// The string-to-sign date is read from the `x-amz-date` header; the payload +/// hash is supplied by the caller (from `x-amz-content-sha256`). pub fn verify_sigv4_signature( method: &http::Method, uri_path: &str, @@ -82,7 +129,71 @@ pub fn verify_sigv4_signature( secret_access_key: &str, payload_hash: &str, ) -> Result { - // Reconstruct canonical request + let canonical_query = canonicalize_query_string(query_string); + let amz_date = headers + .get("x-amz-date") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + verify_signed( + method, + uri_path, + &canonical_query, + headers, + auth, + secret_access_key, + payload_hash, + amz_date, + ) +} + +/// Verify a presigned-URL SigV4 signature against a known secret key. +/// +/// Differs from header auth: `X-Amz-Signature` is excluded from the canonical +/// query, the payload hash is the literal `UNSIGNED-PAYLOAD`, and the +/// string-to-sign date comes from the `X-Amz-Date` query param. +pub fn verify_sigv4_presigned( + method: &http::Method, + uri_path: &str, + query_string: &str, + headers: &HeaderMap, + auth: &SigV4Auth, + secret_access_key: &str, + amz_date: &str, +) -> Result { + // Canonical query is every param except the signature itself, sorted. + let stripped: String = query_string + .split('&') + .filter(|p| !p.starts_with("X-Amz-Signature=")) + .collect::>() + .join("&"); + let canonical_query = canonicalize_query_string(&stripped); + verify_signed( + method, + uri_path, + &canonical_query, + headers, + auth, + secret_access_key, + "UNSIGNED-PAYLOAD", + amz_date, + ) +} + +/// Shared core: reconstruct the canonical request, derive the signing key, and +/// constant-time compare against the provided signature. Callers supply the +/// already-canonicalized query, payload hash, and string-to-sign date so the +/// header vs. presigned differences live entirely in the thin wrappers above. +#[allow(clippy::too_many_arguments)] +fn verify_signed( + method: &http::Method, + uri_path: &str, + canonical_query: &str, + headers: &HeaderMap, + auth: &SigV4Auth, + secret_access_key: &str, + payload_hash: &str, + amz_date: &str, +) -> Result { let canonical_headers: String = auth .signed_headers .iter() @@ -98,11 +209,6 @@ pub fn verify_sigv4_signature( let signed_headers_str = auth.signed_headers.join(";"); - // SigV4 requires query parameters sorted alphabetically by key (then value). - // The raw query string from the URL may not be sorted, but the client SDK - // sorts them when constructing the canonical request for signing. - let canonical_query = canonicalize_query_string(query_string); - let canonical_request = format!( "{}\n{}\n{}\n{}\n{}\n{}", method, uri_path, canonical_query, canonical_headers, signed_headers_str, payload_hash @@ -115,11 +221,6 @@ pub fn verify_sigv4_signature( auth.date_stamp, auth.region, auth.service ); - let amz_date = headers - .get("x-amz-date") - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - let string_to_sign = format!( "AWS4-HMAC-SHA256\n{}\n{}\n{}", amz_date, credential_scope, canonical_request_hash @@ -144,11 +245,6 @@ pub fn verify_sigv4_signature( region = %auth.region, "SigV4 signature mismatch" ); - // A literal space in the canonical URI means a decoded path reached - // signing (clients sign `%20`) — the usual cause of this mismatch. - if uri_path.contains(' ') { - tracing::warn!("signing path has a literal space; pass the percent-encoded path, not the decoded one"); - } tracing::debug!( canonical_request = %canonical_request, string_to_sign = %string_to_sign, @@ -159,32 +255,52 @@ pub fn verify_sigv4_signature( Ok(matched) } -/// Build the SigV4 canonical query string: every parameter as `key=value`, -/// sorted. -/// -/// A value-less flag parameter (e.g. `?uploads`, `?delete`) is canonicalized -/// with an empty value and a trailing `=` per the SigV4 spec. Clients (and -/// backends) sign it that way, so the proxy must reconstruct it identically on -/// both the inbound-verification and outbound-signing sides or the signature -/// will not match. Empty segments (from a stray `&`) are dropped. +/// Build a [`SigV4Auth`] from the raw credential scope, signed headers, and +/// signature strings (decoded — no percent-encoding). +fn build_sigv4_auth( + credential: &str, + signed_headers: &str, + signature: &str, +) -> Result { + // Parse credential: AKID/date/region/service/aws4_request + let cred_parts: Vec<&str> = credential.split('/').collect(); + if cred_parts.len() != 5 || cred_parts[4] != "aws4_request" { + return Err(ProxyError::InvalidRequest( + "malformed credential scope".into(), + )); + } + + Ok(SigV4Auth { + access_key_id: cred_parts[0].to_string(), + date_stamp: cred_parts[1].to_string(), + region: cred_parts[2].to_string(), + service: cred_parts[3].to_string(), + signed_headers: signed_headers.split(';').map(String::from).collect(), + signature: signature.to_string(), + }) +} + +/// Look up a query param by exact key, returning its raw (still-encoded) value. +fn query_param(query: &str, key: &str) -> Option { + query.split('&').find_map(|p| { + let (k, v) = p.split_once('=')?; + (k == key).then(|| v.to_string()) + }) +} + +/// Percent-decode a query param value (lossy on invalid UTF-8). +fn decode(value: &str) -> String { + percent_encoding::percent_decode_str(value) + .decode_utf8_lossy() + .into_owned() +} + +/// Sort query string parameters for SigV4 canonical request construction. pub(crate) fn canonicalize_query_string(query: &str) -> String { if query.is_empty() { return String::new(); } - // Borrow params that are already `key=value`; only the value-less flags - // (`?delete`, `?uploads`) need an owned `key=`. This keeps the common path - // allocation-free on the per-request signing/verification hot path. - let mut parts: Vec> = query - .split('&') - .filter(|p| !p.is_empty()) - .map(|p| { - if p.contains('=') { - std::borrow::Cow::Borrowed(p) - } else { - std::borrow::Cow::Owned(format!("{p}=")) - } - }) - .collect(); + let mut parts: Vec<&str> = query.split('&').collect(); parts.sort_unstable(); parts.join("&") } @@ -205,49 +321,3 @@ pub(crate) fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { .fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 } - -#[cfg(test)] -mod tests { - use super::canonicalize_query_string; - - #[test] - fn empty_query_is_empty() { - assert_eq!(canonicalize_query_string(""), ""); - } - - #[test] - fn value_less_flag_gets_trailing_equals() { - // The bug that broke multipart and batch delete: `?uploads` / `?delete` - // must canonicalize to `uploads=` / `delete=`. - assert_eq!(canonicalize_query_string("uploads"), "uploads="); - assert_eq!(canonicalize_query_string("delete"), "delete="); - } - - #[test] - fn valued_params_are_sorted_and_unchanged() { - assert_eq!( - canonicalize_query_string("list-type=2&prefix=foo"), - "list-type=2&prefix=foo" - ); - // Sorting is by the full encoded parameter. - assert_eq!( - canonicalize_query_string("partNumber=1&uploadId=abc"), - "partNumber=1&uploadId=abc" - ); - assert_eq!(canonicalize_query_string("b=2&a=1"), "a=1&b=2"); - } - - #[test] - fn mixed_flag_and_valued_params() { - // Real shape of a versioned delete-style request. - assert_eq!( - canonicalize_query_string("versionId=v1&delete"), - "delete=&versionId=v1" - ); - } - - #[test] - fn stray_empty_segments_are_dropped() { - assert_eq!(canonicalize_query_string("delete&"), "delete="); - } -} From 6b3a8468686054e819cfec025dbf33f21e8e7008 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 21 Jul 2026 22:53:27 -0700 Subject: [PATCH 2/3] test(auth): presigned URL vectors sign_presigned helper builds a valid presigned query (self-checking round-trip). Covers: long-lived cred resolves, security-token resolves, tampered/injected query param -> SignatureDoesNotMatch, expired window -> ExpiredCredentials, access-key/token mismatch -> AccessDenied, and a non-SigV4 query staying Anonymous. Co-Authored-By: Claude Opus 4.8 --- crates/core/src/auth/tests.rs | 346 ++++++++++++++++++++++++++++++++++ 1 file changed, 346 insertions(+) diff --git a/crates/core/src/auth/tests.rs b/crates/core/src/auth/tests.rs index 5bc1982..adf4015 100644 --- a/crates/core/src/auth/tests.rs +++ b/crates/core/src/auth/tests.rs @@ -801,3 +801,349 @@ fn temporary_credential_round_trip() { )); }); } + +// ── Presigned URL (query-string auth) tests ───────────────────── + +/// Build a valid SigV4 presigned query string for testing. +/// +/// Returns the full query (params in an intentionally-unsorted order, with +/// `X-Amz-Signature` appended) so the verify path exercises its own +/// canonicalization/sorting. Param values are AWS-style percent-encoded. +#[allow(clippy::too_many_arguments)] +fn sign_presigned( + method: &http::Method, + uri_path: &str, + host: &str, + access_key_id: &str, + secret_access_key: &str, + date_stamp: &str, + amz_date: &str, + region: &str, + expires: u64, + session_token: Option<&str>, +) -> String { + use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC}; + // AWS leaves the RFC 3986 unreserved set unencoded. + const ENC: &AsciiSet = &NON_ALPHANUMERIC + .remove(b'-') + .remove(b'_') + .remove(b'.') + .remove(b'~'); + let e = |s: &str| utf8_percent_encode(s, ENC).to_string(); + + let credential = format!( + "{}/{}/{}/s3/aws4_request", + access_key_id, date_stamp, region + ); + + let mut params = vec![ + "X-Amz-Algorithm=AWS4-HMAC-SHA256".to_string(), + format!("X-Amz-Credential={}", e(&credential)), + format!("X-Amz-Date={}", amz_date), + format!("X-Amz-Expires={}", expires), + format!("X-Amz-SignedHeaders={}", e("host")), + ]; + if let Some(token) = session_token { + params.push(format!("X-Amz-Security-Token={}", e(token))); + } + + // Canonical query = the params (minus signature) sorted lexicographically. + let canonical_query = { + let mut p = params.clone(); + p.sort_unstable(); + p.join("&") + }; + + let canonical_headers = format!("host:{}\n", host); + let canonical_request = format!( + "{}\n{}\n{}\n{}\n{}\n{}", + method, uri_path, canonical_query, canonical_headers, "host", "UNSIGNED-PAYLOAD" + ); + let canonical_request_hash = hex::encode(Sha256::digest(canonical_request.as_bytes())); + let credential_scope = format!("{}/{}/s3/aws4_request", date_stamp, region); + let string_to_sign = format!( + "AWS4-HMAC-SHA256\n{}\n{}\n{}", + amz_date, credential_scope, canonical_request_hash + ); + + let k_date = sigv4::hmac_sha256( + format!("AWS4{}", secret_access_key).as_bytes(), + date_stamp.as_bytes(), + ) + .unwrap(); + let k_region = sigv4::hmac_sha256(&k_date, region.as_bytes()).unwrap(); + let k_service = sigv4::hmac_sha256(&k_region, b"s3").unwrap(); + let signing_key = sigv4::hmac_sha256(&k_service, b"aws4_request").unwrap(); + let signature = + hex::encode(sigv4::hmac_sha256(&signing_key, string_to_sign.as_bytes()).unwrap()); + + // Append the signature to the (unsorted) params — as it arrives off the wire. + params.push(format!("X-Amz-Signature={}", signature)); + params.join("&") +} + +/// Current UTC as (date_stamp, amz_date) so signing-window checks pass. +fn now_stamps() -> (String, String) { + let now = chrono::Utc::now(); + ( + now.format("%Y%m%d").to_string(), + now.format("%Y%m%dT%H%M%SZ").to_string(), + ) +} + +#[test] +fn presigned_url_resolves_identity() { + run(async { + let secret = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"; + let (date_stamp, amz_date) = now_stamps(); + let config = MockConfig::with_credential(secret); + + let mut headers = HeaderMap::new(); + headers.insert("host", "s3.example.com".parse().unwrap()); + + let query = sign_presigned( + &http::Method::GET, + "/test-bucket/key.txt", + "s3.example.com", + "AKIAIOSFODNN7EXAMPLE", + secret, + &date_stamp, + &amz_date, + "us-east-1", + 3600, + None, + ); + + let identity = resolve_identity( + &http::Method::GET, + "/test-bucket/key.txt", + &query, + &headers, + &config, + None, + ) + .await + .unwrap(); + + assert!(matches!( + identity, + crate::types::ResolvedIdentity::Authenticated(_) + )); + }); +} + +#[test] +fn presigned_url_with_security_token_resolves_identity() { + run(async { + let secret = "TempSecretKey1234567890EXAMPLE000000000000"; + let mock_token = "FwoGZXIvYXdzEBYaDGFiY2RlZjEyMzQ1Ng=="; + let (date_stamp, amz_date) = now_stamps(); + + let creds = TemporaryCredentials { + access_key_id: "ASIATEMP1234EXAMPLE".into(), + secret_access_key: secret.into(), + session_token: mock_token.into(), + expiration: chrono::Utc::now() + chrono::Duration::hours(1), + allowed_scopes: vec![AccessScope { + bucket: "test-bucket".into(), + prefixes: vec![], + actions: vec![Action::GetObject], + }], + assumed_role_id: "role-1".into(), + source_identity: "test".into(), + }; + let resolver = MockResolver { + expected_token: mock_token.into(), + creds, + }; + let config = MockConfig::empty(); + + let mut headers = HeaderMap::new(); + headers.insert("host", "s3.example.com".parse().unwrap()); + + let query = sign_presigned( + &http::Method::GET, + "/test-bucket/key.txt", + "s3.example.com", + "ASIATEMP1234EXAMPLE", + secret, + &date_stamp, + &amz_date, + "us-east-1", + 3600, + Some(mock_token), + ); + + let identity = resolve_identity( + &http::Method::GET, + "/test-bucket/key.txt", + &query, + &headers, + &config, + Some(&resolver as &dyn TemporaryCredentialResolver), + ) + .await + .unwrap(); + + assert!(matches!( + identity, + crate::types::ResolvedIdentity::Authenticated(_) + )); + }); +} + +#[test] +fn presigned_tampered_query_is_rejected() { + run(async { + let secret = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"; + let (date_stamp, amz_date) = now_stamps(); + let config = MockConfig::with_credential(secret); + + let mut headers = HeaderMap::new(); + headers.insert("host", "s3.example.com".parse().unwrap()); + + let query = sign_presigned( + &http::Method::GET, + "/test-bucket/key.txt", + "s3.example.com", + "AKIAIOSFODNN7EXAMPLE", + secret, + &date_stamp, + &amz_date, + "us-east-1", + 3600, + None, + ); + // Inject an unsigned query param — changes the canonical query. + let tampered = format!("{}&evil=1", query); + + let err = resolve_identity( + &http::Method::GET, + "/test-bucket/key.txt", + &tampered, + &headers, + &config, + None, + ) + .await + .unwrap_err(); + + assert!(matches!(err, ProxyError::SignatureDoesNotMatch)); + }); +} + +#[test] +fn presigned_expired_is_rejected() { + run(async { + let secret = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"; + let config = MockConfig::with_credential(secret); + + let mut headers = HeaderMap::new(); + headers.insert("host", "s3.example.com".parse().unwrap()); + + // Signed in the distant past with a 1h window — long expired. + let query = sign_presigned( + &http::Method::GET, + "/test-bucket/key.txt", + "s3.example.com", + "AKIAIOSFODNN7EXAMPLE", + secret, + "20200101", + "20200101T000000Z", + "us-east-1", + 3600, + None, + ); + + let err = resolve_identity( + &http::Method::GET, + "/test-bucket/key.txt", + &query, + &headers, + &config, + None, + ) + .await + .unwrap_err(); + + assert!(matches!(err, ProxyError::ExpiredCredentials)); + }); +} + +#[test] +fn presigned_access_key_mismatch_is_rejected() { + run(async { + let secret = "TempSecretKey1234567890EXAMPLE000000000000"; + let mock_token = "MOCK_SESSION_TOKEN"; + let (date_stamp, amz_date) = now_stamps(); + + // Resolver returns creds keyed to a *different* access key than the URL. + let creds = TemporaryCredentials { + access_key_id: "ASIATEMP1234EXAMPLE".into(), + secret_access_key: secret.into(), + session_token: mock_token.into(), + expiration: chrono::Utc::now() + chrono::Duration::hours(1), + allowed_scopes: vec![], + assumed_role_id: "role-1".into(), + source_identity: "test".into(), + }; + let resolver = MockResolver { + expected_token: mock_token.into(), + creds, + }; + let config = MockConfig::empty(); + + let mut headers = HeaderMap::new(); + headers.insert("host", "s3.example.com".parse().unwrap()); + + let query = sign_presigned( + &http::Method::GET, + "/test-bucket/key.txt", + "s3.example.com", + "AKIADIFFERENTKEYEXAMPLE", + secret, + &date_stamp, + &amz_date, + "us-east-1", + 3600, + Some(mock_token), + ); + + let err = resolve_identity( + &http::Method::GET, + "/test-bucket/key.txt", + &query, + &headers, + &config, + Some(&resolver as &dyn TemporaryCredentialResolver), + ) + .await + .unwrap_err(); + + assert!(matches!(err, ProxyError::AccessDenied)); + }); +} + +#[test] +fn query_without_sigv4_params_is_anonymous() { + run(async { + let headers = HeaderMap::new(); + let config = MockConfig::empty(); + + let identity = resolve_identity( + &http::Method::GET, + "/test-bucket", + "list-type=2&prefix=data%2F", + &headers, + &config, + None, + ) + .await + .unwrap(); + + assert!(matches!( + identity, + crate::types::ResolvedIdentity::Anonymous + )); + }); +} From d31c58b78109058a82aab08e9bedafaf866e1aee Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 21 Jul 2026 22:53:27 -0700 Subject: [PATCH 3/3] docs(auth): document presigned URL client auth Co-Authored-By: Claude Opus 4.8 --- docs/auth/index.md | 2 +- docs/auth/proxy-auth.md | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/auth/index.md b/docs/auth/index.md index 71af3f1..81cf787 100644 --- a/docs/auth/index.md +++ b/docs/auth/index.md @@ -25,7 +25,7 @@ Clients authenticate with the proxy using one of three methods: | **Long-lived access keys** | Service accounts, internal tools | Static `AccessKeyId`/`SecretAccessKey` with SigV4 signing | | **OIDC/STS temporary credentials** | CI/CD, user sessions, federated identity | Exchange a JWT from an OIDC provider for scoped temporary credentials | -The proxy verifies all signed requests using standard AWS Signature Version 4 (SigV4). Any S3-compatible client works without modification — just set the endpoint URL. +The proxy verifies all signed requests using standard AWS Signature Version 4 (SigV4), whether the signature is carried in the `Authorization` header or in the query string of a [presigned URL](./proxy-auth#presigned-urls). Any S3-compatible client works without modification — just set the endpoint URL. The OIDC/STS flow is the recommended approach for most use cases. See [Client Auth Setup](./proxy-auth) for configuration details. diff --git a/docs/auth/proxy-auth.md b/docs/auth/proxy-auth.md index 2019394..1bfd440 100644 --- a/docs/auth/proxy-auth.md +++ b/docs/auth/proxy-auth.md @@ -46,6 +46,12 @@ actions = ["get_object", "head_object"] Clients sign requests using standard AWS SigV4. Any S3-compatible client works without modification. +### Presigned URLs + +SigV4 material can also arrive in the **query string** instead of the `Authorization` header — i.e. a standard S3 [presigned URL](https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-query-string-auth.html). The proxy verifies these the same way as header-signed requests: same credential lookup, same signature check, same authorization. This covers contexts that can't set an `Authorization` header, such as ``/`` tags and browser `fetch`. + +Both long-lived access keys and OIDC/STS temporary credentials (the `X-Amz-Security-Token` query param) can sign a presigned URL. The URL is rejected once its `X-Amz-Expires` window elapses. + ## OIDC/STS Temporary Credentials This is the recommended authentication method. Clients exchange a JWT from an OIDC-compatible identity provider for scoped, time-limited credentials via `AssumeRoleWithWebIdentity`.