diff --git a/CHANGELOG.md b/CHANGELOG.md index ad20666..73934b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -316,6 +316,32 @@ publishes, so keep it factual and written for the people upgrading. and one event was lost at every page boundary — also in 0.1.3. Webhook delivery was never affected. +### Security + +- **Every `x-amz-*` header must now be signed.** A request could carry `x-amz-*` + headers its signature did not cover, and the server acted on them. That mattered + most for presigned URLs, whose holder could add terms the signer never approved. + Such a request is now refused with `403 AccessDenied` (`There were headers present + in the request which were not signed`), as AWS does. For header authentication this + includes `x-amz-date` and `x-amz-content-sha256`. `Content-MD5` may still be left + unsigned. +- **Setting Object Lock at write time needs the Object Lock permissions.** + `x-amz-object-lock-mode` and `x-amz-object-lock-retain-until-date` on a PUT, copy, + or multipart initiation now require `s3:PutObjectRetention`, and + `x-amz-object-lock-legal-hold` requires `s3:PutObjectLegalHold`. Before, `s3:PutObject` + alone was enough to write a `COMPLIANCE` version nobody could delete. A bucket's + default retention still needs only `s3:PutObject`. +- **Copying a named version needs `s3:GetObjectVersion`.** A copy source with + `?versionId=` was checked against `s3:GetObject`, so an account that could read only + current objects could read any version by copying it. + +**Potentially breaking.** A client that sent unsigned `x-amz-*` headers is now +refused. The AWS SDKs sign every such header, so this affects hand-built requests and +presigned URLs whose holder adds headers: sign the URL with them. An account that +set locks at write time with only `s3:PutObject`, or copied named versions with only +`s3:GetObject`, needs the permissions above. See +[Policies](docs/administration/policies.md). + ## [0.1.3] - 2026-09-16 A patch release that prepares every database for the next one. It changes no diff --git a/crates/record-store-s3/src/auth.rs b/crates/record-store-s3/src/auth.rs index d7a3483..3216301 100644 --- a/crates/record-store-s3/src/auth.rs +++ b/crates/record-store-s3/src/auth.rs @@ -25,10 +25,13 @@ use tracing::info; use crate::cors::{apply_cors_grant, cors_grant_for_request, is_cors_preflight}; use crate::error::{S3Error, S3ErrorKind}; use crate::handlers::listing::query_map; -use crate::response::insert_request_id; +use crate::response::{ + OBJECT_LOCK_LEGAL_HOLD, OBJECT_LOCK_MODE, OBJECT_LOCK_RETAIN_UNTIL, insert_request_id, +}; use crate::sigv4::{ Authenticated, ParsedAuthorization, ParsedPresign, PayloadHash, S3RequestId, calculate_signature, canonical_request, parse_amz_date, parse_payload_hash, parse_request_time, + reject_unsigned_amz_headers, }; use crate::*; @@ -343,6 +346,9 @@ pub(crate) async fn verify_request( }; (parsed, request_time, PayloadHash::Unsigned) }; + // Checked before the credential lookup, so a request that could be + // carrying terms its signer never approved never reaches the secret store. + reject_unsigned_amz_headers(&headers, &parsed.signed_headers)?; if (Utc::now() - request_time).num_seconds().unsigned_abs() > state.allowed_clock_skew.num_seconds() as u64 && headers.contains_key(header::AUTHORIZATION) @@ -499,9 +505,35 @@ pub(crate) fn request_permissions(request: &Request) -> Result, { permissions.push(Permission { action: Action::BypassGovernanceRetention, - resource, + resource: resource.clone(), }); } + // Setting a lock while writing is the same decision as setting it later + // through ?retention or ?legal-hold, and harder to take back: a COMPLIANCE + // retention written with the object outlives every credential that could + // have removed it. So it needs the permission those subresources need, on + // top of PutObject. This covers a plain PUT, a copy, and a multipart + // initiation, which is where a multipart upload's lock is fixed. A bucket's + // default retention is applied by the service when a request names no + // lock, so it is never asked for here and needs nothing extra. Presence is + // what counts, not the value: even `legal-hold: OFF` is an explicit lock + // choice that takes the place of the bucket default. + if key.is_some() { + let headers = request.headers(); + if headers.contains_key(OBJECT_LOCK_MODE) || headers.contains_key(OBJECT_LOCK_RETAIN_UNTIL) + { + permissions.push(Permission { + action: Action::PutObjectRetention, + resource: resource.clone(), + }); + } + if headers.contains_key(OBJECT_LOCK_LEGAL_HOLD) { + permissions.push(Permission { + action: Action::PutObjectLegalHold, + resource, + }); + } + } if let Some(source) = request .headers() .get("x-amz-copy-source") @@ -509,12 +541,20 @@ pub(crate) fn request_permissions(request: &Request) -> Result, { let source = String::from_utf8(percent_decode_str(source).collect()) .map_err(|_| S3ErrorKind::InvalidRequest)?; - let source = source + let (source, source_query) = source .trim_start_matches('/') .split_once('?') - .map_or(source.trim_start_matches('/'), |(path, _)| path); + .unwrap_or((source.trim_start_matches('/'), "")); + // Naming a version reads that version, which may be one the current + // object has since replaced or deleted. A GET with ?versionId needs + // GetObjectVersion, and a copy is a read of the same kind. + let action = if query_map(Some(source_query))?.contains_key("versionId") { + Action::GetObjectVersion + } else { + Action::GetObject + }; permissions.push(Permission { - action: Action::GetObject, + action, resource: format!("bucket:{source}"), }); } @@ -527,10 +567,14 @@ mod tests { use tower::ServiceExt; use crate::test_support::*; + use axum::Router; use axum::body::Body; use axum::http::Request as HttpRequest; + use axum::response::Response; use chrono::{Duration, Utc}; - use record_store_auth::{Action, PolicyEffect, PolicyStatement}; + use record_store_auth::{ + Action, CredentialManager, IssuedServiceAccount, PolicyEffect, PolicyStatement, + }; use record_store_core::OrganizationId; #[tokio::test] @@ -1043,6 +1087,491 @@ mod tests { .expect("bypass with permission"); assert_eq!(permitted.status(), StatusCode::NO_CONTENT); } + + /// Issues a service account allowed exactly `actions` on every bucket. + async fn account_allowed( + credentials: &CredentialManager, + name: &str, + actions: Vec, + ) -> (IssuedServiceAccount, String) { + let issued = credentials + .create_service_account(name, OrganizationId::new()) + .await + .expect("issue service account"); + grant(credentials, &issued, name, actions).await; + let secret = std::str::from_utf8(issued.secret.expose()) + .expect("secret text") + .to_owned(); + (issued, secret) + } + + /// Attaches one more policy, allowing `actions` on every bucket. + async fn grant( + credentials: &CredentialManager, + issued: &IssuedServiceAccount, + name: &str, + actions: Vec, + ) { + let policy = credentials + .create_policy( + name, + "test-only grant", + vec![PolicyStatement { + effect: PolicyEffect::Allow, + actions, + resources: vec!["bucket:*".into()], + }], + ) + .await + .expect("create policy"); + credentials + .attach_policy(issued.info.account.id, policy.id) + .await + .expect("attach policy"); + } + + /// Sends a request signed by a service account rather than root, which + /// is never subject to policy. + async fn send_as( + application: &Router, + (issued, secret): &(IssuedServiceAccount, String), + method: Method, + uri: &str, + payload: &[u8], + headers: &[(&str, &str)], + ) -> Response { + application + .clone() + .oneshot(signed_request( + method, + uri, + payload, + headers, + &issued.info.credential.key_id, + secret, + Utc::now(), + )) + .await + .expect("router responds") + } + + async fn assert_refused_as_unsigned(response: Response) { + assert_eq!(response.status(), StatusCode::FORBIDDEN); + let body = body_text(response).await; + assert_eq!(xml_value(&body, "Code"), Some("AccessDenied"), "{body}"); + assert_eq!( + xml_value(&body, "Message"), + Some("There were headers present in the request which were not signed"), + "{body}" + ); + } + + fn retain_until(days: i64) -> String { + (Utc::now() + Duration::days(days)).to_rfc3339_opts(chrono::SecondsFormat::Millis, true) + } + + /// A presigned URL grants what its signer signed and nothing more. A + /// holder who could add an Object Lock header would write a COMPLIANCE + /// retention nobody can remove, under the signer's name. + #[tokio::test] + async fn a_presigned_put_refuses_an_unsigned_lock_header_before_storing_anything() { + let (_directory, application, _credentials) = test_router().await; + make_locked_bucket(&application, "records").await; + + let mut put = presigned_request( + Method::PUT, + "/records/statement.pdf", + TEST_ACCESS_KEY, + TEST_SECRET_KEY, + Utc::now(), + 60, + ); + put.headers_mut().insert( + "x-amz-object-lock-mode", + HeaderValue::from_static("COMPLIANCE"), + ); + put.headers_mut().insert( + "x-amz-object-lock-retain-until-date", + HeaderValue::from_static("2100-01-01T00:00:00.000Z"), + ); + *put.body_mut() = Body::from("forged"); + let response = application + .clone() + .oneshot(put) + .await + .expect("presigned put"); + assert_refused_as_unsigned(response).await; + + let stored = send( + &application, + Method::GET, + "/records/statement.pdf", + b"", + &[], + ) + .await; + assert_eq!( + stored.status(), + StatusCode::NOT_FOUND, + "nothing reached storage" + ); + + // Refused before the credential is looked up: an unknown key is not + // reported as unknown, because the store was never asked. + let mut unknown = presigned_request( + Method::PUT, + "/records/statement.pdf", + "unknown-access", + TEST_SECRET_KEY, + Utc::now(), + 60, + ); + unknown.headers_mut().insert( + "x-amz-object-lock-legal-hold", + HeaderValue::from_static("ON"), + ); + let response = application + .clone() + .oneshot(unknown) + .await + .expect("presigned put"); + assert_refused_as_unsigned(response).await; + } + + /// A copy source turns an upload into a read of anything the signer can + /// read, so a presigned PUT must not accept one its signer did not sign. + #[tokio::test] + async fn a_presigned_put_refuses_an_unsigned_copy_source() { + let (_directory, application, _credentials) = test_router().await; + make_bucket(&application, "photos").await; + put(&application, "photos", "private.txt", b"private").await; + + let mut upload = presigned_request( + Method::PUT, + "/photos/public.txt", + TEST_ACCESS_KEY, + TEST_SECRET_KEY, + Utc::now(), + 60, + ); + upload.headers_mut().insert( + "x-amz-copy-source", + HeaderValue::from_static("/photos/private.txt"), + ); + let response = application + .clone() + .oneshot(upload) + .await + .expect("presigned put"); + assert_refused_as_unsigned(response).await; + + let copied = send(&application, Method::GET, "/photos/public.txt", b"", &[]).await; + assert_eq!(copied.status(), StatusCode::NOT_FOUND, "nothing was copied"); + } + + /// Header authentication is held to the same rule: a signature covers + /// the headers it names, and any other `x-amz-*` header is refused rather + /// than acted on. + #[tokio::test] + async fn a_header_signed_request_refuses_an_unsigned_amz_header() { + let (_directory, application, _credentials) = test_router().await; + make_bucket(&application, "photos").await; + let metadata = [("x-amz-meta-foo", "bar")]; + + let unsigned = application + .clone() + .oneshot(signed_request_leaving_unsigned( + Method::PUT, + "/photos/a.txt", + b"hello", + &metadata, + &["x-amz-meta-foo"], + TEST_ACCESS_KEY, + TEST_SECRET_KEY, + Utc::now(), + )) + .await + .expect("unsigned metadata"); + assert_refused_as_unsigned(unsigned).await; + let stored = send(&application, Method::GET, "/photos/a.txt", b"", &[]).await; + assert_eq!(stored.status(), StatusCode::NOT_FOUND); + + let signed = send( + &application, + Method::PUT, + "/photos/a.txt", + b"hello", + &metadata, + ) + .await; + assert_eq!(signed.status(), StatusCode::OK); + let head = send(&application, Method::HEAD, "/photos/a.txt", b"", &[]).await; + assert_eq!( + response_header(&head, "x-amz-meta-foo").as_deref(), + Some("bar") + ); + } + + /// SigV4 header authentication requires the payload hash and the request + /// time to be signed. Unsigned, the hash could be swapped for + /// UNSIGNED-PAYLOAD and the time replayed. + #[tokio::test] + async fn the_payload_hash_and_request_time_must_be_signed() { + let (_directory, application, _credentials) = test_router().await; + for header in ["x-amz-content-sha256", "x-amz-date"] { + let response = application + .clone() + .oneshot(signed_request_leaving_unsigned( + Method::GET, + "/", + b"", + &[], + &[header], + TEST_ACCESS_KEY, + TEST_SECRET_KEY, + Utc::now(), + )) + .await + .expect("response"); + assert_refused_as_unsigned(response).await; + } + } + + /// Everything an ordinary writer needs, with no Object Lock permission. + fn writer_actions() -> Vec { + vec![Action::ListBucket, Action::GetObject, Action::PutObject] + } + + /// Writing an object with a retention is the same decision as setting one + /// through ?retention afterwards, and needs the same permission. A PUT and + /// a multipart initiation, where a multipart upload's lock is fixed, are + /// held to it alike. + #[tokio::test] + async fn a_retention_written_with_the_object_requires_the_retention_permission() { + let (_directory, application, credentials) = test_router().await; + make_locked_bucket(&application, "records").await; + let writer = account_allowed(&credentials, "lock-writer", writer_actions()).await; + let until = retain_until(30); + let lock = [ + ("x-amz-object-lock-mode", "COMPLIANCE"), + ("x-amz-object-lock-retain-until-date", until.as_str()), + ]; + + let refused = send_as( + &application, + &writer, + Method::PUT, + "/records/a.txt", + b"hello", + &lock, + ) + .await; + assert_eq!(refused.status(), StatusCode::FORBIDDEN); + let stored = send(&application, Method::GET, "/records/a.txt", b"", &[]).await; + assert_eq!( + stored.status(), + StatusCode::NOT_FOUND, + "nothing reached storage" + ); + let initiation = send_as( + &application, + &writer, + Method::POST, + "/records/big.bin?uploads", + b"", + &lock, + ) + .await; + assert_eq!(initiation.status(), StatusCode::FORBIDDEN); + + grant( + &credentials, + &writer.0, + "lock-writer-retention", + vec![Action::PutObjectRetention], + ) + .await; + let permitted = send_as( + &application, + &writer, + Method::PUT, + "/records/a.txt", + b"hello", + &lock, + ) + .await; + assert_eq!(permitted.status(), StatusCode::OK); + let head = send(&application, Method::HEAD, "/records/a.txt", b"", &[]).await; + assert_eq!( + response_header(&head, "x-amz-object-lock-mode").as_deref(), + Some("COMPLIANCE") + ); + let initiation = send_as( + &application, + &writer, + Method::POST, + "/records/big.bin?uploads", + b"", + &lock, + ) + .await; + assert_eq!(initiation.status(), StatusCode::OK); + } + + /// The legal-hold header needs the legal-hold permission, whatever its + /// value. Even `OFF` is an explicit lock choice that takes the place of + /// the bucket default, so it is not a free way to decline one. + #[tokio::test] + async fn a_legal_hold_written_with_the_object_requires_the_legal_hold_permission() { + let (_directory, application, credentials) = test_router().await; + make_locked_bucket(&application, "records").await; + let writer = account_allowed(&credentials, "hold-writer", writer_actions()).await; + + for value in ["ON", "OFF"] { + let refused = send_as( + &application, + &writer, + Method::PUT, + "/records/held.txt", + b"hello", + &[("x-amz-object-lock-legal-hold", value)], + ) + .await; + assert_eq!( + refused.status(), + StatusCode::FORBIDDEN, + "legal hold {value}" + ); + } + let stored = send(&application, Method::GET, "/records/held.txt", b"", &[]).await; + assert_eq!(stored.status(), StatusCode::NOT_FOUND); + + grant( + &credentials, + &writer.0, + "hold-writer-legal-hold", + vec![Action::PutObjectLegalHold], + ) + .await; + let permitted = send_as( + &application, + &writer, + Method::PUT, + "/records/held.txt", + b"hello", + &[("x-amz-object-lock-legal-hold", "ON")], + ) + .await; + assert_eq!(permitted.status(), StatusCode::OK); + let head = send(&application, Method::HEAD, "/records/held.txt", b"", &[]).await; + assert_eq!( + response_header(&head, "x-amz-object-lock-legal-hold").as_deref(), + Some("ON") + ); + } + + /// A bucket default is the bucket owner's decision, applied by the + /// service. A writer who asks for no lock needs only PutObject, and still + /// gets the default. + #[tokio::test] + async fn a_bucket_default_retention_applies_to_a_writer_holding_only_put_object() { + let (_directory, application, credentials) = test_router().await; + make_locked_bucket(&application, "records").await; + let configured = send( + &application, + Method::PUT, + "/records?object-lock", + b"EnabledGOVERNANCE30", + &[], + ) + .await; + assert_eq!(configured.status(), StatusCode::OK); + let writer = account_allowed(&credentials, "default-writer", vec![Action::PutObject]).await; + + let written = send_as( + &application, + &writer, + Method::PUT, + "/records/a.txt", + b"hello", + &[], + ) + .await; + assert_eq!(written.status(), StatusCode::OK); + let head = send(&application, Method::HEAD, "/records/a.txt", b"", &[]).await; + assert_eq!( + response_header(&head, "x-amz-object-lock-mode").as_deref(), + Some("GOVERNANCE"), + "the default still materializes onto the version" + ); + } + + /// Copying a named version reads that version, which the current object + /// may have replaced. That needs GetObjectVersion, as a GET with + /// ?versionId does. + #[tokio::test] + async fn copying_a_named_version_requires_get_object_version() { + let (_directory, application, credentials) = test_router().await; + make_bucket(&application, "photos").await; + let versioning = send( + &application, + Method::PUT, + "/photos?versioning", + b"Enabled", + &[], + ) + .await; + assert_eq!(versioning.status(), StatusCode::OK); + let first = put_returning_version(&application, "photos", "a.txt", b"one", &[]).await; + put(&application, "photos", "a.txt", b"two").await; + let copier = account_allowed(&credentials, "copier", writer_actions()).await; + let versioned_source = format!("/photos/a.txt?versionId={first}"); + + let refused = send_as( + &application, + &copier, + Method::PUT, + "/photos/copy.txt", + b"", + &[("x-amz-copy-source", &versioned_source)], + ) + .await; + assert_eq!(refused.status(), StatusCode::FORBIDDEN); + let current = send_as( + &application, + &copier, + Method::PUT, + "/photos/copy.txt", + b"", + &[("x-amz-copy-source", "/photos/a.txt")], + ) + .await; + assert_eq!( + current.status(), + StatusCode::OK, + "the current version needs only GetObject" + ); + + grant( + &credentials, + &copier.0, + "copier-versions", + vec![Action::GetObjectVersion], + ) + .await; + let permitted = send_as( + &application, + &copier, + Method::PUT, + "/photos/copy.txt", + b"", + &[("x-amz-copy-source", &versioned_source)], + ) + .await; + assert_eq!(permitted.status(), StatusCode::OK); + let copied = send(&application, Method::GET, "/photos/copy.txt", b"", &[]).await; + assert_eq!(body_text(copied).await, "one"); + } } #[cfg(test)] diff --git a/crates/record-store-s3/src/error.rs b/crates/record-store-s3/src/error.rs index 40c21b7..2c1c924 100644 --- a/crates/record-store-s3/src/error.rs +++ b/crates/record-store-s3/src/error.rs @@ -130,6 +130,12 @@ pub(crate) enum S3ErrorKind { InvalidAccessKeyId, SignatureDoesNotMatch, AuthorizationHeaderMalformed, + /// An `x-amz-*` header is present that the signature does not cover. + /// + /// It answers `AccessDenied` with the message AWS uses for the same + /// refusal, so a client sees what it would see from S3, and an operator can + /// tell it apart from a policy denial. + UnsignedAmzHeader, RequestTimeTooSkewed, NoSuchBucket, NoSuchCorsConfiguration, @@ -171,7 +177,7 @@ pub(crate) enum S3ErrorKind { impl S3ErrorKind { const fn code(self) -> &'static str { match self { - Self::AccessDenied => "AccessDenied", + Self::AccessDenied | Self::UnsignedAmzHeader => "AccessDenied", Self::InvalidAccessKeyId => "InvalidAccessKeyId", Self::SignatureDoesNotMatch => "SignatureDoesNotMatch", Self::AuthorizationHeaderMalformed => "AuthorizationHeaderMalformed", @@ -213,6 +219,9 @@ impl S3ErrorKind { Self::InvalidAccessKeyId => "The AWS access key ID does not exist", Self::SignatureDoesNotMatch => "The request signature does not match", Self::AuthorizationHeaderMalformed => "The authorization header is malformed", + Self::UnsignedAmzHeader => { + "There were headers present in the request which were not signed" + } Self::RequestTimeTooSkewed => { "The difference between request time and server time is too large" } @@ -276,6 +285,7 @@ impl S3ErrorKind { Self::AccessDenied | Self::InvalidAccessKeyId | Self::SignatureDoesNotMatch + | Self::UnsignedAmzHeader | Self::RequestTimeTooSkewed | Self::ObjectUnderLegalHold | Self::ObjectUnderGovernanceRetention diff --git a/crates/record-store-s3/src/sigv4.rs b/crates/record-store-s3/src/sigv4.rs index 1823e9a..2de8f45 100644 --- a/crates/record-store-s3/src/sigv4.rs +++ b/crates/record-store-s3/src/sigv4.rs @@ -290,6 +290,37 @@ impl ParsedAuthorization { } } +/// Refuses a request carrying an `x-amz-*` header its signature does not cover. +/// +/// Only signed headers reach the canonical request, so an unsigned one is +/// whatever whoever holds the request chose to add. In this family that is +/// never harmless: an Object Lock mode, a copy source, custom metadata, or a +/// governance bypass each changes what the request does. A presigned URL is +/// the sharp case, because it is handed to someone who does not hold the +/// credential. AWS refuses these requests too, so no conforming client sends one. +/// +/// This also makes `x-amz-date` and `x-amz-content-sha256` signed wherever +/// they appear, which header authentication needs both of. +/// +/// `content-md5` is deliberately not held to this on presigned requests. It +/// can only make the server refuse a body the holder already chose, so leaving +/// it unsigned grants nothing, and AWS does not require it to be signed either. +pub(crate) fn reject_unsigned_amz_headers( + headers: &HeaderMap, + signed_headers: &[String], +) -> Result<(), S3ErrorKind> { + // Header names are already lowercase in a HeaderMap, and the parsers + // refuse any signed-header name with an uppercase letter, so the two + // spellings compare directly. + if headers.keys().any(|name| { + name.as_str().starts_with("x-amz-") + && !signed_headers.iter().any(|signed| signed == name.as_str()) + }) { + return Err(S3ErrorKind::UnsignedAmzHeader); + } + Ok(()) +} + pub(crate) fn parse_request_time(headers: &HeaderMap) -> Result, S3ErrorKind> { let value = headers .get("x-amz-date") diff --git a/crates/record-store-s3/src/test_support.rs b/crates/record-store-s3/src/test_support.rs index 9c0c211..e7e3bba 100644 --- a/crates/record-store-s3/src/test_support.rs +++ b/crates/record-store-s3/src/test_support.rs @@ -115,6 +115,39 @@ pub(crate) fn signed_request( access_key: &str, secret_key: &str, time: DateTime, +) -> HttpRequest { + signed_request_leaving_unsigned( + method, + uri, + payload, + extra_headers, + &[], + access_key, + secret_key, + time, + ) +} + +/// Signs a request with every header it carries except those named in +/// `unsigned`, which are sent but left out of `SignedHeaders`. +/// +/// The signature is genuine over what remains, so a refusal can only come from +/// the server noticing what the signature leaves out, never from a signature +/// that simply fails to match. +#[expect( + clippy::too_many_arguments, + reason = "the signing inputs are independent; bundling them would only \ + hide which one a test is varying" +)] +pub(crate) fn signed_request_leaving_unsigned( + method: Method, + uri: &str, + payload: &[u8], + extra_headers: &[(&str, &str)], + unsigned: &[&str], + access_key: &str, + secret_key: &str, + time: DateTime, ) -> HttpRequest { let uri: Uri = uri.parse().expect("request URI"); let payload_hash = hex::encode(Sha256::digest(payload)); @@ -138,6 +171,7 @@ pub(crate) fn signed_request( let mut signed_headers = headers .keys() .map(|name| name.as_str().to_owned()) + .filter(|name| !unsigned.contains(&name.as_str())) .collect::>(); signed_headers.sort(); let payload_hash = headers["x-amz-content-sha256"] diff --git a/docs/administration/object-lock.md b/docs/administration/object-lock.md index 6d782ba..323cdf3 100644 --- a/docs/administration/object-lock.md +++ b/docs/administration/object-lock.md @@ -70,6 +70,14 @@ aws --endpoint-url http://localhost:7600 s3api put-object-retention \ --retention '{"Mode":"COMPLIANCE","RetainUntilDate":"2033-01-01T00:00:00Z"}' ``` +Setting a lock at write time needs the same permission as setting it afterwards, on top +of `s3:PutObject`: `s3:PutObjectRetention` for `x-amz-object-lock-mode` and +`x-amz-object-lock-retain-until-date`, and `s3:PutObjectLegalHold` for +`x-amz-object-lock-legal-hold`. That applies to `PutObject`, `CopyObject`, and +`CreateMultipartUpload`, which is where a multipart upload's lock is fixed. Without the +permission the request is refused and nothing is written. A writer that names no lock +needs only `s3:PutObject`, and the bucket default still applies. + A mode without a date, or a date without a mode, is refused. Half a retention describes no retention period at all, and guessing the other half would invent a promise nobody made. @@ -216,9 +224,9 @@ exercising a permission, so expiry gives way to retention and not the other way | Action | Grants | | --- | --- | | `s3:GetObjectRetention` | Read a version's retention | -| `s3:PutObjectRetention` | Place, extend, or (with a bypass) shorten a retention | +| `s3:PutObjectRetention` | Place, extend, or (with a bypass) shorten a retention, including one written with the object | | `s3:GetObjectLegalHold` | Read a version's legal hold | -| `s3:PutObjectLegalHold` | Place or remove a legal hold | +| `s3:PutObjectLegalHold` | Place or remove a legal hold, including naming one when writing the object | | `s3:BypassGovernanceRetention` | Override a governance retention | | `s3:ManageBucket` | Read and set the bucket's default retention | diff --git a/docs/administration/policies.md b/docs/administration/policies.md index 91ec0d7..11734a7 100644 --- a/docs/administration/policies.md +++ b/docs/administration/policies.md @@ -54,7 +54,24 @@ Record Store derives the action from the request itself: A copy request carries `x-amz-copy-source`. It is checked twice: `s3:PutObject` on the destination and `s3:GetObject` on the source. An account that can write to the -destination but not read the source cannot copy. +destination but not read the source cannot copy. A copy that names a source version +(`x-amz-copy-source: //?versionId=`) needs `s3:GetObjectVersion` +on the source instead, as a `GET` with `?versionId` does. + +Some request headers add a permission on the same object, on top of the action above. +A request that lacks one is refused before it runs: + +| Header | Also requires | +| --- | --- | +| `x-amz-object-lock-mode` or `x-amz-object-lock-retain-until-date` | `s3:PutObjectRetention` | +| `x-amz-object-lock-legal-hold`, with either value | `s3:PutObjectLegalHold` | +| `x-amz-bypass-governance-retention: true` | `s3:BypassGovernanceRetention` | + +The lock headers are held to this on `PutObject`, `CopyObject` and +`CreateMultipartUpload` alike. A lock written with an object is as permanent as one set +afterwards through `?retention` or `?legal-hold`, so it needs the same permission. A +bucket's default retention needs none of them: it is applied when a request names no +lock of its own. See [Object Lock](object-lock.md). ## Resources diff --git a/docs/reference/errors.md b/docs/reference/errors.md index 8d919cb..e973a99 100644 --- a/docs/reference/errors.md +++ b/docs/reference/errors.md @@ -23,6 +23,7 @@ The request ID is also in `x-amz-request-id`. | --- | --- | --- | | `AccessDenied` | Authenticated, but no policy allows it | Check the account's [policies](../administration/policies.md) | | `AccessDenied` | The version is under retention or a legal hold | Read the message: it names which, and whether a bypass exists. See [Object Lock](../administration/object-lock.md) | +| `AccessDenied` | `There were headers present in the request which were not signed` | Sign every `x-amz-*` header the request sends, including on a presigned URL. See [S3 Compatibility](s3-compatibility.md#signed-headers) | | `InvalidAccessKeyId` | The access key is not known | Check the key; check the account is not deleted | | `SignatureDoesNotMatch` | The signature does not verify | Usually a proxy rewriting `Host`, or a wrong secret | | `RequestTimeTooSkewed` | Client clock is too far off | Sync time | diff --git a/docs/reference/s3-compatibility.md b/docs/reference/s3-compatibility.md index cb506c8..c4e20da 100644 --- a/docs/reference/s3-compatibility.md +++ b/docs/reference/s3-compatibility.md @@ -37,6 +37,20 @@ Requests for an unsupported operation return `501 NotImplemented`. ## Client requirements +### Signed headers + +Every `x-amz-*` header a request carries must be covered by its signature, as in AWS. +A request with an unsigned one is refused with `403 AccessDenied`, `There were headers +present in the request which were not signed`, before its credential is looked up. For +header authentication this includes `x-amz-date` and `x-amz-content-sha256`. For a +presigned URL, sign it with every `x-amz-*` header the uploader will send, such as +`x-amz-meta-*` or an Object Lock header. The URL's own `X-Amz-*` query parameters are +not headers and are unaffected. `Content-MD5` may be left unsigned: it can only make the +server refuse a body, never change what is stored. + +The AWS SDKs sign every `x-amz-*` header they send, so this affects only hand-built +requests and presigned URLs whose holder adds headers. + ### Path-style addressing ```text