Repository navigation
fix(auth): check the scope and session token, canonicalize repeated headers, and time out stalled requests - #355
LeeroyHannigan wants to merge 13 commits into
Conversation
| .http_builder() | ||
| .http1() | ||
| .timer(hyper_util::rt::TokioTimer::new()) | ||
| .header_read_timeout(request_timeout); |
There was a problem hiding this comment.
These connection timeouts only reach the TLS path: the plain-HTTP else arm below calls axum::serve directly, which installs no timer, so the header_read_timeout and HTTP/2 keep-alive settings never apply there. A stalled HTTP/1 header phase — the exact hole this PR closes — stays open on that branch; only the body-timeout layer covers it. If that branch is unreachable in production because TLS is mandatory, a comment saying so (and why dev-mode accepts the gap) closes the question; otherwise the same config needs to reach it.
There was a problem hiding this comment.
Fixed in 5e02110. Both arms now run on the same axum_server builder: serve_app in crates/server/src/listener.rs builds the TLS or the plaintext server, passes it through with_request_timeouts (HTTP/1 header read timeout, HTTP/2 keep-alive interval and timeout), and shares one shutdown handle. The plaintext arm sets TCP_NODELAY through a small acceptor instead of the tap_io listener, so axum::serve is gone from the server.
The plaintext arm is reachable (dev-mode builds serve plain HTTP, and start_server is public), so the same config now reaches it.
Neither of those timers covered a connection that sends no byte at all. hyper_util's auto builder reads the stream to tell HTTP/2 from HTTP/1 apart before either protocol's timer starts, and that read had no bound, so a client that connected (or completed the TLS handshake) and then sent nothing, or sent only part of the 24-byte HTTP/2 preface, held the connection. 631a361 adds a first-read deadline of the same request_timeout on both arms, and bounds the redirect acceptor's own first-byte peek with it, so that shape is closed at the timeout too.
Test: listener::tests::plaintext_listener_closes_a_stalled_header_phase serves a router on a loopback plaintext listener with a 300 ms timeout, sends a request line and one header with no blank line, and requires the connection to close within 3 s. On the commit before the fix it fails with "server held a stalled header phase open for 3s on the plaintext listener". listener::tests also covers the zero-byte and 16-preface-byte shapes on both listener modes, and a request that arrives late but inside the deadline, which is still served; without the first-read deadline those four tests report the connection held open for 3 s.
| // refused outright, as the service's front end does (400, connection | ||
| // closed). Either duplicate is a header-confusion shape a proxy in front | ||
| // of this server could resolve differently from the signature check. | ||
| if headers.get_all("host").iter().count() > 1 |
There was a problem hiding this comment.
This refusal only covers the DynamoDB route: it lives in handle_request, so /console and /management never see it. The stated rationale — a proxy in front of the server resolving header confusion differently — applies at least as much to the session-authed console as to the SigV4 route, where the signature already binds the host. A thin layer applied alongside the other security layers would cover every route with the same code.
There was a problem hiding this comment.
Fixed in 6591468. The check is now a middleware, repeated_headers::refuse_repeated_headers in crates/server/src/repeated_headers.rs, layered on the whole router after the console and management routers are nested and inside the security header layers. Every route and the fallback answer a repeated host or authorization header with the same 400, Connection: close and "400 Bad Request" body the data plane returned; handle_request no longer carries the check.
I measured the service again on 2026-10-23, first with an unauthenticated request and then with a correctly signed ListTables: two Authorization headers (identical or different values) or two Host headers get the front end's 400 Bad Request with the connection closed and no request id, before authentication. A repeated X-Amz-Date or X-Amz-Target is not refused there; the request reaches signing, the canonical headers carry both values and the answer is InvalidSignatureException. The server does the same on all six cases (build_canonical_headers joins repeated values with commas), so X-Amz-Date stays out of this check.
Tests: repeated_headers::tests run the layer over a router shaped like the server's (data plane, /health, nested /console and /management, an unmatched path). tests/test_auth_error_fidelity.py adds test_duplicate_host_header_is_refused_on_every_route and test_duplicate_authorization_header_is_refused_on_every_route over /console/login, /management/accounts and /health, plus test_duplicate_authorization_header_is_refused_on_the_data_plane. On the commit before the fix the console, management and health cases answer 200 or 401. b8654a8 adds test_signed_header_repeated_after_signing_is_a_signature_mismatch for the X-Amz-Date and X-Amz-Target cases; it runs against extenddb and the service.
…eaders, and time out stalled requests Each behavior below was measured against the service on 2026-09-16 unless marked as hardening. Credential scope. verify_signature() checked the scope's service and date and derived the signing key from whatever region the client named, so a request signed for one deployment verified against another that shared its credentials. check_scope() now compares the region with the configured [server] region and the service with dynamodb, and reports every failing part in one message in the service's order: "Credential should be scoped to a valid region. " then "Credential should be scoped to correct service: 'dynamodb'. " (the region is not named; the trailing spaces are the service's). A malformed scope reports the service's IncompleteSignatureException wire name and its five-element message. Session tokens. A token presented with a long-term key was ignored. The service validates any presented token and a long-term key has none, so the request now fails after signature verification with UnrecognizedClientException. The message for a presented token that does not resolve has no trailing period; the messages for an unknown key and for a missing token keep theirs, as measured. Repeated headers. build_canonical_headers() read only the first value of a header, so a client that sent a signed header twice could never authenticate. Values are now joined with commas in the order sent, which is how the service and botocore canonicalize them; a value that is not visible ASCII contributes an empty value rather than vanishing. A request with more than one host or authorization header is refused with a plain 400 before anything else, as the service's front end does. UNSIGNED-PAYLOAD. The special rejection message is removed. The service answers a client that signs that literal with the plain signature mismatch; since the payload line is always the hash of the received body, the mismatch now falls out of verification with the measured text. Request timeouts (hardening). Nothing bounded how long a connection could take to deliver headers or a body; a Content-Length the client never completed held the connection until the client gave up. [server] request_timeout_secs (default 30) now sets the HTTP/1 header read timeout on the TLS acceptor, the HTTP/2 keep-alive PING interval and timeout, and a body inactivity timeout on every route. A stalled body is answered with 408 and Connection: close; a stalled HTTP/1 header phase is closed at the timeout and a stalled HTTP/2 connection within twice it. The value also bounds the idle wait on a keep-alive connection, which is hyper's own 30 second default that never applied here for lack of a timer. Zero is refused at startup. The security manual's verification steps now list every check in the order the code runs them, and its credential storage section describes the in-process cache that is on by default in place of the sentence that denied it. Signed-off-by: Lee Hannigan <lhnng@amazon.com>
start_server keeps building the router. Serving it (the TLS and plaintext arms, the request timeouts, the HTTPS redirect acceptor, TCP_NODELAY on accepted sockets, shutdown and PID file cleanup) moves unchanged into listener::serve_app, together with its tests. No behaviour changes.
The header read timeout and the HTTP/2 keep-alive settings were installed on the axum_server builder of the TLS arm only. The plaintext arm (dev-mode builds, and any caller of start_server without TLS) went through axum::serve, which installs no timer, so a connection that sent part of its request headers and stalled stayed open there; only the body timeout layer covered it. Both arms now run on axum_server with one helper, with_request_timeouts, that sets the HTTP/1 header read timeout and the HTTP/2 keep-alive interval and timeout. The plaintext arm uses a NodelayAcceptor that sets TCP_NODELAY on each accepted socket, replacing the tap_io listener, and shares the shutdown handle with the TLS arm, so a shutdown signal drains plaintext connections with the same 5 second bound. Test: listener::tests::plaintext_listener_closes_a_stalled_header_phase serves a router on a loopback plaintext listener with a 300 ms timeout, sends a request line and one header with no terminating blank line, and requires the server to close the connection within 3 s. On the commit before this one the test fails with "server held a stalled header phase open for 3s on the plaintext listener".
…route The refusal lived in handle_request, so only the DynamoDB route saw it; /console, /management, /health and /metrics read the first or the joined value of a repeated header as before. The header-confusion case applies to the session-authed console and the Basic-authed management API as much as to the signed route. The check moves to a middleware, repeated_headers::refuse_repeated_headers, layered on the whole router after nesting and inside the security header layers, so every route and the fallback answer with the same 400, Connection: close and plain "400 Bad Request" body the data plane already returned. Measured against the service on 2026-10-23: two Authorization headers or two Host headers get the front end's 400 Bad Request with the connection closed before authentication; a repeated X-Amz-Date is not refused by the front end, so it stays out of this check. Tests: repeated_headers::tests cover the layer on a router shaped like the server's (data plane, /health, nested /console and /management, an unmatched path). tests/test_auth_error_fidelity.py adds test_duplicate_host_header_is_refused_on_every_route and test_duplicate_authorization_header_is_refused_on_every_route over /console/login, /management/accounts and /health, and test_duplicate_authorization_header_is_refused_on_the_data_plane. On the commit before this one the console, management and health cases answer 200 or 401 instead of 400.
Measured against the service on 2026-10-23: a ListTables request signed over one X-Amz-Date or X-Amz-Target value and sent with that header twice gets InvalidSignatureException with a request id. The front end does not refuse these headers the way it refuses a repeated host or authorization; the canonical headers join both values and the signature no longer matches. The server already behaves this way; the test pins it on both targets.
…y lookup The service validates the request timestamp, then the credential scope, then looks the access key up: measured 2026-10-23 with signed ListTables requests, an unknown key with a stale date answers "Signature expired", an unknown key scoped to another region answers "Credential should be scoped to a valid region. ", and a stale date with a wrong region answers "Signature expired". BuiltinAuthProvider::authenticate looked the key up first and answered UnrecognizedClientException for the first two. Run validate_timestamp and check_scope before lookup_credential, in that order. Neither reads the stored credential, so the constant-time handling of the signature and token comparisons after the lookup is unchanged. The comment that said the service returns the invalid-key error for an expired timestamp is removed; the measurement contradicts it. Add unit tests for the three orderings and move the authenticate tests from lib.rs into crates/auth/src/tests.rs, keeping lib.rs under 600 lines. Add the wire case for an unknown key scoped to another region to tests/test_auth_error_fidelity.py; it runs against extenddb and the service.
…listeners axum_server serves every accepted stream through hyper_util's auto builder, which reads the stream to tell HTTP/2 from HTTP/1 apart before either protocol's timer starts, and that read has no bound of its own. A client that connected, or that completed the TLS handshake, and then sent no byte, or sent only a prefix of the 24-byte HTTP/2 preface, held the connection and its task for as long as it kept the socket open. hyper's header read timeout starts after the sniff and the HTTP/2 keep-alive PING starts after the h2 handshake, so neither bound covered that shape. serve_app now wraps both listeners' acceptors in FirstByteAcceptor, which hands every accepted stream on inside FirstByteDeadline. The adapter arms a request_timeout sleep when the stream is handed over and drops it where the auto builder stops sniffing: at a byte that differs from the preface, or at the whole preface. While the deadline is armed, a read that would block also polls it, so the connection task wakes when it fires and the read fails with TimedOut; hyper_util then drops the connection. The redirect acceptor on the TLS port bounds its own first-byte peek with the same value, which closes a connection that sends no byte before the handshake as well. TCP_NODELAY and the plain HTTP redirect behave as before. Tests in listener::tests cover both listener modes: zero bytes after accept and after the TLS handshake, and 16 of the 24 preface bytes, each closed within the timeout. A request that arrives late but before the deadline is still served, and the sniff accounting is pinned directly. The TLS tests build a certificate, so rcgen, already a workspace dependency, becomes a dev-dependency of the crate. The admin guide row for request_timeout_secs states the new bound.
request_timeout_secs was checked for 0 only. The per-connection timers add the value to the current instant, and that addition panics on overflow, so a value near u64::MAX started the server and then panicked the task of every connection it accepted while the process itself stayed up. The startup check now refuses anything above 86400, a day, which is longer than any pause a client takes before sending its request and leaves the addition far from the overflow point. The check moved into check_request_timeout_secs so both bounds carry unit tests, and the admin guide row and the generated config template state the range.
The presented session token was read through HeaderValue::to_str, which fails for a value carrying a byte above 0x7f, so such a header looked absent. A long-term key passes the token check only when no token is presented, so a request with a non-ASCII x-amz-security-token header was served where any visible-ASCII value is refused. Presence now comes from the header map and the value from the header's text, so the header is refused whatever its bytes. A session credential is unaffected: a value that cannot be read as text is compared as empty and does not match the stored token. Whether the service's front end accepts a header value with a byte above 0x7f at all is not measured here, so this keeps the server's own rule, that only an absent header passes for a long-term key.
test_duplicate_host_header_is_refused sent no Authorization header and asserted the status only, so it passed against a server with no repeated-header check at all: the handler answers MissingAuthenticationToken with a 400 as well. It now goes through _raw_request and asserts the connection is closed, and against extenddb the "400 Bad Request" body that tells the listener's refusal apart from the handler's error. The service answers this shape with 400, Connection: close and an HTML error page, so the body assertion stays scoped to extenddb, as it is in the Authorization case next to it.
4461c26 to
49fb3e2
Compare
crates/server/src/lib.rs: main added the registration module, the admission layer that answers ServiceUnavailable while the serving registration is lost, and a stop that the admission can ask for in place of a shutdown signal, wired into both listener arms. This branch moved the listener code into listener.rs and added the repeated header refusal and the request body timeout. The merged file keeps the admission layer directly in front of the routes, with the repeated header refusal, the security headers and the body timeout layered over it, so a repeated host or authorization header is refused before the admission check and the handlers run. The listener code stays in listener.rs, and start_server passes the admission to listener::serve_app. crates/server/src/listener.rs: serve_app takes the admission, and its shutdown task waits on signal_or_stop, moved here from lib.rs, so a stop after a lost registration drains both listener modes the way a signal does. The listener tests pass a default admission to serve_app. Main's test that a stop ends the server runs against serve_app, because the plaintext arm no longer runs axum::serve with its own graceful shutdown future. crates/server/src/serve.rs: main added CatalogRefusal, RegistrationLost and an inline tests module for the background workers. This branch added the request_timeout_secs range check and a tests module in serve/tests.rs. The merged file keeps both. The branch's test file is renamed to serve/request_timeout_tests.rs so that its module does not take the name of main's inline tests module. docs/manuals/10-security-model.md: both sides replaced the line that said there is no in-process credential cache. Main's line points to its new Credential and Policy Caching section, which covers what this branch's line said, so the merged file keeps main's line and section together with this branch's SigV4 verification steps.
docs/manuals/05-admin-guide.md: main rewrote the parenthesis in the `region` row to say that `CreateBackup` is refused with a `ValidationException` naming `server.region` when the value falls outside `[a-z0-9-]`, and that backups taken before the upgrade under such a region keep an ARN the backup operations refuse. This branch added the sentence that requests must carry a credential scope for the server's region and the `request_timeout_secs` row. The result keeps main's row text with the scope sentence inserted after the first sentence, and keeps the timeout row below it.
Brings in #541, which applies IAM's rules for policy variables in condition values. No file conflicted. The auth and server crates pass fmt, clippy and the auth library tests on the merged tree.
What
Four request checks in the SigV4 path brought in line with the service, and request timeouts on every connection. Each service behavior cited below was measured against
dynamodb.us-east-1.amazonaws.comon 2026-09-16 with a hand-rolled signer, and each is reproduced by a test in this PR.crates/auth/src/sigv4/verify.rs::check_scope, called from the provider): the region must equal[server] regionand the service must bedynamodb; every failing part is reported in oneInvalidSignatureExceptionmessage in the service's order,"Credential should be scoped to a valid region. "then"Credential should be scoped to correct service: 'dynamodb'. "(the region is not named; the trailing spaces are the service's). Before, the signing key was derived from whatever region the client wrote, so a request signed for one deployment verified against another that shared its credentials. A malformed scope now reports the service'sIncompleteSignatureExceptionwire name (wasIncompleteSignature) and its five-element message.lib.rs): rejected after signature verification withUnrecognizedClientException: "The security token included in the request is invalid". Before, the token was ignored. The message for a presented token that does not resolve has no trailing period; the unknown-key and missing-token messages keep theirs, as measured.crates/auth/src/sigv4/canonical.rs): values of a header sent more than once are joined with commas in the order sent. Before, only the first value was read, so a client that sent a signed header twice could never authenticate; botocore signs such headers comma-joined and the service accepts them. A value that is not visible ASCII contributes an empty value rather than vanishing, so a request carrying such a header cannot canonicalize the same as one without it. A request with more than onehostorauthorizationheader is refused with a plain 400 andConnection: closebefore anything else, as the service's front end does (crates/server/src/handler.rs).UNSIGNED-PAYLOAD(crates/auth/src/sigv4/verify.rs): the special rejection is removed. The service answers a client that signs that literal with the plain signature mismatch, and since fix(auth): compute the SigV4 payload hash from the received body #351 the payload line is always the hash of the received body, so the mismatch falls out of verification with the measured text.crates/server,crates/config): new[server] request_timeout_secs, default 30, sets the HTTP/1 header read timeout on the TLS acceptor, the HTTP/2 keep-alive PING interval and timeout (both servers negotiateh2by ALPN), and a body inactivity timeout on every route (tower_http::timeout::RequestBodyTimeoutLayer). A stalled body is answered with HTTP 408 andConnection: close; a stalled HTTP/1 header phase is closed at the timeout and a stalled HTTP/2 connection within twice it (measured: 10.0 s at a 5 s setting). The header timeout also bounds how long an idle keep-alive connection waits for its next request, which is hyper's own 30 second default (never active before because no timer was installed).request_timeout_secs = 0is refused at startup. Hardening, not fidelity: nothing bounded either before.docs/manuals/10-security-model.mdlists every verification check in the order the code runs them, and its credential storage section now describes the in-process cache ([auth.cache], on by default) in place of a sentence that denied it.docs/manuals/05-admin-guide.mddocumentsregionas the accepted scope and the new timeout key; theinittemplate mentions both.Two candidate changes were measured and deliberately not made: the service parses the request body before verifying the signature (a malformed body with a wrong secret or no Authorization header returns
SerializationException), which is the handler's existing order; and the service accepts unsignedx-amz-*headers, includingx-amz-target, so no signed-header requirement beyondhostis added.Why
Findings from the review of #351. Two are correctness against the service (region and token), one is a compatibility defect (a client repeating a signed header could not authenticate), one is a message divergence, and the timeouts close a denial-of-service hole in which a
Content-Lengththe client never delivers held a connection open indefinitely (reproduced: still open after 20 s on main; closed at the configured 5 s on this branch).Testing done
Unit (
extenddb-auth, 160 passed): comma-joined repeated header in the canonical request;UNSIGNED-PAYLOADsigned as the payload line fails as a plain mismatch with no special wording; end-to-end provider tests with a correctly signed request for the wrong region, a token with a long-term key, a matching session token, a bogus session token, and a missing session token, each asserting the exact measured message; exact (case- and whitespace-sensitive) region comparison; an empty token header on a long-term key; an unsignable duplicate header value changing the canonical form.Integration (
tests/test_auth_error_fidelity.py::TestRequestChecks): wrong-region scope, scope wrong in both parts, token with a long-term key, repeated signed header sent as two lines,UNSIGNED-PAYLOADliteral, duplicatedhost, and a stalled body. Against a SQLite server built from this branch withrequest_timeout_secs = 5: 14 of 14 in the file pass. Against a server built from main: six of the seven new cases fail (main already answered a duplicatedhostwith a 400, as a signature mismatch). Against the service itself (endpoint unset): region, both-parts scope, repeated header,UNSIGNED-PAYLOAD, and duplicatedhostpass, so those five assertions are measured, not inferred; the token case skips with temporary credentials and the timeout case is server-only by design.Stalls probed by hand at a 5 s setting: HTTP/1 header stall closed at 5.0 s (main holds it past 20 s); HTTP/2 preface-then-stall closed at 10.0 s (main holds it past 15 s); a body delivered one byte every 4 s succeeds (inactivity, not total time); boto3 and the Node SDK reuse pooled connections across the idle close without a caller-visible error.
The only integration failures were the six import/export tests, which need
[import]/[export]paths the ad hoc server lacks (excluded in CI).Changes after review (4473fed to 4461c26)
Five commits on top of b8654a8. The earlier sections still hold, with one correction under "What the request timeout bounds".
Check order in the auth provider (4473fed)
The provider looked the access key up first, then validated the request timestamp, then checked the credential scope. Measured against DynamoDB on 2026-10-23 with signed ListTables requests (15:46:53Z and 16:05:33Z): the service checks the timestamp first, then the credential scope, then looks the key up. An unknown key scoped to another region answers
InvalidSignatureExceptionwith "Credential should be scoped to a valid region. ", and an unknown key carrying a date an hour old answers the timestamp error, so both checks run ahead of the lookup.validate_timestampandcheck_scopenow run beforelookup_credential, in that order. Neither reads the stored credential, so the constant-time path for the signature and session token comparisons is unchanged. The code comment stating that the service answers the invalid-key error when the timestamp is also expired is removed, because the measurement says the opposite.The timestamp error's own type and text are not changed by this PR. The server answers
UnrecognizedClientExceptionwith "Signature expired: the signature is too old or the request date is too far in the future.", where the service answersInvalidSignatureExceptionand names both dates. That difference is on main as well.What the request timeout bounds (631a361)
The description says a stalled HTTP/1 header phase is closed at the timeout. That was true of a connection that had sent at least one byte. It did not cover a connection that sent none.
axum_serverserves every accepted stream throughhyper_util's auto builder, which reads the stream to tell HTTP/2 from HTTP/1 apart before either protocol's timer starts, and that read had no bound. hyper's header read timeout starts after the sniff, and the HTTP/2 keep-alive PING starts after the h2 handshake, so a client that connected (or completed the TLS handshake) and then sent nothing, or sent only part of the 24-byte HTTP/2 preface, held the connection and its task for as long as it kept the socket open. That is the cheapest stall a client can produce, and it was unbounded on main too.Both listeners now hand each accepted stream to the serving code inside a first-read deadline of
request_timeout. The deadline is armed when the stream is handed over and dropped where the auto builder stops sniffing, at a byte that differs from the preface or at the whole preface, so it never bounds a request that is already being read. While it is armed, a read that would block also polls it, so the connection task wakes when it fires and the read fails withTimedOut. The acceptor on the TLS port bounds its own first-byte peek with the same value, which also closes a connection that sends no byte before the TLS handshake.TCP_NODELAYand the plain HTTP redirect on the TLS port behave as before.So one value,
request_timeout_secs, now bounds every phase before a request is answered: the wait for the first byte, the header phase, a stalled body (HTTP 408), the idle wait on a keep-alive connection, and the HTTP/2 keep-alive PING interval and timeout. A stalled HTTP/2 connection closes within twice the value, because the interval and the timeout are both set to it. The admin guide row states this.Ceiling on request_timeout_secs (cb15021)
The startup check refused 0 only. The per-connection timers add the value to the current instant, and that addition panics on overflow, so a value near
u64::MAXstarted the server and then panicked the task of every connection it accepted, while the process stayed up. Startup now refuses anything above 86400 (a day), and the admin guide row and the generated config template state the accepted range.Session token header that is not visible ASCII (fd49abd)
The presented session token was read through
HeaderValue::to_str, which fails for a value carrying a byte above 0x7f. Such a header looked absent, and a long-term key passes the token check when no token is presented, so a request with a non-ASCIIx-amz-security-tokenheader was served where any visible-ASCII value is refused. Presence now comes from the header map and the value from the header's text, so the header is refused whatever its bytes. The service accepts such a header at its front end: measured on 2026-10-23 at 19:33 UTC with a session credential, a token header carrying byte 0x80 answers UnrecognizedClientException "The security token included in the request is invalid", the answer for a presented token that does not resolve. A long-term key with such a header now gets UnrecognizedClientException, as it does for any presented token.Tests
listener::testscovers both listener modes for the new bound: zero bytes after accept and after the TLS handshake, and 16 of the 24 preface bytes, each closed within the timeout. A request that arrives late but inside the deadline is still served, and the sniff accounting is pinned directly. Without the first-read deadline, those tests report the connection held open for the whole bound. The TLS tests build a self-signed certificate, sorcgen, already a workspace dependency, becomes a dev-dependency ofextenddb-server.serve::testscovers both ends of the timeout range and the overflow the ceiling refuses.extenddb-authadds the non-ASCII token header case.tests/test_auth_error_fidelity.py:test_duplicate_host_header_is_refusedasserted the status only and sent no Authorization header, so it passed against a server with no repeated-header check, whose handler answersMissingAuthenticationTokenwith a 400 as well. It now asserts the closed connection, and against extenddb the "400 Bad Request" body that separates the listener's refusal from the handler's error. The service answers this shape with 400, Connection: close and an HTML error page (measured 2026-10-23 12:22Z), so the body assertion stays scoped to extenddb.Not changed here
Two differences from the service remain, both present on main and on b8654a8. They will be filed separately:
Checklist
cargo test --workspace)cargo fmt --check)cargo clippy -- -W clippy::pedantic)Storagetrait, auth model, on-diskformat, or public CLI surface, an RFC has been accepted or is linked
below. Otherwise, an ADR captures the decision (link below).
ADR / RFC: n/a. No new surface; the checks bring existing verification in line with the service, and the timeout is a new optional
[server]key with a default.Breaking changes
Region scope enforcement. A client whose SDK region differs from the server's
[server] region(defaultus-east-1) is now rejected withInvalidSignatureException, where it was silently accepted. This is what the service does for a scope other than the endpoint's, but deployments may have relied on the old behavior without knowing. Remedy: set[server] regionto the region clients sign for, or configure clients with the server's region. Release notes should carry this line.Timeouts. A client that pauses for more than
request_timeout_secs(default 30) while sending headers or a body is disconnected. No SDK pauses that long; the value is configurable.By submitting this pull request, I confirm that my contribution is made under
the terms of the Apache License 2.0 and I agree to the Developer Certificate of
Origin (DCO). See CONTRIBUTING.md for details.