Repository navigation
fix(server): refuse stream ARNs from another account or region - #466
LeeroyHannigan wants to merge 14 commits into
Conversation
robinnsc
left a comment
There was a problem hiding this comment.
Looks good to me. On local SQLite servers with two accounts and about a dozen principals, we found no cross-account or cross-table read on head. The principals covered Allow and Deny on the table ARN, the stream ARN and stream/*, wildcard account and partition, and aws:ResourceTag conditions. The shapes we threw at it: respelled ARNs, forged and foreign-minted GetRecords tokens, ExclusiveStartStreamArn and ExclusiveStartShardId from other streams, sequence numbers from other accounts' streams, and in-scope ARNs paired with denied shards. #450 reproduces on main, where B's admin naming A's account read B's records, and head refuses it. The canonical ARN gets the same answers as main for every principal, so we saw no false denies. test_stream_arn_scope.py is 10/10 here and 8/10 fail on main, and the dev-mode mutant (return moved back above the checks) fails TestDevModeKeepsStreamArnScope. Latency is unchanged.
Merge notes, since it conflicts with main in six files:
- Stream engines: take main's side in all four. #543 already moved these messages into
extenddb_storage::util::arn, andshard_not_in_streamkeeps its period on purpose, so this branch's third hunk per engine would undo that. After the merge the PR changes nothing there, so the description's lines about the trailing period can go. authz_resource.rsandrequest_helpers.rs: inline below. With those, the resolved tree passed the server lib tests (239), the dev-mode file (8/8), and main'stest_streams.pyplustest_stream_shard_ownership.py(19/19).- Main's
engine/src/streams.rs:316comment that the authorization layer "is skipped in developer mode" won't be true for GetRecords after this.
Small notes:
- A cn-* or us-gov deployment now refuses a client-built
arn:aws-cn:/arn:aws-us-gov:StreamArn, which main served. ARNs the server issues are alwaysarn:aws:, so only hand-built ones are affected. Maybe a line in the upgrade notes. - Pre-existing: tag operations on a stream ARN only check that the table exists, so an unknown label (or one with trailing whitespace) stores tag rows under that string. Those rows are never deleted with the table. They don't touch authorization, as far as I can tell.
- The description says seven of nine fail on main. The file has 10 tests, and 8 fail on the base (7 on current main, since #543 fixed the period).
- The comment at
request_helpers.rs:162listsSourceTableArnamong the ARNs checked before IAM, butvalidate_operation_arnsdoesn't check it.
| // as the service does. The engine separately confirms the shard belongs to | ||
| // that stream before reading, so a token that names an allowed stream but a | ||
| // different stream's shard reads nothing. | ||
| if let Some(resource_arn) = getrecords_stream { |
There was a problem hiding this comment.
For the merge: keep this if let Some(resource_arn) = getrecords_stream, and the body needs main's base_params(request_tags, request_tag_keys) from #522. tag_condition_keys can stay after the dev-mode return. The sentence above about the engine separately confirming the shard is the one #543 replaced ("The storage read resolves the shard against that stream as part of the read"), so a take-ours resolution would bring the old wording back.
| arn.trim_matches(|c: char| c.is_ascii_whitespace()), | ||
| ArnKind::Backup, | ||
| ) | ||
| && !arn_is_backup(arn.trim_matches(|c: char| c.is_ascii_whitespace())) |
There was a problem hiding this comment.
For the merge: main (#394) uses extenddb_core::backup_format::trim_backup_arn(arn) here, the same trim canonical_resource and the typed backup resolver use. So this probably wants to become !arn_is_backup(extenddb_core::backup_format::trim_backup_arn(arn)), otherwise the kind check and the authorized resource trim differently. ArnKind has no users after that.
| /// not parse to a stream is refused as Invalid rather than authorized verbatim, | ||
| /// so no respelled or forged stream ARN is served. | ||
| /// must parse to a stream and name this deployment's partition, service and | ||
| /// region and the caller's account: the server issues no other spelling, so a |
There was a problem hiding this comment.
"the server issues no other spelling" doesn't quite hold across the upgrade. Main's GetShardIterator encodes StreamArn as sent and accepts respelled ones. Iterators main issued for aws-cn, US-EAST-1 and DYNAMODB spellings, and every NextShardIterator chained from them, got the non-retryable Invalid ShardIterator once head read them from the same database, rather than ExpiredIteratorException. A change of server.region does the same even to canonical iterators. It's a narrow window and the behaviour seems fine. It might just be worth saying "iterators issued from the canonical stream ARN" here and in the description's upgrade notes.
| return invalid(); | ||
| } | ||
| check_stream_arn_account(arn, account_id)?; | ||
| if f.partition != PARTITION || f.service != SERVICE || f.region != region { |
There was a problem hiding this comment.
The same partition/service/region rule also lives in engine/src/tagging.rs:138, and the tag path depends on that copy. Since a tag op's stream ARN is authorized as sent, a Deny on the canonical stream ARN doesn't match an aws-cn or DYNAMODB spelling, and only the engine check stops it before a row is written. Today only the tests keep the two in step. One shared predicate in extenddb_core::arn would remove the second copy.
| Deny principal (whose Deny names another label). DescribeStream: | ||
| "Requested resource not found: Stream: <arn> not found" with no trailing | ||
| period (#451). GetShardIterator: the service says "Requested resource | ||
| not found: Stream does not exist"; ExtendDB answers the DescribeStream |
There was a problem hiding this comment.
nit: after merging main, GetShardIterator answers Stream does not exist for an unknown label, so this "pre-existing difference" goes away. The assertion at :545 could then pin that message too.
…egion DescribeStream and GetShardIterator served the caller's own stream for a StreamArn that named another account, another partition, another region, an upper-cased region or the service spelled DYNAMODB. Authorization rebuilt every stream ARN with the server's partition, service and region and the caller's account, and the stream engine resolved the stream by table name and label, so the fields a client might get wrong were ignored on both sides. The tag operations answered the same stream ARN with "Invalid TableArn: Invalid ResourceArn provided as input <arn>". Measured on DynamoDB on 2026-10-23 for an admin, a no-permissions user, a user allowed the exact stream ARN and a user with a Deny on it. On StreamArn, every principal gets AccessDeniedException "Access is denied" for another account and ValidationException "Invalid StreamArn" for aws-cn, aws-us-gov, DynamoDB, DYNAMODB, us-west-2 or US-EAST-1, so both checks run before the IAM decision, account first. On a tag operation's ResourceArn, another account is "Access is denied" for every principal; the other spellings are authorized as sent (a no-permissions principal is denied naming the ARN it sent, a principal allowed only the canonical ARN is denied the respelled one) and then refused with "Invalid StreamArn". A GetRecords token the service did not issue is "Invalid ShardIterator". A stream ARN is now checked in the server before the IAM decision: on StreamArn for its account, partition, service and region (check_stream_arn_scope); on a tag operation's ResourceArn for its account (check_stream_arn_account), with the ARN then authorized as sent and the engine refusing another partition, service or region with "Invalid StreamArn" after authorization; inside a GetRecords iterator for all four fields, refusing the token as Invalid. Wire tests in tests/test_stream_arn_scope.py cover the four principals, five operations and seventeen spellings with the measured message text; test_authz_resource_arn.py and test_cross_account_isolation.py are updated to the measured answers. Fixes #450 Fixes #451
The control in test_getrecords_iterator_with_respelled_stream_arn_is_invalid reads the stream with a hand-built token carrying the issued ARN. The service refuses every token it did not issue, so the comment now states that the control passes only because ExtendDB iterators carry no signature, a gap that exists on main.
…account tests/python/test_streams.py::test_describe_stream_invalid_arn sent a StreamArn in account 000000000000 and accepted ResourceNotFoundException or ValidationException. With this change the server refuses another account before the IAM decision with AccessDeniedException "Access is denied", the service's answer measured on 2026-10-23, so the test now asserts that code and message.
…essages Three test helpers printed the error returned by the stream ARN checks in their panic messages, and that text carries the account id from the ARN. They now name the error variant only.
authorize_request returned for a dev-mode server before the tag ResourceArn shape check, validate_operation_arns and the GetRecords iterator classification. A dev-mode build therefore served a StreamArn naming another account or partition, and a GetRecords token naming another account, as the caller's own stream. The service applies these checks to every principal before the IAM decision. They now run ahead of the dev-mode return, and dev mode skips only check_authorization. Each check reads the request body, the configured region and the caller's account, with no lock, await or storage access. tests/test_dev_mode_authorization.py, which run-integration-dev-mode runs, sends DescribeStream and GetShardIterator with an aws-cn StreamArn (Invalid StreamArn) and with account 111111111111 (Access is denied), and GetRecords with a hand-built token naming account 111111111111 (Invalid ShardIterator) and with a token in the pre-upgrade shape (Shard iterator has expired). A dev-mode build of the previous head answered 200 to all six.
The service checks a StreamArn's partition shape, then the account, then the region (measured on DescribeStream on 2026-10-28). With the region field empty and another account it answers AccessDeniedException "Access is denied". ExtendDB answered "Invalid StreamArn" there, because arn_is_kind required every field to be non-empty before check_stream_arn_scope compared the account. check_stream_arn_scope now holds the whole StreamArn check: a stream resource with a non-empty partition, service and account, then the caller's account, then the partition, service and region values, where an empty region is a region other than the server's. arn_is_kind had only the backup caller left and becomes arn_is_backup. The seven measured cells are a unit-test table beside respellings() in stream_scope.rs and wire rows in tests/test_stream_arn_scope.py, whose module docstring records the measurement. With the previous order the row for another account with an empty region fails.
The authz_resource module docs said a GetRecords iterator naming another account answers "Access is denied" and one naming another partition, service or region answers "Invalid StreamArn". The code answers both with ValidationException "Invalid ShardIterator", as the tests expect. The docs also said every resource ARN is canonicalized, while a tag operation's stream ARN is authorized as sent and refused by the engine's validate_resource_arn unless its partition, service, region and account are this deployment's. The comments cited measurement files and probe names that are not in the repository. They now cite the measured tables in the module docstring of tests/test_stream_arn_scope.py and their dates, except the GetRecords token answer, which keeps its 2026-10-22 citation as main's iterator comments do.
The variant() doc in stream_scope.rs and a comment in validate_operation_arns_matches_measured_live_behaviour said the error text is left out of failure messages because it carries the account id from the checked ARN. The scope checks answer "Access is denied" and "Invalid StreamArn", which carry no account id, and the same failure messages print the ARN. The reason is removed; the helpers are unchanged.
check_stream_arn_scope refuses a StreamArn with an empty service field with ValidationException "Invalid StreamArn" before it compares the account, so that answer holds in another account too. The service gives the same answer on DescribeStream and GetShardIterator, in another account and in the caller's (measured 2026-10-28 at 21:51 UTC, two samples each). No test sent an empty-service StreamArn, so removing the empty-service check left every test passing. measured_order() and test_partition_then_account_then_region now assert both cells on both operations, and the order table in tests/test_stream_arn_scope.py lists them with the time they were measured.
authorize_request runs validate_tag_resource_arn_shape before the dev-mode return, so a dev-mode server refuses a malformed tag ResourceArn with the same answer as every other build. No dev-mode case sent a tag operation, so moving that check back below the return left every test passing, and a dev-mode server then answered 200 to TagResource with the issued stream ARN plus a trailing slash and wrote a tag under that ARN. The new case in TestDevModeKeepsStreamArnScope sends TagResource and ListTagsOfResource with that ARN and expects ValidationException "One or more parameter values were invalid: Provided Arn is not a DynamoDB resource arn: <arn>", the service's answer for every principal (measured 2026-10-23).
The StreamArn order cells were measured on GetShardIterator on 2026-10-28 at 21:51 UTC, and an empty service field in another account and in the caller's on both operations at the same time, with the answers the code gives. The comments still said those cells were measured on DescribeStream only, or not measured. The order table header in tests/test_stream_arn_scope.py, the doc of check_stream_arn_scope, the doc of stream_arn_checks_follow_the_measured_order and the docstring of test_partition_then_account_then_region now name both operations and the 21:51 UTC set. Comments only.
The authz_resource.rs module docs said every resource ARN produced there is canonical and that a Deny on the real resource always matches, while a tag operation's stream ARN is authorized as sent. They now name that exception, and say that on StreamArn the shape step runs before the account check, so an empty partition, service or account field is Invalid StreamArn in any account. The module docstring of tests/test_stream_arn_scope.py said every answer below it was measured for four principals; the 2026-10-28 order table was measured as the admin only, and the docstring now says so. The TestDevModeKeepsStreamArnScope docstring now says that "Shard iterator has expired" for a pre-upgrade token is this server's own upgrade answer, which no service measurement covers. Comments only.
check_stream_arn_scope in the server and validate_resource_arn in the engine tag handler each compared an ARN's partition, service and region with this deployment's. The tag path depends on the engine copy: a tag operation's stream ARN is authorized as sent, so a Deny on the canonical stream ARN does not match an aws-cn or DYNAMODB spelling, and only the engine check refuses it before a tag row is written. Only the tests kept the two copies in step. Both now call ArnFields::in_deployment in extenddb_core::arn. The tag handler's refusal moves into deployment_scope_error, a function with no storage access, so a unit test can reach it. Unit tests in core, in stream_scope.rs and in tagging.rs check each caller's answer against the predicate for the aws-cn, aws-us-gov, DynamoDB, DYNAMODB, us-west-2, US-EAST-1 and empty-region spellings. No answer changes.
dd4a7ff to
5ebcfb4
Compare
|
Thanks for the merge notes. I rebased onto main at 1d0d8f4.
The partition, service and region rule is now On the tag rows note: #452 covers the unknown label, and I've added the trailing-space case and the rows that outlive the table there. The ruleset needs an approval on the last push, so could you take another look at the new head? |
Summary
DescribeStream and GetShardIterator served the caller's own stream for a
StreamArnthat named another account (#450), or another partition, another region, an upper-cased region, or the service spelledDYNAMODB(#451). Authorization rebuilt every stream ARN asarn:aws:dynamodb:<server region>:<caller account>:table/<name>/stream/<label>and the stream engine resolved the stream by table name and label, so the fields a client might get wrong were ignored on both sides. The tag operations answered the same respelled stream ARN with "Invalid TableArn: Invalid ResourceArn provided as input ".A stream ARN is now checked before the IAM decision: on
StreamArnfor its account, partition, service and region; on a tag operation'sResourceArnfor its account; inside a GetRecords shard iterator for all four fields. A tag operation's stream ARN is authorized as the caller spelled it, and the engine refuses another partition, service or region after authorization with "Invalid StreamArn". Each answer matches DynamoDB as measured on 2026-10-23, apart from two ResourceArn cells noted under Measurement.These checks run before the dev-mode return in
authorize_request, so a dev-mode build (theextenddb-devimage and the npm launcher) refuses the same stream ARNs; dev mode skips only the IAM decision. OnStreamArnthe checks run in the service's order: the ARN's shape first (an empty partition included), then the account, then the partition, service and region values.Measurement
Live DynamoDB, scratch account, us-east-1, 2026-10-23 12:22 to 12:27 UTC. Stream
arn:aws:dynamodb:us-east-1:<account>:table/bb-arn-main/stream/2026-10-20T16:17:40.188. Four principals: admin (Allow dynamodb:* on *), noperm (no stream permissions), exact (Allow dynamodb:*on the canonical stream ARN only), deny (Allow *plusDeny dynamodb:*on the canonical stream ARN).StreamArn on DescribeStream and GetShardIterator, the same answer for all four principals:
111111111111, accountabc, another account withaws-cn,us-west-2or an unknown labelaws-cn,aws-us-gov,DynamoDB,DYNAMODB,us-west-2,US-EAST-1, extra field, missing region field, trailing slashResourceArn on ListTagsOfResource, TagResource and UntagResource:
111111111111,abc, empty account, missing region fieldaws-cn,aws-us-gov,DynamoDB,DYNAMODB,us-west-2,US-EAST-1So on a tag operation the account is checked before the IAM decision, the IAM decision runs on the ARN as sent, and the partition, service and region are checked after it.
Two of these cells are answered differently by this server, and
test_stream_arn_scope.pydoes not assert them. A missing region field gets "One or more parameter values were invalid: Provided Arn is not a DynamoDB resource arn: ", because the shape check reads the shifted fields first. An unknown label gets 200 on all three operations (#452).GetRecords with a token the service did not issue (canonical, another account,
aws-cn,us-west-2, random bytes): ValidationException "Invalid ShardIterator" for admin and noperm.UntagResource, measured 14:03 to 14:09 UTC for the same four principals and spellings, matches the ListTagsOfResource and TagResource rows in every cell.
The service checks a StreamArn's shape first (an empty partition or service field), then the account, then the region. Measured on 2026-10-28 in us-east-1 as the admin principal, two samples per row: DescribeStream from 20:47 to 20:48 UTC and GetShardIterator at 21:51 UTC, except the empty-service row, measured on both operations at 21:51 UTC.
111111111111or the caller's111111111111or the caller's111111111111with an empty region, withus-west-2, or with every other field the caller'sus-west-2What changed
crates/server/src/authz_resource/stream_scope.rs(new)check_stream_arn_account: the account must be the caller's, AccessDeniedException "Access is denied".check_stream_arn_scope: the whole StreamArn check, in the measured order. A value that is not a stream ARN, or has an empty partition, service or account, is ValidationException "Invalid StreamArn"; then another account is AccessDeniedException "Access is denied"; then a partition other thanaws, a service other thandynamodbor a region other than the server's, an empty region included, is "Invalid StreamArn".crates/server/src/authz_resource.rsvalidate_operation_arnsappliescheck_stream_arn_scopetoStreamArnon DescribeStream and GetShardIterator. The kind check it replaces forStreamArnrefused an empty region before the account check; it stays forBackupArnasarn_is_backup.validate_tag_resource_arn_shapeappliescheck_stream_arn_accountto a stream ARN inResourceArn.tag_resource_arnpasses a stream ARN to authorization as sent instead of canonicalizing it. A table ARN is still canonicalized.getrecords_resourcerefuses an iterator whose stream ARN is outside scope as Invalid. GetShardIterator now refuses such aStreamArn, so it issues no such token.aws-cn,s3,us-west-2or other-account stream ARN canonicalizes to the deployment's ARN is replaced by tests for each measured answer.crates/server/src/request_helpers.rs: passes the region and the caller's account tovalidate_operation_arns, and runs the tagResourceArnshape check,validate_operation_arnsand the GetRecords iterator classification before the dev-mode return. The GetRecords IAM check stays after it.crates/engine/src/tagging.rs: a stream ARN with another partition, service or region answers "Invalid StreamArn" instead of "Invalid TableArn: Invalid ResourceArn provided as input ". Table ARNs are unchanged.crates/core/src/arn.rs:ArnFields::in_deployment, the partition, service and region rule.check_stream_arn_scopeand the engine's tag check both call it. The tag check's refusal moves intodeployment_scope_error, which reads no storage, so a unit test can reach it.docs/manuals/07-upgrade-manual.md: a client-builtarn:aws-cn:orarn:aws-us-gov:StreamArn is now refused.Tests
tests/test_stream_arn_scope.py(new): the two tables above on the wire for the four principals, five operations (DescribeStream, GetShardIterator, ListTagsOfResource, TagResource, UntagResource) and seventeen spellings, with message text; a Deny on the canonical ARN not evaded by any spelling; forged GetRecords tokens; the unknown-label messages of DescribeStream and GetShardIterator. The file has ten tests, and seven of them fail against main at 1d0d8f4 (the canonical ARN, malformed ARN and unknown label tests pass there).tests/test_stream_arn_scope.py::test_partition_then_account_then_region: the nine 2026-10-28 cells of the Measurement table, on DescribeStream and GetShardIterator. The test fails if the kind check runs ahead of the account check (the row for another account with an empty region answers "Invalid StreamArn") or if the empty-service check is removed (the row for another account with an empty service answers "Access is denied").tests/test_dev_mode_authorization.py::TestDevModeKeepsStreamArnScope(run by run-integration-dev-mode): DescribeStream and GetShardIterator with anaws-cnStreamArn ("Invalid StreamArn") and with account111111111111("Access is denied"); TagResource and ListTagsOfResource with the issued stream ARN plus a trailing slash ("Provided Arn is not a DynamoDB resource arn: /"); GetRecords with a hand-built token naming account111111111111("Invalid ShardIterator") and a token in the pre-upgrade shape ("Shard iterator has expired"). With the checks after the dev-mode return, a dev-mode server answers 200 to all eight requests, and TagResource writes a tag under the trailing-slash ARN.tests/test_authz_resource_arn.py::test_respelled_stream_arn_cannot_bypass_deny: expects "Invalid StreamArn" instead of AccessDeniedException; fails on main.tests/test_cross_account_isolation.py::TestStreamAccountScoping: a current-format token naming account A's stream, sent by account B, is "Invalid ShardIterator" instead of ResourceNotFoundException; fails on main.in_deployment_compares_partition_service_and_region_exactlyin core, andstream_arn_scope_uses_the_shared_predicateanddeployment_scope_uses_the_shared_predicate, which compare each caller's answer with the predicate for theaws-cn,aws-us-gov,DynamoDB,DYNAMODB,us-west-2,US-EAST-1and empty-region spellings.The order cells were measured as the admin principal only, and no service measurement covers "Shard iterator has expired" for a pre-upgrade token, which is this server's answer to an iterator issued before the upgrade.
Behaviour an upgrader will notice
StreamArnwhose account is not the caller's return AccessDeniedException "Access is denied" instead of the caller's stream. With another partition, region or service spelling they return ValidationException "Invalid StreamArn". An empty partition, service or account field, or a value that is not a stream ARN, is "Invalid StreamArn" whatever the account; another account with an empty region field is "Access is denied".StreamArn(aws-cn,US-EAST-1,DYNAMODB), and every iterator chained from it, gets that answer after the upgrade rather than ExpiredIteratorException, as does every iterator issued before a change ofserver.region. The consumer has to call GetShardIterator again.StreamArnrefusal also applies on a deployment whoseserver.regionis a China or GovCloud region: a client there that builds anarn:aws-cn:orarn:aws-us-gov:StreamArn itself was served before and is refused now. The server issues every stream ARN asarn:aws:, so ARNs taken from DescribeTable or ListStreams are not affected.StreamArnis ValidationException "Invalid StreamArn" (was "Invalid stream ARN format: " for a table ARN, and 200 for an empty partition).Rebase on #543
Rebased onto main at 1d0d8f4. #543 moved the stream not-found messages into
extenddb_storage::util::arnand dropped the trailing period from DescribeStream's "Stream: not found" (the message for a shard that is not one of the stream's shards keeps it), so all four stream engines take main's side and this PR no longer changes them. Inrequest_helpers.rsthe GetRecords branch keepsif let Some(resource_arn) = getrecords_stream, with #522'sbase_params(request_tags, request_tag_keys)and #543's sentence about the storage read. Inauthz_resource.rsthe backup kind check trims the ARN withextenddb_core::backup_format::trim_backup_arn, as #394 does, andArnKindis removed.test_unknown_labelalso pins GetShardIterator's "Requested resource not found: Stream does not exist", which main answers since #543.Also on this head:
extenddb_core::arn::ArnFields::in_deploymentholds the partition, service and region rule.check_stream_arn_scopeand the engine's tag check both call it, and the tag check's refusal moves intodeployment_scope_error, which reads no storage, so a unit test can call it. A unit test in each caller compares its answer with the predicate; with a case-folded region compare in either caller, its test fails.validate_operation_arnslists only the fields it checks. Thegetrecords_resourcedocs say which iterators getInvalid ShardIteratorafter the upgrade.docs/manuals/07-upgrade-manual.mdnotes that a client-builtarn:aws-cn:orarn:aws-us-gov:StreamArn is refused.Checked on the rebased head, debug builds:
cargo +1.97.0 fmt --all --check;cargo +1.97.0 clippy --locked --workspace --all-targets -- -D warnings;cargo +1.97.0 check --locked --workspace --all-targets;cargo +1.88.0 check --locked --workspace.cargo +1.97.0 test --locked --lib: extenddb-core 698 passed (1 ignored), extenddb-engine 184, extenddb-server 240.test_stream_arn_scope.py10/10,test_authz_resource_arn.py43/43,test_cross_account_isolation.py11/11,tests/python/test_streams.py16/16,test_stream_shard_ownership.py3/3,test_tagging.py7/7, andtest_abac,test_auth_permissions,test_misc_operations,test_stream_table_reuse,test_ttl,test_put_item_create_raceand the fivetest_tag_request_*files, 262/262.tests/test_dev_mode_authorization.pyon a dev-mode build: 8/8.test_stream_arn_scope.pyagainst a SQLite server built from main: 7 of 10 fail. The canonical ARN, malformed ARN and unknown label tests pass there.Fixes #450
Fixes #451