From ccdae81c2e3147c21d736614ba0bc9343e71b5d6 Mon Sep 17 00:00:00 2001 From: Lee <29015211+LeeroyHannigan@users.noreply.github.com> Date: Wed, 4 Nov 2026 23:19:39 +0000 Subject: [PATCH 1/3] fix(server): check table names, index names and ARNs before authorization A request's table names reach storage before the engine checks them. The authorization layer reads each table's key schema for dynamodb:LeadingKeys. It also reads the resource tags of the table ARN it builds from the name. On PostgreSQL a name that holds U+0000 cannot bind as a TEXT parameter. So those lookups failed, and the request answered 500, "Internal error during authorization". DynamoDB answers 400 ValidationException, and it answers before the IAM decision. A caller with no permission gets the ValidationException for a bad name, and AccessDeniedException for the name of a table that exists (measured on 2026-10-24). Two new modules in crates/server run first in authorize_request, before the key info and tag lookups. table_name_gate checks every field that carries a table or index name: TableName, the RequestItems keys and the TransactItems table names. It also checks the restore SourceTableName and TargetTableName, the ImportTable table name, ExclusiveStartTableName, and the Query and Scan IndexName. A name outside the pattern [a-zA-Z0-9_.-]+ gets the member pattern sentence at the field's own location. A name that also breaks a length bound gets both clauses, the length clause first. ListStreams has its own sentence. arn_gate checks an ARN in a name field, and the stream, backup, export, restore and tag ARN fields. The faults are a bad table segment, a bad account or region segment, and a shape that is not a plain table ARN. Each fault gets the sentence DynamoDB gives for it. UpdateTable reports an index name outside the pattern at globalSecondaryIndexUpdates.N.member.create.indexName and at the delete form. Those are the paths DynamoDB uses. The sentences were measured against DynamoDB in us-east-1 on 2026-10-24 and again on 2026-11-04. Part of #367 --- crates/engine/src/update_table.rs | 87 ++- crates/server/src/arn_gate.rs | 332 ++++++++++++ crates/server/src/lib.rs | 2 + crates/server/src/request_helpers.rs | 8 + crates/server/src/table_name_gate.rs | 581 +++++++++++++++++++++ tests/python/conftest.py | 21 +- tests/python/helpers.py | 43 ++ tests/python/test_nul_arn_shapes.py | 168 ++++++ tests/python/test_nul_table_name_fields.py | 270 ++++++++++ tests/python/test_nul_table_names.py | 272 ++++++++++ 10 files changed, 1779 insertions(+), 5 deletions(-) create mode 100644 crates/server/src/arn_gate.rs create mode 100644 crates/server/src/table_name_gate.rs create mode 100644 tests/python/test_nul_arn_shapes.py create mode 100644 tests/python/test_nul_table_name_fields.py create mode 100644 tests/python/test_nul_table_names.py diff --git a/crates/engine/src/update_table.rs b/crates/engine/src/update_table.rs index 6db763f82..27ade5cf3 100755 --- a/crates/engine/src/update_table.rs +++ b/crates/engine/src/update_table.rs @@ -10,6 +10,31 @@ use serde_json::Value; use crate::OperationContext; use crate::serialize_output; +/// `validate_index_name` with the pattern clause located at the update's +/// member path, as the service reports it (measured 2026-10-24). The length +/// clauses keep the bare `indexName` location, which is not measured for +/// UpdateTable. +fn validate_update_index_name( + name: &str, + position: usize, + action: &str, +) -> Result<(), DynamoDbError> { + match extenddb_core::validation::validate_index_name(name) { + Err(DynamoDbError::ValidationException(m)) + if m.contains("' at 'indexName' ") && m.contains("regular expression pattern") => + { + Err(DynamoDbError::ValidationException(m.replace( + "' at 'indexName' ", + &format!( + "' at 'globalSecondaryIndexUpdates.{}.member.{action}.indexName' ", + position + 1 + ), + ))) + } + other => other, + } +} + /// Handle `UpdateTable` — modify billing mode, throughput, deletion protection, /// or GSI configuration. /// @@ -209,7 +234,7 @@ pub async fn handle_update_table( /// (when given) a throughput of at least 1 unit each. fn validate_gsi_updates(input: &UpdateTableInput) -> Result<(), DynamoDbError> { if let Some(updates) = &input.global_secondary_index_updates { - for update in updates { + for (position, update) in updates.iter().enumerate() { if update.create.is_some() && update.delete.is_some() { return Err(DynamoDbError::ValidationException( "One or more parameter values were invalid: Only one of Create or Delete can be specified per GlobalSecondaryIndexUpdate".to_owned(), @@ -227,9 +252,11 @@ fn validate_gsi_updates(input: &UpdateTableInput) -> Result<(), DynamoDbError> { "One or more parameter values were invalid: GlobalSecondaryIndexUpdate must contain Create, Update, or Delete".to_owned(), )); } - // M3: Validate index names the same way CreateTable does. + // Index names are validated the way CreateTable does, except that the + // pattern clause carries the member's path (measured 2026-10-24: + // `globalSecondaryIndexUpdates..member.create.indexName`, 1-based). if let Some(create) = &update.create { - extenddb_core::validation::validate_index_name(&create.index_name)?; + validate_update_index_name(&create.index_name, position, "create")?; extenddb_core::validation::validate_index_throughput_minimum( &create.index_name, create.provisioned_throughput.as_ref(), @@ -260,7 +287,7 @@ fn validate_gsi_updates(input: &UpdateTableInput) -> Result<(), DynamoDbError> { } } if let Some(delete) = &update.delete { - extenddb_core::validation::validate_index_name(&delete.index_name)?; + validate_update_index_name(&delete.index_name, position, "delete")?; } } } @@ -379,6 +406,58 @@ mod tests { validate_gsi_updates(&input(ok)).unwrap(); } + #[test] + fn an_index_name_outside_the_pattern_is_reported_at_its_member_path() { + let input = |updates: Value| -> UpdateTableInput { + serde_json::from_value(serde_json::json!({ + "TableName": "t", + "AttributeDefinitions": [{ "AttributeName": "g", "AttributeType": "S" }], + "GlobalSecondaryIndexUpdates": updates, + })) + .unwrap() + }; + let create = |name: &str| { + serde_json::json!({ "Create": { + "IndexName": name, + "KeySchema": [{ "AttributeName": "g", "KeyType": "HASH" }], + "Projection": { "ProjectionType": "ALL" }, + }}) + }; + let cases = [ + ( + serde_json::json!([create("g\u{0}2")]), + "globalSecondaryIndexUpdates.1.member.create.indexName", + ), + ( + serde_json::json!([ + { "Delete": { "IndexName": "gix" } }, + { "Delete": { "IndexName": "g\u{0}2" } }, + ]), + "globalSecondaryIndexUpdates.2.member.delete.indexName", + ), + ]; + for (updates, path) in cases { + match validate_gsi_updates(&input(updates)).unwrap_err() { + DynamoDbError::ValidationException(m) => assert_eq!( + m, + format!( + "1 validation error detected: Value 'g\u{0}2' at '{path}' failed to \ + satisfy constraint: Member must satisfy regular expression pattern: \ + [a-zA-Z0-9_.-]+" + ) + ), + other => panic!("{path}: {other:?}"), + } + } + // A length fault keeps the bare location, which is not measured here. + match validate_gsi_updates(&input(serde_json::json!([create("ab")]))).unwrap_err() { + DynamoDbError::ValidationException(m) => { + assert!(m.contains("' at 'indexName' "), "{m}"); + } + other => panic!("{other:?}"), + } + } + #[test] fn projected_total_counts_kept_indexes_and_created_ones() { use extenddb_core::types::{ diff --git a/crates/server/src/arn_gate.rs b/crates/server/src/arn_gate.rs new file mode 100644 index 000000000..2f8d1a7b9 --- /dev/null +++ b/crates/server/src/arn_gate.rs @@ -0,0 +1,332 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! The ARN shapes `table_name_gate` refuses before any catalog lookup, with the +//! service's sentences (measured 2026-10-24 unless marked derived). +//! +//! A `TableName` may be a table ARN. The service checks the ARN's shape and +//! each of its segments before it resolves the table: +//! +//! | `TableName` | sentence at `'tableName'` | +//! |---|---| +//! | a table ARN whose table segment fails the pattern | `Table name of ARN must satisfy regular expression pattern: ...` | +//! | a dynamodb ARN with a bad region segment | `Invalid AWS region` | +//! | a dynamodb ARN with a bad account segment | `Invalid subscriber ID` | +//! | anything else that is not a plain table ARN (another service, an index or stream suffix, too few fields) | the `Valid ARN format is ...` frame | +//! +//! `DescribeTable` with an ARN of another service is `AccessDeniedException` +//! on the service; that answer is left to the engine, so the frame is not +//! applied to it there. The frame for a well-formed index or stream ARN in +//! `TableName` is derived from the measured index ARN with a bad segment. +//! +//! The other ARN-bearing fields: `SourceTableArn` (`RestoreTableToPointInTime`) +//! and `TableArn` (`ExportTableToPointInTime`) with a bad table segment, +//! `StreamArn` (`DescribeStream`, `GetShardIterator`) and a tag operation's +//! stream `ResourceArn` with a bad table segment or U+0000 in the label, +//! `BackupArn` (`DescribeBackup`, `DeleteBackup`, `RestoreTableFromBackup`) +//! with a bad table segment, and a tag operation's table `ResourceArn` with a +//! bad table segment. Each has its own sentence below. + +use extenddb_core::arn::{SubResource, fields, parse}; +use extenddb_core::error::DynamoDbError; + +pub(crate) const PATTERN: &str = "[a-zA-Z0-9_.-]+"; + +const FRAME: &str = "Valid ARN format is \ + 'arn:::::resourceType/resourceName', \ + where Resource name must have length less than or equal to 255, \ + Resource name must have length greater than or equal to 3, \ + Resource name must satisfy regular expression pattern: [a-zA-Z0-9_.-]+"; + +/// The `RequestItems` map-key constraint list for a key that is an ARN. +pub(crate) const MAP_KEYS_ARN: &str = "Map keys must satisfy constraint: \ + [Valid resource name or resource arn must be given. Valid resource arn format is \ + 'arn:::::resourceType/resourceName', \ + where Resource name must have length less than or equal to 255, \ + Resource name must have length greater than or equal to 3, \ + Resource name must satisfy regular expression pattern: [a-zA-Z0-9_.-]+]"; + +pub(crate) fn in_pattern(name: &str) -> bool { + name.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-') +} + +fn validation(message: String) -> DynamoDbError { + DynamoDbError::ValidationException(message) +} + +fn one_error(value: &str, location: &str, constraint: &str) -> DynamoDbError { + validation(format!( + "1 validation error detected: Value '{value}' at '{location}' failed to satisfy \ + constraint: {constraint}" + )) +} + +/// What is wrong with an `arn:` value read as a table ARN. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum ArnFault { + /// A plain table ARN whose table segment fails the pattern. + TableSegment, + /// A dynamodb ARN whose region segment carries a character outside `[a-z0-9-]`. + Region, + /// A dynamodb ARN whose account segment carries a character that is not a digit. + Account, + /// Not a plain table ARN of this service: too few fields, another service, + /// an index or stream or backup suffix, or a shape `arn::parse` rejects. + NotPlainTable, +} + +/// Inspect an `arn:` value the way the service checks a `TableName` ARN. +/// `None` means a well-formed plain table ARN whose segments all pass. +pub(crate) fn table_arn_fault(arn: &str) -> Option { + let Some(f) = fields(arn) else { + return Some(ArnFault::NotPlainTable); + }; + if !f + .region + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + { + return Some(ArnFault::Region); + } + if !f.account.chars().all(|c| c.is_ascii_digit()) { + return Some(ArnFault::Account); + } + if f.service != "dynamodb" { + return Some(ArnFault::NotPlainTable); + } + match parse(arn) { + Some(res) if res.sub.is_none() => { + (!in_pattern(&res.table)).then_some(ArnFault::TableSegment) + } + _ => Some(ArnFault::NotPlainTable), + } +} + +fn arn_constraint(fault: ArnFault) -> &'static str { + match fault { + ArnFault::TableSegment => { + "Table name of ARN must satisfy regular expression pattern: [a-zA-Z0-9_.-]+" + } + ArnFault::Region => "Invalid AWS region", + ArnFault::Account => "Invalid subscriber ID", + ArnFault::NotPlainTable => FRAME, + } +} + +/// A `TableName` that is an ARN, for `operation`. `Ok` passes the value on to +/// the handlers, which resolve a well-formed table ARN. +pub(crate) fn check_table_name_arn(operation: &str, arn: &str) -> Result<(), DynamoDbError> { + let Some(fault) = table_arn_fault(arn) else { + return Ok(()); + }; + // DescribeTable answers a foreign-service ARN with AccessDeniedException on + // the service; the engine's own answer stands for that one shape. + if operation == "DescribeTable" + && fault == ArnFault::NotPlainTable + && fields(arn).is_some_and(|f| f.service != "dynamodb") + { + return Ok(()); + } + Err(one_error(arn, "tableName", arn_constraint(fault))) +} + +/// A `TransactItems` member's `TableName` that is an ARN, at `location` +/// (`transactItems..member..tableName`). A plain table ARN with a bad +/// table segment is measured; the other faults take the same constraints as a +/// top-level `TableName` (derived). +pub(crate) fn check_transact_member_arn(arn: &str, location: &str) -> Result<(), DynamoDbError> { + match table_arn_fault(arn) { + None => Ok(()), + Some(fault) => Err(one_error(arn, location, arn_constraint(fault))), + } +} + +/// `SourceTableArn` of `RestoreTableToPointInTime` and `TableArn` of +/// `ExportTableToPointInTime` with a table segment that fails the pattern. +pub(crate) fn check_table_arn_field(operation: &str, arn: &str) -> Result<(), DynamoDbError> { + let bad = matches!(parse(arn), Some(res) if res.sub.is_none() && !in_pattern(&res.table)); + if !bad { + return Ok(()); + } + Err(validation( + match operation { + "RestoreTableToPointInTime" => "Invalid Request: Invalid TableArn: Invalid Table ARN", + _ => "Invalid TableArn: Invalid Table ARN", + } + .to_owned(), + )) +} + +/// A stream ARN whose table segment fails the pattern or whose label carries +/// U+0000: `Invalid StreamArn` (measured for `DescribeStream`, +/// `GetShardIterator` and `TagResource`). +pub(crate) fn check_stream_arn(arn: &str) -> Result<(), DynamoDbError> { + match parse(arn) { + Some(res) + if matches!(&res.sub, Some(SubResource::Stream(label)) + if !in_pattern(&res.table) || label.contains('\u{0}')) => + { + Err(validation("Invalid StreamArn".to_owned())) + } + _ => Ok(()), + } +} + +/// A backup ARN whose table segment fails the pattern: `Invalid Backup ARN` +/// (measured for `DescribeBackup`, `DeleteBackup` and +/// `RestoreTableFromBackup`). U+0000 in the backup id keeps the engine's +/// `Invalid Request: BackupArn is not valid`, as measured. +pub(crate) fn check_backup_arn(arn: &str) -> Result<(), DynamoDbError> { + match parse(arn.trim_matches(|c: char| c.is_ascii_whitespace())) { + Some(res) if matches!(res.sub, Some(SubResource::Backup(_))) && !in_pattern(&res.table) => { + Err(validation("Invalid Backup ARN".to_owned())) + } + _ => Ok(()), + } +} + +/// A tag operation's `ResourceArn`: a table ARN with a bad table segment is +/// `Invalid TableArn: Invalid ResourceArn provided as input ` (measured +/// for `TagResource`; `UntagResource` and `ListTagsOfResource` the same), a +/// stream ARN with a bad table segment or U+0000 in the label is +/// `Invalid StreamArn`. An index or backup ARN keeps the shape check's verdict. +pub(crate) fn check_tag_resource_arn(arn: &str) -> Result<(), DynamoDbError> { + check_stream_arn(arn)?; + match parse(arn) { + Some(res) if res.sub.is_none() && !in_pattern(&res.table) => Err(validation(format!( + "Invalid TableArn: Invalid ResourceArn provided as input {arn}" + ))), + _ => Ok(()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const ACCT: &str = "123456789012"; + fn table(name: &str) -> String { + format!("arn:aws:dynamodb:us-east-1:{ACCT}:table/{name}") + } + fn message(r: Result<(), DynamoDbError>) -> Option { + match r { + Ok(()) => None, + Err(DynamoDbError::ValidationException(m)) => Some(m), + Err(other) => panic!("{other:?}"), + } + } + + #[test] + fn a_plain_table_arn_with_a_bad_segment_gets_the_table_name_of_arn_sentence() { + let arn = table("t367\u{0}name"); + let m = message(check_table_name_arn("PutItem", &arn)).expect("refused"); + assert_eq!( + m, + format!( + "1 validation error detected: Value '{arn}' at 'tableName' failed to satisfy \ + constraint: Table name of ARN must satisfy regular expression pattern: \ + [a-zA-Z0-9_.-]+" + ) + ); + assert!(check_table_name_arn("PutItem", &table("t367name")).is_ok()); + } + + #[test] + fn region_and_account_faults_name_their_segment() { + let region = format!("arn:aws:dynamodb:us-ea\u{0}st-1:{ACCT}:table/t367name"); + assert!( + message(check_table_name_arn("PutItem", ®ion)) + .unwrap() + .ends_with("failed to satisfy constraint: Invalid AWS region") + ); + let account = "arn:aws:dynamodb:us-east-1:1234\u{0}56789012:table/t367name"; + assert!( + message(check_table_name_arn("PutItem", account)) + .unwrap() + .ends_with("failed to satisfy constraint: Invalid subscriber ID") + ); + } + + #[test] + fn other_shapes_get_the_valid_arn_format_frame() { + for arn in [ + "arn:aws:s3:::t367\u{0}name".to_owned(), + format!("{}/index/i\u{0}x", table("t367name")), + format!("{}/index/ix", table("t367\u{0}name")), + format!("{}/stream/2026-10-24T00:00:00.000", table("t367\u{0}name")), + format!("{}/index/ix", table("t367name")), + "arn:\u{0}".to_owned(), + format!("{}/", table("t367\u{0}name")), + ] { + let m = + message(check_table_name_arn("Query", &arn)).unwrap_or_else(|| panic!("{arn:?}")); + assert_eq!( + m, + format!( + "1 validation error detected: Value '{arn}' at 'tableName' failed to satisfy \ + constraint: {FRAME}" + ) + ); + } + let m = message(check_transact_member_arn( + &table("t367\u{0}name"), + "transactItems.1.member.put.tableName", + )) + .unwrap(); + assert!(m.contains("at 'transactItems.1.member.put.tableName' failed to satisfy constraint: Table name of ARN"), "{m}"); + } + + #[test] + fn describe_table_leaves_a_foreign_service_arn_to_the_engine() { + assert!(check_table_name_arn("DescribeTable", "arn:aws:s3:::t367\u{0}name").is_ok()); + assert!( + check_table_name_arn( + "DescribeTable", + &format!("{}/index/ix", table("t367\u{0}name")) + ) + .is_err() + ); + } + + #[test] + fn table_arn_fields_stream_backup_and_tag_arns() { + let bad = table("t367\u{0}name"); + assert_eq!( + message(check_table_arn_field("RestoreTableToPointInTime", &bad)).unwrap(), + "Invalid Request: Invalid TableArn: Invalid Table ARN" + ); + assert_eq!( + message(check_table_arn_field("ExportTableToPointInTime", &bad)).unwrap(), + "Invalid TableArn: Invalid Table ARN" + ); + assert!(check_table_arn_field("ExportTableToPointInTime", &table("ok")).is_ok()); + let stream = format!("{bad}/stream/2026-10-24T00:00:00.000"); + assert_eq!( + message(check_stream_arn(&stream)).unwrap(), + "Invalid StreamArn" + ); + let label = format!("{}/stream/2026\u{0}", table("ok")); + assert_eq!( + message(check_stream_arn(&label)).unwrap(), + "Invalid StreamArn" + ); + assert!(check_stream_arn(&format!("{}/stream/2026", table("ok"))).is_ok()); + let backup = format!("{bad}/backup/01700000000000-abcdef12"); + assert_eq!( + message(check_backup_arn(&backup)).unwrap(), + "Invalid Backup ARN" + ); + let backup_id = format!("{}/backup/0170\u{0}", table("ok")); + assert!(check_backup_arn(&backup_id).is_ok(), "left to the engine"); + assert_eq!( + message(check_tag_resource_arn(&bad)).unwrap(), + format!("Invalid TableArn: Invalid ResourceArn provided as input {bad}") + ); + assert_eq!( + message(check_tag_resource_arn(&stream)).unwrap(), + "Invalid StreamArn" + ); + assert!(check_tag_resource_arn(&format!("{bad}/index/i")).is_ok()); + } +} diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index f608c74d7..fbaf49648 100755 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -8,6 +8,7 @@ //! with correct headers (x-amzn-RequestId, x-amz-crc32, Content-Type). //! Supports TLS via `axum-server` with rustls when configured. +pub(crate) mod arn_gate; pub(crate) mod authorization; pub mod authz_cache; pub(crate) mod authz_resource; @@ -25,6 +26,7 @@ mod request_helpers; mod request_tags; mod response; mod serve; +mod table_name_gate; mod throttle_helpers; mod workers; diff --git a/crates/server/src/request_helpers.rs b/crates/server/src/request_helpers.rs index 8c0d6e39c..244fde437 100755 --- a/crates/server/src/request_helpers.rs +++ b/crates/server/src/request_helpers.rs @@ -108,6 +108,14 @@ pub(crate) async fn authorize_request( source_ip: Option<&str>, user_agent: Option<&str>, ) -> Result { + // A table name, index name or ARN that the service refuses, in TableName + // or in any other field that carries one (RequestItems, TransactItems, the + // restore and import names, the stream, backup and tag ARNs), is refused + // here, before the key info and resource tag lookups below and before the + // developer-mode return, the cross-account refusal and the ARN shape + // checks (see `table_name_gate`). + crate::table_name_gate::check_table_name_chars(operation, input)?; + let table_name = extract_table_name(input); let mut authorized_key_info = extenddb_engine::AuthorizedKeyInfo::new(); diff --git a/crates/server/src/table_name_gate.rs b/crates/server/src/table_name_gate.rs new file mode 100644 index 000000000..d3202f1b0 --- /dev/null +++ b/crates/server/src/table_name_gate.rs @@ -0,0 +1,581 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! The table name checks that run before any catalog lookup. +//! +//! A request's table names reach storage before the engine validates them: +//! authorization looks up each table's key schema (for `dynamodb:LeadingKeys`) +//! and the resource tags for the table ARN it builds from the name. On +//! PostgreSQL a name containing U+0000 cannot be bound as a `TEXT` parameter +//! (`invalid byte sequence for encoding "UTF8": 0x00`), so those lookups failed +//! and the request was answered 500 `Internal error during authorization`. +//! The service answers 400 `ValidationException`, and it does so before the +//! IAM decision: a principal with no DynamoDB permission gets the same +//! `ValidationException` for a bad name and `AccessDeniedException` for a real +//! one (measured 2026-10-24 with `DescribeTable`, `PutItem`, `Scan` and +//! `BatchWriteItem`). This module runs first in `authorize_request`. +//! +//! Sentences (measured 2026-10-24 unless marked otherwise): +//! +//! - A bare name outside `[a-zA-Z0-9_.-]+`: `Value '' at '' +//! failed to satisfy constraint: Member must satisfy regular expression +//! pattern: ...`. When the name is also shorter than 3 or longer than 255 +//! characters the message lists both clauses, the length first, joined by +//! `; ` under `2 validation errors detected:` (measured at `tableName`; +//! derived for the other locations). Locations: `tableName` for `TableName`, +//! `targetTableName`, `sourceTableName`, `tableCreationParameters.tableName`, +//! `exclusiveStartTableName`, `indexName` for `Query` and `Scan`, and +//! `transactItems..member..tableName` (1-based, `put`, `get`, +//! `delete`, `update`, `conditionCheck`). A name inside the pattern but of a +//! bad length is left to the engine handlers, whose answer the service's +//! matches already. +//! - `ListStreams` has its own validator: `TableName must be at least 3 +//! characters long and at most 255 characters long`, measured for a U+0000 +//! name, and on 2026-11-04 for a table ARN and for `abc:def`; applied to +//! any other character outside the pattern (derived). +//! - A `RequestItems` key outside the pattern: `at 'requestItems'` with the +//! map-key constraint list. The service renders its own dump of the whole +//! map inside `Value '...'`; the first offending key the parsed map yields +//! is rendered alone here. That is request order only when `serde_json`'s +//! `preserve_order` feature is in the build (the MongoDB driver turns it +//! on), and key order otherwise. A key that is an ARN takes the ARN +//! constraint list (`arn_gate::MAP_KEYS_ARN`). +//! - ARN-shaped values: see `arn_gate`. +//! +//! The service reads `SourceTableName`, `TargetTableName` (both restores), +//! `TableCreationParameters.TableName` (`ImportTable`), +//! `ExclusiveStartTableName` and the `ListStreams` `TableName` as names, never +//! as ARNs; the service model gives each the `TableName` shape. For an ARN in +//! `ImportTable` it answers with the authorization of +//! `table/` (measured 2026-11-03), and for an ARN in +//! `SourceTableName` with the pattern sentence at `sourceTableName` for a +//! caller allowed every action (measured 2026-11-04). So the ARN rules do not +//! apply in these members: a value that starts with `arn:` and carries no +//! U+0000 is left to those later checks, and one that carries U+0000 gets the +//! member's own sentence (derived), as a bare name outside the pattern does. +//! `ListStreams` refuses every value outside the pattern, as measured. + +use extenddb_core::error::DynamoDbError; +use serde_json::Value; + +use crate::arn_gate::{self, PATTERN, in_pattern}; + +const MAP_KEYS_NAME: &str = "Map keys must satisfy constraint: \ + [Member must have length less than or equal to 255, \ + Member must have length greater than or equal to 3, \ + Member must satisfy regular expression pattern: [a-zA-Z0-9_.-]+]"; + +const LIST_STREAMS_NAME: &str = + "TableName must be at least 3 characters long and at most 255 characters long"; + +/// Refuse a table name outside the service's character pattern, in any field +/// the operation carries one, before the name is used in any lookup. A +/// missing, non-string or empty name passes. +pub(crate) fn check_table_name_chars(operation: &str, input: &Value) -> Result<(), DynamoDbError> { + match operation { + "BatchGetItem" | "BatchWriteItem" => check_request_items_keys(input), + "TransactGetItems" => check_transact_items(input, &["Get"]), + "TransactWriteItems" => { + check_transact_items(input, &["Put", "Delete", "Update", "ConditionCheck"]) + } + "TagResource" | "UntagResource" | "ListTagsOfResource" => { + with_str(input, "ResourceArn", arn_gate::check_tag_resource_arn) + } + "DescribeStream" | "GetShardIterator" => { + with_str(input, "StreamArn", arn_gate::check_stream_arn) + } + "DescribeBackup" | "DeleteBackup" => { + with_str(input, "BackupArn", arn_gate::check_backup_arn) + } + "RestoreTableFromBackup" => { + with_str(input, "BackupArn", arn_gate::check_backup_arn)?; + check_name_member(input.get("TargetTableName"), "targetTableName") + } + "RestoreTableToPointInTime" => { + check_name_member(input.get("SourceTableName"), "sourceTableName")?; + check_name_member(input.get("TargetTableName"), "targetTableName")?; + with_str(input, "SourceTableArn", |a| { + arn_gate::check_table_arn_field(operation, a) + }) + } + "ExportTableToPointInTime" => with_str(input, "TableArn", |a| { + arn_gate::check_table_arn_field(operation, a) + }), + "ImportTable" => check_name_member( + input + .get("TableCreationParameters") + .and_then(|p| p.get("TableName")), + "tableCreationParameters.tableName", + ), + "ListTables" => check_name_member( + input.get("ExclusiveStartTableName"), + "exclusiveStartTableName", + ), + "ListStreams" => match input.get("TableName").and_then(Value::as_str) { + Some(name) if !name.is_empty() && !in_pattern(name) => Err( + DynamoDbError::ValidationException(LIST_STREAMS_NAME.to_owned()), + ), + _ => Ok(()), + }, + "Query" | "Scan" => { + check_name(input.get("TableName"), "tableName", operation)?; + match input.get("IndexName").and_then(Value::as_str) { + // Pattern only: the length clauses for an index name are unmeasured. + Some(index) if !index.is_empty() && !in_pattern(index) => { + Err(one_error(index, "indexName", &pattern_clause())) + } + _ => Ok(()), + } + } + _ => check_name(input.get("TableName"), "tableName", operation), + } +} + +fn with_str( + input: &Value, + field: &str, + check: impl Fn(&str) -> Result<(), DynamoDbError>, +) -> Result<(), DynamoDbError> { + match input.get(field).and_then(Value::as_str) { + Some(s) if !s.is_empty() => check(s), + _ => Ok(()), + } +} + +fn pattern_clause() -> String { + format!("Member must satisfy regular expression pattern: {PATTERN}") +} + +fn one_error(value: &str, location: &str, constraint: &str) -> DynamoDbError { + DynamoDbError::ValidationException(format!( + "1 validation error detected: Value '{value}' at '{location}' failed to satisfy \ + constraint: {constraint}" + )) +} + +/// A table name field: a bare name outside the pattern is refused with the +/// pattern clause (and the length clause when that fails too); an `arn:` value +/// goes through the ARN rules. +fn check_name(value: Option<&Value>, location: &str, operation: &str) -> Result<(), DynamoDbError> { + let Some(name) = value.and_then(Value::as_str) else { + return Ok(()); + }; + if name.starts_with("arn:") { + return arn_gate::check_table_name_arn(operation, name); + } + if name.is_empty() || in_pattern(name) { + return Ok(()); + } + Err(bare_name_error(name, location)) +} + +/// A member the service reads as a name (see the module docs): a value +/// outside the pattern is refused as a bare name is, unless it starts with +/// `arn:` and carries no U+0000, which is passed on. +fn check_name_member(value: Option<&Value>, location: &str) -> Result<(), DynamoDbError> { + let Some(name) = value.and_then(Value::as_str) else { + return Ok(()); + }; + if name.is_empty() || in_pattern(name) { + return Ok(()); + } + if name.starts_with("arn:") && !name.contains('\u{0}') { + return Ok(()); + } + Err(bare_name_error(name, location)) +} + +/// The service lists every failed constraint of a member in one message, the +/// length clause before the pattern clause. +fn bare_name_error(name: &str, location: &str) -> DynamoDbError { + let length = name.chars().count(); + let mut clauses: Vec = Vec::with_capacity(2); + if length < 3 { + clauses.push("Member must have length greater than or equal to 3".to_owned()); + } else if length > 255 { + clauses.push("Member must have length less than or equal to 255".to_owned()); + } + clauses.push(pattern_clause()); + let n = clauses.len(); + DynamoDbError::ValidationException(format!( + "{n} validation error{} detected: {}", + if n == 1 { "" } else { "s" }, + clauses + .iter() + .map(|c| format!("Value '{name}' at '{location}' failed to satisfy constraint: {c}")) + .collect::>() + .join("; ") + )) +} + +/// A `RequestItems` key outside the pattern, or an ARN key that the ARN rules +/// refuse. The sentence from `at 'requestItems'` onward is the service's; the +/// offending key alone is rendered where the service renders the whole map. +fn check_request_items_keys(input: &Value) -> Result<(), DynamoDbError> { + let Some(map) = input.get("RequestItems").and_then(Value::as_object) else { + return Ok(()); + }; + for key in map.keys() { + if key.is_empty() { + continue; + } + if key.starts_with("arn:") { + if arn_gate::table_arn_fault(key).is_some() { + return Err(one_error(key, "requestItems", arn_gate::MAP_KEYS_ARN)); + } + } else if !in_pattern(key) { + return Err(one_error(key, "requestItems", MAP_KEYS_NAME)); + } + } + Ok(()) +} + +/// A `TransactItems` member whose sub-operation names a table outside the +/// pattern, or by an ARN that the ARN rules refuse. +fn check_transact_items(input: &Value, subs: &[&str]) -> Result<(), DynamoDbError> { + let Some(items) = input.get("TransactItems").and_then(Value::as_array) else { + return Ok(()); + }; + for (i, item) in items.iter().enumerate() { + for sub in subs { + let Some(name) = item + .get(sub) + .and_then(|o| o.get("TableName")) + .and_then(Value::as_str) + else { + continue; + }; + if name.is_empty() { + continue; + } + let location = format!( + "transactItems.{}.member.{}.tableName", + i + 1, + lower_camel(sub) + ); + if name.starts_with("arn:") { + arn_gate::check_transact_member_arn(name, &location)?; + } else if !in_pattern(name) { + return Err(bare_name_error(name, &location)); + } + } + } + Ok(()) +} + +fn lower_camel(s: &str) -> String { + let mut c = s.chars(); + match c.next() { + Some(f) => f.to_ascii_lowercase().to_string() + c.as_str(), + None => String::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + const NUL: &str = "t367\u{0}name"; + const ARN: &str = "arn:aws:dynamodb:us-east-1:123456789012:table/t367\u{0}name"; + const PAT: &str = "Member must satisfy regular expression pattern: [a-zA-Z0-9_.-]+"; + + fn message(operation: &str, input: Value) -> Option { + match check_table_name_chars(operation, &input) { + Ok(()) => None, + Err(DynamoDbError::ValidationException(m)) => Some(m), + Err(other) => panic!("unexpected error {other:?}"), + } + } + + fn one(value: &str, location: &str, constraint: &str) -> String { + format!( + "1 validation error detected: Value '{value}' at '{location}' failed to satisfy \ + constraint: {constraint}" + ) + } + + #[test] + fn nul_in_table_name_is_refused_with_the_pattern_message() { + let m = message("DescribeTable", json!({ "TableName": NUL })).expect("refused"); + assert_eq!(m, one(NUL, "tableName", PAT)); + } + + #[test] + fn other_characters_outside_the_pattern_are_refused_and_plain_names_pass() { + for name in [ + "a b", + "tab\tle", + "t/name", + "t:name", + "caf\u{e9}", + "\u{0}t367name", + ] { + assert!( + message("PutItem", json!({ "TableName": name })).is_some(), + "{name:?}" + ); + } + for name in ["abc", "Table_1.a-b", &"x".repeat(300), "ab"] { + assert!( + message("Query", json!({ "TableName": name })).is_none(), + "{name:?}" + ); + } + for input in [ + json!({}), + json!({ "TableName": "" }), + json!({ "TableName": null }), + json!({ "TableName": 7 }), + json!({ "TableName": "arn:aws:dynamodb:us-east-1:123456789012:table/t" }), + ] { + assert!(message("GetItem", input.clone()).is_none(), "{input}"); + } + } + + #[test] + fn a_name_failing_two_constraints_lists_the_length_clause_first() { + let m = message("DescribeTable", json!({ "TableName": "\u{0}" })).expect("refused"); + assert_eq!( + m, + format!( + "2 validation errors detected: Value '\u{0}' at 'tableName' failed to satisfy \ + constraint: Member must have length greater than or equal to 3; \ + Value '\u{0}' at 'tableName' failed to satisfy constraint: {PAT}" + ) + ); + let long = format!("{}\u{0}", "x".repeat(300)); + let m = message("CreateTable", json!({ "TableName": long })).expect("refused"); + assert!(m.starts_with("2 validation errors detected: "), "{m}"); + assert!( + m.contains("Member must have length less than or equal to 255; "), + "{m}" + ); + } + + #[test] + fn the_other_name_fields_report_their_own_location() { + let cases = [ + ( + "RestoreTableFromBackup", + json!({ "TargetTableName": NUL }), + "targetTableName", + ), + ( + "RestoreTableToPointInTime", + json!({ "SourceTableName": NUL }), + "sourceTableName", + ), + ( + "RestoreTableToPointInTime", + json!({ "TargetTableName": NUL }), + "targetTableName", + ), + ( + "ImportTable", + json!({ "TableCreationParameters": { "TableName": NUL } }), + "tableCreationParameters.tableName", + ), + ( + "ListTables", + json!({ "ExclusiveStartTableName": NUL }), + "exclusiveStartTableName", + ), + ("ListBackups", json!({ "TableName": NUL }), "tableName"), + ("UpdateTimeToLive", json!({ "TableName": NUL }), "tableName"), + ]; + for (op, input, location) in cases { + assert_eq!( + message(op, input).as_deref(), + Some(one(NUL, location, PAT).as_str()), + "{op}" + ); + } + assert_eq!( + message( + "Query", + json!({ "TableName": "ok", "IndexName": "g\u{0}2" }) + ) + .unwrap(), + one("g\u{0}2", "indexName", PAT) + ); + assert!(message("Scan", json!({ "TableName": "ok", "IndexName": "gsi1" })).is_none()); + assert_eq!( + message("ListStreams", json!({ "TableName": NUL })).unwrap(), + "TableName must be at least 3 characters long and at most 255 characters long" + ); + } + + #[test] + fn members_read_as_names_pass_an_arn_on_unless_it_carries_nul() { + let good = "arn:aws:dynamodb:us-east-1:123456789012:table/t367name"; + let index = format!("{good}/index/ix"); + let cases = [ + ( + "RestoreTableFromBackup", + "TargetTableName", + "targetTableName", + ), + ( + "RestoreTableToPointInTime", + "SourceTableName", + "sourceTableName", + ), + ( + "RestoreTableToPointInTime", + "TargetTableName", + "targetTableName", + ), + ( + "ListTables", + "ExclusiveStartTableName", + "exclusiveStartTableName", + ), + ]; + for (op, member, location) in cases { + for passed in [good, index.as_str(), "arn:aws:s3:::bucket"] { + assert!( + message(op, json!({ member: passed })).is_none(), + "{op} {passed}" + ); + } + assert_eq!( + message(op, json!({ member: ARN })).as_deref(), + Some(one(ARN, location, PAT).as_str()), + "{op}" + ); + } + let import = |name: &str| json!({ "TableCreationParameters": { "TableName": name } }); + assert!(message("ImportTable", import(good)).is_none()); + assert_eq!( + message("ImportTable", import(ARN)).unwrap(), + one(ARN, "tableCreationParameters.tableName", PAT) + ); + } + + #[test] + fn list_streams_refuses_an_arn_with_its_own_sentence() { + for name in [ + "arn:aws:dynamodb:us-east-1:123456789012:table/t367name", + ARN, + ] { + assert_eq!( + message("ListStreams", json!({ "TableName": name })).unwrap(), + LIST_STREAMS_NAME, + "{name}" + ); + } + assert!(message("ListStreams", json!({ "TableName": "t367name" })).is_none()); + assert!(message("ListStreams", json!({})).is_none()); + } + + #[test] + fn arn_bearing_fields_are_checked_before_authorization() { + let m = message("DescribeTable", json!({ "TableName": ARN })).expect("refused"); + assert!(m.contains("Table name of ARN must satisfy"), "{m}"); + assert_eq!( + message( + "RestoreTableToPointInTime", + json!({ "SourceTableArn": ARN }) + ) + .unwrap(), + "Invalid Request: Invalid TableArn: Invalid Table ARN" + ); + assert_eq!( + message("ExportTableToPointInTime", json!({ "TableArn": ARN })).unwrap(), + "Invalid TableArn: Invalid Table ARN" + ); + let stream = format!("{ARN}/stream/2026-10-24T00:00:00.000"); + for op in ["DescribeStream", "GetShardIterator"] { + assert_eq!( + message(op, json!({ "StreamArn": stream })).unwrap(), + "Invalid StreamArn" + ); + } + let backup = format!("{ARN}/backup/01700000000000-abcdef12"); + for op in ["DescribeBackup", "DeleteBackup", "RestoreTableFromBackup"] { + assert_eq!( + message(op, json!({ "BackupArn": backup })).unwrap(), + "Invalid Backup ARN" + ); + } + for op in ["TagResource", "UntagResource", "ListTagsOfResource"] { + assert_eq!( + message(op, json!({ "ResourceArn": ARN })).unwrap(), + format!("Invalid TableArn: Invalid ResourceArn provided as input {ARN}") + ); + assert_eq!( + message(op, json!({ "ResourceArn": stream })).unwrap(), + "Invalid StreamArn" + ); + assert!(message(op, json!({ "ResourceArn": format!("{ARN}/index/i") })).is_none()); + assert!(message(op, json!({})).is_none()); + } + } + + #[test] + fn batch_request_items_keys_are_refused_at_request_items() { + for op in ["BatchWriteItem", "BatchGetItem"] { + let m = message(op, json!({ "RequestItems": { "ok": {}, NUL: {} } })).expect("refused"); + assert_eq!(m, one(NUL, "requestItems", MAP_KEYS_NAME)); + let m = message(op, json!({ "RequestItems": { ARN: {} } })).expect("refused"); + assert_eq!(m, one(ARN, "requestItems", arn_gate::MAP_KEYS_ARN)); + assert!(message(op, json!({ "RequestItems": { "ok": {} } })).is_none()); + assert!(message(op, json!({ "RequestItems": [] })).is_none()); + } + } + + #[test] + fn transact_members_are_refused_with_their_member_index_and_operation() { + let m = message( + "TransactWriteItems", + json!({ "TransactItems": [ + { "Put": { "TableName": "ok", "Item": {} } }, + { "Delete": { "TableName": NUL, "Key": {} } }, + ] }), + ) + .expect("refused"); + assert_eq!(m, one(NUL, "transactItems.2.member.delete.tableName", PAT)); + for (sub, member) in [("Update", "update"), ("ConditionCheck", "conditionCheck")] { + let m = message( + "TransactWriteItems", + json!({ "TransactItems": [ { sub: { "TableName": NUL } } ] }), + ) + .expect("refused"); + assert_eq!( + m, + one( + NUL, + &format!("transactItems.1.member.{member}.tableName"), + PAT + ) + ); + } + let m = message( + "TransactGetItems", + json!({ "TransactItems": [ { "Get": { "TableName": NUL, "Key": {} } } ] }), + ) + .expect("refused"); + assert_eq!(m, one(NUL, "transactItems.1.member.get.tableName", PAT)); + let m = message( + "TransactWriteItems", + json!({ "TransactItems": [ { "Put": { "TableName": ARN, "Item": {} } } ] }), + ) + .expect("refused"); + assert_eq!( + m, + one( + ARN, + "transactItems.1.member.put.tableName", + "Table name of ARN must satisfy regular expression pattern: [a-zA-Z0-9_.-]+" + ) + ); + assert!( + message( + "TransactWriteItems", + json!({ "TransactItems": [ { "Delete": { "TableName": "ok" } } ] }) + ) + .is_none() + ); + } +} diff --git a/tests/python/conftest.py b/tests/python/conftest.py index 956ea3f53..765294ea5 100755 --- a/tests/python/conftest.py +++ b/tests/python/conftest.py @@ -27,7 +27,13 @@ import pytest import urllib3 -from helpers import make_dynamodb_client, unique_name, wait_for_active, wait_for_deleted # noqa: F401 +from helpers import ( # noqa: F401 + make_dynamodb_client, + make_raw_client, + unique_name, + wait_for_active, + wait_for_deleted, +) # Suppress InsecureRequestWarning for self-signed TLS certs. urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) @@ -92,6 +98,19 @@ def dynamodb_resource(endpoint_url: str | None): return boto3.resource(**kwargs) +@pytest.fixture(scope="session") +def raw_client(endpoint_url: str | None): + """Session-scoped client with SDK parameter validation off; see + `helpers.make_raw_client`.""" + return make_raw_client(endpoint_url) + + +@pytest.fixture(scope="session") +def raw_streams_client(endpoint_url: str | None): + """The streams API counterpart of `raw_client`.""" + return make_raw_client(endpoint_url, "dynamodbstreams") + + @pytest.fixture() def table_factory(dynamodb_client): """Factory fixture: creates tables and cleans them up after the test. diff --git a/tests/python/helpers.py b/tests/python/helpers.py index e8dfd66e5..ec3379a6c 100755 --- a/tests/python/helpers.py +++ b/tests/python/helpers.py @@ -83,3 +83,46 @@ def describe_stream_not_found_message(stream_arn: str) -> str: """What DescribeStream answers for a stream ARN that names no stream of the caller's account (measured 2026-10-24): the ARN, and no trailing period.""" return f"Requested resource not found: Stream: {stream_arn} not found" + + +def make_raw_client(endpoint_url: str | None, service_name: str = "dynamodb"): + """A boto3 client with SDK parameter validation off and retries off, so the + service, not the SDK, decides what a request is answered with. Used by the + tests that send names and ARNs the SDK's model would refuse client-side.""" + kwargs: dict = { + "service_name": service_name, + "region_name": os.environ.get("AWS_DEFAULT_REGION", "us-east-1"), + "config": botocore.config.Config( + parameter_validation=False, retries={"max_attempts": 0} + ), + } + if endpoint_url: + kwargs["endpoint_url"] = endpoint_url + if endpoint_url.startswith("https://"): + ca_cert = os.environ.get("EXTENDDB_CA_CERT", "") + kwargs["verify"] = ca_cert if ca_cert else False + return boto3.client(**kwargs) + + +MEMBER_PATTERN = "Member must satisfy regular expression pattern: [a-zA-Z0-9_.-]+" + + +def assert_validation(exc, message: str) -> None: + """A 400 ValidationException with exactly this message.""" + resp = exc.response + assert resp["ResponseMetadata"]["HTTPStatusCode"] == 400, resp + assert resp["Error"]["Code"] == "ValidationException", resp["Error"] + assert resp["Error"]["Message"] == message, resp["Error"]["Message"] + + +def assert_member_pattern(exc, location: str, value: str) -> None: + """The service's one-constraint member pattern sentence, byte for byte. + + The offending value is rendered inside ``Value '...'`` with a U+0000 as the + raw character (the JSON body carries ``\\u0000``, which the SDK decodes). + """ + assert_validation( + exc, + f"1 validation error detected: Value '{value}' at '{location}' " + f"failed to satisfy constraint: {MEMBER_PATTERN}", + ) diff --git a/tests/python/test_nul_arn_shapes.py b/tests/python/test_nul_arn_shapes.py new file mode 100644 index 000000000..a2f04bd70 --- /dev/null +++ b/tests/python/test_nul_arn_shapes.py @@ -0,0 +1,168 @@ +# Copyright 2026 ExtendDB contributors +# SPDX-License-Identifier: Apache-2.0 + +"""ARN-shaped values with U+0000 in `TableName`, in nested table names, and in +the stream, backup and tag ARN fields. + +Measured against the service on 2026-10-24 with SDK parameter validation off; +every row passed against the service again on 2026-11-04 (us-east-1). +A plain table ARN whose table segment fails the pattern gets the `Table name +of ARN` sentence; an ARN that is not a plain table ARN gets the `Valid ARN +format is ...` frame, with the constraint replaced by `Invalid subscriber ID` +or `Invalid AWS region` when that segment is the bad one. The ARNs below are +built from a table the test creates, so the account and region are the +caller's on either target. +""" + +import pytest +from botocore.exceptions import ClientError + +from helpers import assert_validation, unique_name, wait_for_active, wait_for_deleted + +NUL = "\u0000" +FRAME = ( + "Valid ARN format is " + "'arn:::::resourceType/resourceName', " + "where Resource name must have length less than or equal to 255, " + "Resource name must have length greater than or equal to 3, " + "Resource name must satisfy regular expression pattern: [a-zA-Z0-9_.-]+" +) +TABLE_OF_ARN = "Table name of ARN must satisfy regular expression pattern: [a-zA-Z0-9_.-]+" +MAP_KEYS_ARN = ( + "Map keys must satisfy constraint: [Valid resource name or resource arn must be given. " + "Valid resource arn format is " + "'arn:::::resourceType/resourceName', " + "where Resource name must have length less than or equal to 255, " + "Resource name must have length greater than or equal to 3, " + "Resource name must satisfy regular expression pattern: [a-zA-Z0-9_.-]+]" +) + + +def _at_table_name(exc: ClientError, value: str, constraint: str) -> None: + assert_validation( + exc, + f"1 validation error detected: Value '{value}' at 'tableName' failed to satisfy " + f"constraint: {constraint}", + ) + + +@pytest.fixture() +def arns(raw_client): + """The created table's name and the parts of its ARN.""" + name = unique_name("nula") + raw_client.create_table( + TableName=name, + BillingMode="PAY_PER_REQUEST", + KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": "pk", "AttributeType": "S"}], + ) + wait_for_active(raw_client, name) + arn = raw_client.describe_table(TableName=name)["Table"]["TableArn"] + # arn:aws:dynamodb:::table/ + _, partition, service, region, account, _ = arn.split(":", 5) + yield { + "name": name, + "prefix": f"arn:{partition}:{service}:{region}:{account}:table/", + "region": region, + "account": account, + "bad_table": f"arn:{partition}:{service}:{region}:{account}:table/t367{NUL}name", + } + try: + raw_client.delete_table(TableName=name) + wait_for_deleted(raw_client, name) + except Exception: + pass + + +class TestArnInTableName: + def test_plain_table_arn_with_nul_segment(self, raw_client, arns): + bad = arns["bad_table"] + for call in [ + lambda: raw_client.describe_table(TableName=bad), + lambda: raw_client.put_item(TableName=bad, Item={"pk": {"S": "a"}}), + ]: + with pytest.raises(ClientError) as exc: + call() + _at_table_name(exc.value, bad, TABLE_OF_ARN) + + def test_foreign_service_arn_on_item_operations_gets_the_frame(self, raw_client): + arn = f"arn:aws:s3:::t367{NUL}name" + with pytest.raises(ClientError) as exc: + raw_client.put_item(TableName=arn, Item={"pk": {"S": "a"}}) + _at_table_name(exc.value, arn, FRAME) + with pytest.raises(ClientError) as exc: + raw_client.get_item(TableName=arn, Key={"pk": {"S": "a"}}) + _at_table_name(exc.value, arn, FRAME) + # DescribeTable answers AccessDeniedException on the service (measured + # 2026-10-24) and the engine's own ValidationException on ExtendDB; + # neither is a 500, and the row is not asserted beyond that. + with pytest.raises(ClientError) as exc: + raw_client.describe_table(TableName=arn) + assert exc.value.response["ResponseMetadata"]["HTTPStatusCode"] == 400 + + def test_bad_account_and_region_segments_name_their_constraint(self, raw_client, arns): + acct = arns["account"] + region = arns["region"] + arn = f"arn:aws:dynamodb:{region}:{acct[:4]}{NUL}{acct[4:]}:table/t367name" + with pytest.raises(ClientError) as exc: + raw_client.put_item(TableName=arn, Item={"pk": {"S": "a"}}) + _at_table_name(exc.value, arn, "Invalid subscriber ID") + arn = f"arn:aws:dynamodb:{region[:5]}{NUL}{region[5:]}:{acct}:table/t367name" + with pytest.raises(ClientError) as exc: + raw_client.put_item(TableName=arn, Item={"pk": {"S": "a"}}) + _at_table_name(exc.value, arn, "Invalid AWS region") + + def test_index_and_stream_arns_get_the_frame(self, raw_client, arns): + index = f"{arns['prefix']}{arns['name']}/index/i{NUL}x" + with pytest.raises(ClientError) as exc: + raw_client.query( + TableName=index, + KeyConditionExpression="pk = :v", + ExpressionAttributeValues={":v": {"S": "a"}}, + ) + _at_table_name(exc.value, index, FRAME) + for arn in [ + f"{arns['bad_table']}/index/ix", + f"{arns['bad_table']}/stream/2026-10-24T00:00:00.000", + ]: + with pytest.raises(ClientError) as exc: + raw_client.describe_table(TableName=arn) + _at_table_name(exc.value, arn, FRAME) + + +class TestNestedArns: + def test_batch_request_items_key_as_arn_with_nul_segment(self, raw_client, arns): + bad = arns["bad_table"] + for call in [ + lambda: raw_client.batch_write_item( + RequestItems={bad: [{"PutRequest": {"Item": {"pk": {"S": "a"}}}}]} + ), + lambda: raw_client.batch_get_item(RequestItems={bad: {"Keys": [{"pk": {"S": "a"}}]}}), + ]: + with pytest.raises(ClientError) as exc: + call() + resp = exc.value.response + assert resp["ResponseMetadata"]["HTTPStatusCode"] == 400 + assert resp["Error"]["Code"] == "ValidationException" + msg = resp["Error"]["Message"] + # The service renders its own dump of the whole map inside + # Value '...'; the sentence from `at 'requestItems'` onward is + # compared byte for byte and the key must appear in the value. + assert msg.startswith("1 validation error detected: Value '"), msg + assert msg.endswith( + f"' at 'requestItems' failed to satisfy constraint: {MAP_KEYS_ARN}" + ), msg + value = msg[len("1 validation error detected: Value '") : msg.rindex("' at 'requestItems'")] + assert bad.replace(NUL, "") in value.replace(NUL, "").replace("\\u0000", ""), msg + + def test_transact_write_items_put_table_name_as_arn(self, raw_client, arns): + bad = arns["bad_table"] + with pytest.raises(ClientError) as exc: + raw_client.transact_write_items( + TransactItems=[{"Put": {"TableName": bad, "Item": {"pk": {"S": "a"}}}}] + ) + assert_validation( + exc.value, + f"1 validation error detected: Value '{bad}' at " + f"'transactItems.1.member.put.tableName' failed to satisfy constraint: {TABLE_OF_ARN}", + ) diff --git a/tests/python/test_nul_table_name_fields.py b/tests/python/test_nul_table_name_fields.py new file mode 100644 index 000000000..87e972ba6 --- /dev/null +++ b/tests/python/test_nul_table_name_fields.py @@ -0,0 +1,270 @@ +# Copyright 2026 ExtendDB contributors +# SPDX-License-Identifier: Apache-2.0 + +"""Table names outside the pattern in every field that carries one, beyond +`TableName` (covered by test_nul_table_names.py), and the stream, backup +and tag ARN fields with U+0000 in a segment. + +Measured against the service on 2026-10-24 with SDK parameter validation off. +Every row passed against the service again on 2026-11-04 (us-east-1). Every +operation here looks its table up before its handler runs, so the name has to +be refused before any lookup; on PostgreSQL a U+0000 cannot even be bound as a +parameter. These rows pin the service's sentence and location for each field. +""" + +import time + +import pytest +from botocore.exceptions import ClientError + +from helpers import ( + MEMBER_PATTERN, + assert_member_pattern, + assert_validation, + unique_name, + wait_for_active, + wait_for_deleted, +) + +NUL = "\u0000" +BAD = f"t367{NUL}name" + + +@pytest.fixture() +def nul_table(raw_client): + """A plain table with a GSI, and its ARN prefix up to `table/`.""" + name = unique_name("nulf") + raw_client.create_table( + TableName=name, + BillingMode="PAY_PER_REQUEST", + KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}], + AttributeDefinitions=[ + {"AttributeName": "pk", "AttributeType": "S"}, + {"AttributeName": "gk", "AttributeType": "S"}, + ], + GlobalSecondaryIndexes=[ + { + "IndexName": "gsi1", + "KeySchema": [{"AttributeName": "gk", "KeyType": "HASH"}], + "Projection": {"ProjectionType": "ALL"}, + } + ], + ) + wait_for_active(raw_client, name) + arn = raw_client.describe_table(TableName=name)["Table"]["TableArn"] + yield name, arn[: arn.rindex("table/") + len("table/")] + try: + raw_client.delete_table(TableName=name) + wait_for_deleted(raw_client, name) + except Exception: + pass + + +class TestTableNameFieldsBeyondTableName: + def test_restore_table_from_backup_target_table_name(self, raw_client, nul_table): + name, _ = nul_table + # The service refuses CreateBackup for a short while after CreateTable + # (ContinuousBackupsUnavailableException); retry as the backup tests do. + deadline = time.monotonic() + 120 + while True: + try: + arn = raw_client.create_backup(TableName=name, BackupName="bk367")["BackupDetails"]["BackupArn"] + break + except ClientError as e: + if e.response["Error"]["Code"] != "ContinuousBackupsUnavailableException" or time.monotonic() >= deadline: + raise + time.sleep(2) + try: + for bad in [BAD, "a b"]: + with pytest.raises(ClientError) as exc: + raw_client.restore_table_from_backup(BackupArn=arn, TargetTableName=bad) + assert_member_pattern(exc.value, "targetTableName", bad) + finally: + try: + raw_client.delete_backup(BackupArn=arn) + except Exception: + pass + + def test_restore_table_to_point_in_time_names(self, raw_client, nul_table): + name, _ = nul_table + with pytest.raises(ClientError) as exc: + raw_client.restore_table_to_point_in_time( + SourceTableName=BAD, TargetTableName=unique_name("tgt"), UseLatestRestorableTime=True + ) + assert_member_pattern(exc.value, "sourceTableName", BAD) + with pytest.raises(ClientError) as exc: + raw_client.restore_table_to_point_in_time( + SourceTableName=name, TargetTableName=BAD, UseLatestRestorableTime=True + ) + assert_member_pattern(exc.value, "targetTableName", BAD) + + def test_import_table_creation_parameters_table_name(self, raw_client): + with pytest.raises(ClientError) as exc: + raw_client.import_table( + S3BucketSource={"S3Bucket": "bucket"}, + InputFormat="DYNAMODB_JSON", + TableCreationParameters={ + "TableName": BAD, + "KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}], + "AttributeDefinitions": [{"AttributeName": "pk", "AttributeType": "S"}], + "BillingMode": "PAY_PER_REQUEST", + }, + ) + assert_member_pattern(exc.value, "tableCreationParameters.tableName", BAD) + + def test_list_tables_exclusive_start_table_name(self, raw_client): + with pytest.raises(ClientError) as exc: + raw_client.list_tables(ExclusiveStartTableName=BAD) + assert_member_pattern(exc.value, "exclusiveStartTableName", BAD) + + @pytest.mark.parametrize( + "call", + [ + pytest.param(lambda c, t: c.list_backups(TableName=t), id="ListBackups"), + pytest.param( + lambda c, t: c.create_backup(TableName=t, BackupName="bk367"), id="CreateBackup" + ), + pytest.param(lambda c, t: c.describe_time_to_live(TableName=t), id="DescribeTimeToLive"), + pytest.param( + lambda c, t: c.update_time_to_live( + TableName=t, TimeToLiveSpecification={"Enabled": True, "AttributeName": "ttl"} + ), + id="UpdateTimeToLive", + ), + pytest.param( + lambda c, t: c.describe_continuous_backups(TableName=t), + id="DescribeContinuousBackups", + ), + pytest.param( + lambda c, t: c.update_continuous_backups( + TableName=t, + PointInTimeRecoverySpecification={"PointInTimeRecoveryEnabled": True}, + ), + id="UpdateContinuousBackups", + ), + ], + ) + def test_other_operations_with_a_bare_table_name(self, raw_client, call): + with pytest.raises(ClientError) as exc: + call(raw_client, BAD) + assert_member_pattern(exc.value, "tableName", BAD) + + def test_list_streams_has_its_own_sentence(self, raw_streams_client): + with pytest.raises(ClientError) as exc: + raw_streams_client.list_streams(TableName=BAD) + assert_validation( + exc.value, + "TableName must be at least 3 characters long and at most 255 characters long", + ) + + +class TestSeveralConstraintsAtOnce: + """A name that fails the pattern and a length bound lists both clauses, + the length first, under `2 validation errors detected:`.""" + + @pytest.mark.parametrize( + ("name", "length_clause"), + [ + pytest.param(NUL, "Member must have length greater than or equal to 3", id="nul-alone"), + pytest.param(f"a{NUL}", "Member must have length greater than or equal to 3", id="two"), + pytest.param( + "x" * 300 + NUL, "Member must have length less than or equal to 255", id="301" + ), + ], + ) + def test_describe_table(self, raw_client, name, length_clause): + with pytest.raises(ClientError) as exc: + raw_client.describe_table(TableName=name) + assert_validation( + exc.value, + f"2 validation errors detected: Value '{name}' at 'tableName' failed to satisfy " + f"constraint: {length_clause}; Value '{name}' at 'tableName' failed to satisfy " + f"constraint: {MEMBER_PATTERN}", + ) + + +class TestIndexName: + def test_query_and_scan_with_nul_index_name(self, raw_client, nul_table): + name, _ = nul_table + bad = f"g{NUL}2" + with pytest.raises(ClientError) as exc: + raw_client.query( + TableName=name, + IndexName=bad, + KeyConditionExpression="gk = :v", + ExpressionAttributeValues={":v": {"S": "a"}}, + ) + assert_member_pattern(exc.value, "indexName", bad) + with pytest.raises(ClientError) as exc: + raw_client.scan(TableName=name, IndexName=bad) + assert_member_pattern(exc.value, "indexName", bad) + + def test_update_table_create_and_delete_with_nul_index_name(self, raw_client, nul_table): + name, _ = nul_table + bad = f"g{NUL}2" + with pytest.raises(ClientError) as exc: + raw_client.update_table( + TableName=name, + AttributeDefinitions=[{"AttributeName": "gk", "AttributeType": "S"}], + GlobalSecondaryIndexUpdates=[ + { + "Create": { + "IndexName": bad, + "KeySchema": [{"AttributeName": "gk", "KeyType": "HASH"}], + "Projection": {"ProjectionType": "ALL"}, + } + } + ], + ) + assert_member_pattern( + exc.value, "globalSecondaryIndexUpdates.1.member.create.indexName", bad + ) + with pytest.raises(ClientError) as exc: + raw_client.update_table( + TableName=name, GlobalSecondaryIndexUpdates=[{"Delete": {"IndexName": bad}}] + ) + assert_member_pattern( + exc.value, "globalSecondaryIndexUpdates.1.member.delete.indexName", bad + ) + + +class TestStreamBackupAndTagArns: + def test_stream_arns_with_nul(self, raw_client, raw_streams_client, nul_table): + name, prefix = nul_table + bad = f"{prefix}t367{NUL}name" + table_seg = f"{bad}/stream/2026-10-24T00:00:00.000" + # Measured 2026-10-24: a U+0000 inside the label answers Invalid StreamArn; + # a U+0000 after a complete label reads as a label and answers + # ResourceNotFoundException, so the character goes inside. + label = f"{prefix}{name}/stream/2026-10-24T00:00{NUL}:00.000" + for arn in [table_seg, label]: + with pytest.raises(ClientError) as exc: + raw_streams_client.describe_stream(StreamArn=arn) + assert_validation(exc.value, "Invalid StreamArn") + with pytest.raises(ClientError) as exc: + raw_streams_client.get_shard_iterator( + StreamArn=table_seg, ShardId="shardId-00000001", ShardIteratorType="TRIM_HORIZON" + ) + assert_validation(exc.value, "Invalid StreamArn") + with pytest.raises(ClientError) as exc: + raw_client.tag_resource(ResourceArn=table_seg, Tags=[{"Key": "k", "Value": "v"}]) + assert_validation(exc.value, "Invalid StreamArn") + + def test_backup_arns_with_nul(self, raw_client, nul_table): + name, prefix = nul_table + bad = f"{prefix}t367{NUL}name" + table_seg = f"{bad}/backup/01700000000000-abcdef12" + for call in [ + lambda: raw_client.describe_backup(BackupArn=table_seg), + lambda: raw_client.delete_backup(BackupArn=table_seg), + lambda: raw_client.restore_table_from_backup( + BackupArn=table_seg, TargetTableName=unique_name("tgt") + ), + ]: + with pytest.raises(ClientError) as exc: + call() + assert_validation(exc.value, "Invalid Backup ARN") + backup_id = f"{prefix}{name}/backup/0170000000{NUL}0000-abcdef12" + with pytest.raises(ClientError) as exc: + raw_client.describe_backup(BackupArn=backup_id) + assert_validation(exc.value, "Invalid Request: BackupArn is not valid") diff --git a/tests/python/test_nul_table_names.py b/tests/python/test_nul_table_names.py new file mode 100644 index 000000000..4837c6a3f --- /dev/null +++ b/tests/python/test_nul_table_names.py @@ -0,0 +1,272 @@ +# Copyright 2026 ExtendDB contributors +# SPDX-License-Identifier: Apache-2.0 + +"""U+0000 in index names and table names. + +DynamoDB refuses U+0000 inside an index name or a table name with the member +pattern ValidationException. Measured against the service with SDK parameter +validation off, so the service, not the SDK, decided: the index name row on +2026-09-18, and every other row on 2026-10-24 (the CreateTable table name row +on both dates). Every row passed against the service again on 2026-11-04 +(us-east-1). + +The table-name row is checked on every operation that carries a bare +TableName, since the name is used before any handler runs. + +Order against authorization (measured 2026-10-24, not encoded as a test): a +principal with no DynamoDB permission gets the same tableName pattern +ValidationException for DescribeTable, PutItem and Scan with a U+0000 name +(and the requestItems sentence for BatchWriteItem), and AccessDeniedException +for DescribeTable on a real table. The pattern check precedes the IAM decision. +This suite has no denied-principal fixture against the service, so the row is +recorded here as a comment; the server places the check before authorization +(crates/server/src/table_name_gate.rs). +""" + +import pytest +from botocore.exceptions import ClientError + +from helpers import assert_member_pattern, unique_name, wait_for_active, wait_for_deleted + +NUL = "\u0000" +PATTERN = "Member must satisfy regular expression pattern: [a-zA-Z0-9_.-]+" +MAP_KEYS = ( + "Map keys must satisfy constraint: [Member must have length less than or equal to 255, " + "Member must have length greater than or equal to 3, " + "Member must satisfy regular expression pattern: [a-zA-Z0-9_.-]+]" +) + + +@pytest.fixture() +def raw_table_factory(raw_client): + created: list[str] = [] + + def _create(**kwargs) -> str: + name = kwargs.pop("TableName", None) or unique_name("nul") + raw_client.create_table(TableName=name, BillingMode="PAY_PER_REQUEST", **kwargs) + created.append(name) + wait_for_active(raw_client, name) + return name + + yield _create + + for name in created: + try: + raw_client.delete_table(TableName=name) + wait_for_deleted(raw_client, name) + except Exception: + pass + + +def _validation_error(exc: ClientError, location: str, value: str) -> None: + assert_member_pattern(exc, location, value) + + +class TestNulInNames: + def test_gsi_name_with_nul_is_refused(self, raw_client): + gsi_name = f"gsi{NUL}1" + with pytest.raises(ClientError) as exc: + raw_client.create_table( + TableName=unique_name("nul"), + BillingMode="PAY_PER_REQUEST", + KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}], + AttributeDefinitions=[ + {"AttributeName": "pk", "AttributeType": "S"}, + {"AttributeName": "gk", "AttributeType": "S"}, + ], + GlobalSecondaryIndexes=[ + { + "IndexName": gsi_name, + "KeySchema": [{"AttributeName": "gk", "KeyType": "HASH"}], + "Projection": {"ProjectionType": "ALL"}, + } + ], + ) + resp = exc.value.response + assert resp["ResponseMetadata"]["HTTPStatusCode"] == 400 + assert resp["Error"]["Code"] == "ValidationException" + assert "indexName" in resp["Error"]["Message"] + assert PATTERN in resp["Error"]["Message"] + + def test_create_table_with_nul_in_table_name_is_refused(self, raw_client): + bad = f"nul{NUL}table" + with pytest.raises(ClientError) as exc: + raw_client.create_table( + TableName=bad, + BillingMode="PAY_PER_REQUEST", + KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": "pk", "AttributeType": "S"}], + ) + _validation_error(exc.value, "tableName", bad) + + @pytest.mark.parametrize( + "call", + [ + pytest.param(lambda c, t: c.describe_table(TableName=t), id="DescribeTable"), + pytest.param(lambda c, t: c.delete_table(TableName=t), id="DeleteTable"), + pytest.param( + lambda c, t: c.update_table(TableName=t, BillingMode="PAY_PER_REQUEST"), + id="UpdateTable", + ), + pytest.param( + lambda c, t: c.put_item(TableName=t, Item={"pk": {"S": "a"}}), id="PutItem" + ), + pytest.param( + lambda c, t: c.get_item(TableName=t, Key={"pk": {"S": "a"}}), id="GetItem" + ), + pytest.param( + lambda c, t: c.update_item( + TableName=t, + Key={"pk": {"S": "a"}}, + UpdateExpression="SET v = :v", + ExpressionAttributeValues={":v": {"S": "a"}}, + ), + id="UpdateItem", + ), + pytest.param( + lambda c, t: c.delete_item(TableName=t, Key={"pk": {"S": "a"}}), + id="DeleteItem", + ), + pytest.param( + lambda c, t: c.query( + TableName=t, + KeyConditionExpression="pk = :v", + ExpressionAttributeValues={":v": {"S": "a"}}, + ), + id="Query", + ), + pytest.param(lambda c, t: c.scan(TableName=t), id="Scan"), + ], + ) + def test_operations_with_nul_in_table_name_are_refused(self, raw_client, call): + bad = f"nul{NUL}table" + with pytest.raises(ClientError) as exc: + call(raw_client, bad) + _validation_error(exc.value, "tableName", bad) + + @pytest.mark.parametrize("name", ["a b"], ids=["space"]) + def test_query_and_scan_with_other_bad_characters_are_refused(self, raw_client, name): + """Same sentence for any character outside the pattern (measured with a space).""" + with pytest.raises(ClientError) as exc: + raw_client.scan(TableName=name) + _validation_error(exc.value, "tableName", name) + with pytest.raises(ClientError) as exc: + raw_client.query( + TableName=name, + KeyConditionExpression="pk = :v", + ExpressionAttributeValues={":v": {"S": "a"}}, + ) + _validation_error(exc.value, "tableName", name) + + +class TestNulInNestedTableNames: + """Table names carried inside RequestItems or TransactItems. + + Measured 2026-10-24. For the batch operations the service renders its own + dump of the whole RequestItems map inside ``Value '...'`` (a Java-style + object dump for BatchWriteItem, the JSON of the map for BatchGetItem), so + the value is not compared byte for byte: the sentence from + ``at 'requestItems'`` onward is, and the offending key must appear in the + value (with the U+0000 normalised, since the BatchGetItem dump carries it as + the six characters ``\\u0000``). ExtendDB renders the offending key alone. + """ + + @staticmethod + def _request_items_error(exc: ClientError, key: str) -> None: + resp = exc.response + assert resp["ResponseMetadata"]["HTTPStatusCode"] == 400 + assert resp["Error"]["Code"] == "ValidationException" + msg = resp["Error"]["Message"] + assert msg.startswith("1 validation error detected: Value '"), msg + assert msg.endswith(f"' at 'requestItems' failed to satisfy constraint: {MAP_KEYS}"), msg + value = msg[len("1 validation error detected: Value '") : msg.rindex("' at 'requestItems'")] + assert key.replace(NUL, "") in value.replace(NUL, "").replace("\\u0000", ""), msg + + def test_batch_write_item_with_nul_request_items_key_is_refused(self, raw_client): + bad = f"nul{NUL}table" + with pytest.raises(ClientError) as exc: + raw_client.batch_write_item( + RequestItems={bad: [{"PutRequest": {"Item": {"pk": {"S": "a"}}}}]} + ) + self._request_items_error(exc.value, bad) + + def test_batch_get_item_with_nul_request_items_key_is_refused(self, raw_client): + bad = f"nul{NUL}table" + with pytest.raises(ClientError) as exc: + raw_client.batch_get_item(RequestItems={bad: {"Keys": [{"pk": {"S": "a"}}]}}) + self._request_items_error(exc.value, bad) + + def test_transact_write_items_with_nul_table_name_is_refused(self, raw_client): + bad = f"nul{NUL}table" + with pytest.raises(ClientError) as exc: + raw_client.transact_write_items( + TransactItems=[{"Put": {"TableName": bad, "Item": {"pk": {"S": "a"}}}}] + ) + _validation_error(exc.value, "transactItems.1.member.put.tableName", bad) + + def test_transact_get_items_with_nul_table_name_is_refused(self, raw_client): + bad = f"nul{NUL}table" + with pytest.raises(ClientError) as exc: + raw_client.transact_get_items( + TransactItems=[{"Get": {"TableName": bad, "Key": {"pk": {"S": "a"}}}}] + ) + _validation_error(exc.value, "transactItems.1.member.get.tableName", bad) + + +class TestNulInTableArn: + """A TableName or ResourceArn that is a table ARN whose table segment + contains U+0000 (measured 2026-10-24). The ARN is built in the caller's + own account and region, read from a table the test creates.""" + + @pytest.fixture() + def nul_arn(self, raw_client, raw_table_factory): + name = raw_table_factory( + KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": "pk", "AttributeType": "S"}], + ) + real = raw_client.describe_table(TableName=name)["Table"]["TableArn"] + prefix = real[: real.rindex("table/") + len("table/")] + return f"{prefix}nul{NUL}table" + + @staticmethod + def _arn_table_name_error(exc: ClientError, arn: str) -> None: + resp = exc.response + assert resp["ResponseMetadata"]["HTTPStatusCode"] == 400 + assert resp["Error"]["Code"] == "ValidationException" + assert resp["Error"]["Message"] == ( + f"1 validation error detected: Value '{arn}' at 'tableName' failed to satisfy " + "constraint: Table name of ARN must satisfy regular expression pattern: " + "[a-zA-Z0-9_.-]+" + ) + + def test_describe_table_with_nul_arn_segment_is_refused(self, raw_client, nul_arn): + with pytest.raises(ClientError) as exc: + raw_client.describe_table(TableName=nul_arn) + self._arn_table_name_error(exc.value, nul_arn) + + def test_put_item_with_nul_arn_segment_is_refused(self, raw_client, nul_arn): + with pytest.raises(ClientError) as exc: + raw_client.put_item(TableName=nul_arn, Item={"pk": {"S": "a"}}) + self._arn_table_name_error(exc.value, nul_arn) + + @pytest.mark.parametrize( + "call", + [ + pytest.param( + lambda c, a: c.tag_resource(ResourceArn=a, Tags=[{"Key": "k", "Value": "v"}]), + id="TagResource", + ), + pytest.param(lambda c, a: c.untag_resource(ResourceArn=a, TagKeys=["k"]), id="UntagResource"), + pytest.param(lambda c, a: c.list_tags_of_resource(ResourceArn=a), id="ListTagsOfResource"), + ], + ) + def test_tag_operations_with_nul_arn_segment_are_refused(self, raw_client, nul_arn, call): + """Measured on all three operations on 2026-10-24.""" + with pytest.raises(ClientError) as exc: + call(raw_client, nul_arn) + resp = exc.value.response + assert resp["ResponseMetadata"]["HTTPStatusCode"] == 400 + assert resp["Error"]["Code"] == "ValidationException" + assert resp["Error"]["Message"] == ( + f"Invalid TableArn: Invalid ResourceArn provided as input {nul_arn}" + ) From aea4a54ef11784f7835093d315cf08933d0f4545 Mon Sep 17 00:00:00 2001 From: Lee <29015211+LeeroyHannigan@users.noreply.github.com> Date: Sat, 7 Nov 2026 00:33:37 +0000 Subject: [PATCH 2/3] fix(server): answer ARN account segments that are not account ids An ARN account segment that is not 12 ASCII digits names no account in the catalog, because the management API creates no other. The cross-account check now answers such an account as one that does not exist, without the catalog lookup. extenddb-core gains arn::is_account_id for the check. On PostgreSQL the lookup could not bind an account that carries U+0000. TableArn, SourceTableArn, a tag operation's ResourceArn, StreamArn and BackupArn answered 500 "Internal error during authorization" for such an account. DynamoDB answers each of them with "Access is denied", or with the owner-only ValidationException for RestoreTableToPointInTime. These are its answers for an account that does not exist. The measurements of 2026-11-06 used U+0000, a letter, 11 digits and 13 digits in the account segment. The table name gate answers the account and region faults of a table ARN in TableName as DynamoDB does for each group of operations. The item operations and the transaction members refuse an account segment of 11 or 13 digits with "Invalid subscriber ID". BatchGetItem refuses it at requestItems. The control-plane operations pass a bad account segment on to the cross-account answer. They give a bad region segment as "Invalid AWS region in ''" with no location. These operations are CreateTable, DescribeTable, UpdateTable, DeleteTable, the time to live and continuous backup operations, CreateBackup and ListBackups. BatchWriteItem refuses a key at requestItems for its account segment only when the segment carries U+0000. ListStreams checks ExclusiveStartStreamArn with the stream ARN rules of DescribeStream and GetShardIterator. Storage binds the start ARN's table segment and label, and U+0000 in either answered 500 "Internal server error" on PostgreSQL. DynamoDB answers "Invalid StreamArn". tests/python/test_nul_arn_shapes.py has 24 new rows for these answers, measured against DynamoDB in us-east-1 on 2026-11-06. Part of #367 --- crates/core/src/arn.rs | 22 ++ crates/server/src/arn_gate.rs | 111 +++++++- .../src/authz_resource/cross_account.rs | 14 + crates/server/src/table_name_gate.rs | 108 +++++++- tests/python/test_nul_arn_shapes.py | 253 ++++++++++++++++++ 5 files changed, 493 insertions(+), 15 deletions(-) diff --git a/crates/core/src/arn.rs b/crates/core/src/arn.rs index 408d368cc..ef185c442 100644 --- a/crates/core/src/arn.rs +++ b/crates/core/src/arn.rs @@ -49,6 +49,12 @@ pub struct TableResource { pub sub: Option, } +/// True when `account` is an account id: exactly 12 ASCII digits. +#[must_use] +pub fn is_account_id(account: &str) -> bool { + account.len() == 12 && account.bytes().all(|b| b.is_ascii_digit()) +} + /// Split a string into the five ARN fields after the `arn:` prefix. /// /// The resource field keeps every `:` and `/` it contains, so a stream ARN's @@ -148,6 +154,22 @@ mod tests { assert_eq!(f.resource, "table/T"); } + #[test] + fn an_account_id_is_twelve_ascii_digits() { + assert!(is_account_id("123456789012")); + for account in [ + "", + "12345678901", + "1234567890123", + "12345678901x", + "1234\u{0}", + "12345678901\u{0}", + "\u{661}1234567890", + ] { + assert!(!is_account_id(account), "{account:?}"); + } + } + #[test] fn parses_table_index_stream_backup() { assert_eq!( diff --git a/crates/server/src/arn_gate.rs b/crates/server/src/arn_gate.rs index 2f8d1a7b9..747409359 100644 --- a/crates/server/src/arn_gate.rs +++ b/crates/server/src/arn_gate.rs @@ -11,7 +11,7 @@ //! |---|---| //! | a table ARN whose table segment fails the pattern | `Table name of ARN must satisfy regular expression pattern: ...` | //! | a dynamodb ARN with a bad region segment | `Invalid AWS region` | -//! | a dynamodb ARN with a bad account segment | `Invalid subscriber ID` | +//! | a dynamodb ARN with a bad account segment (a character that is not a digit, or not 12 digits) | `Invalid subscriber ID` | //! | anything else that is not a plain table ARN (another service, an index or stream suffix, too few fields) | the `Valid ARN format is ...` frame | //! //! `DescribeTable` with an ARN of another service is `AccessDeniedException` @@ -19,9 +19,17 @@ //! applied to it there. The frame for a well-formed index or stream ARN in //! `TableName` is derived from the measured index ARN with a bad segment. //! +//! The table above is measured with the item operations. The control-plane +//! operations in [`CONTROL_PLANE`] answer the region and account faults +//! differently (measured 2026-11-06): a bad region segment is `1 validation +//! error detected: Invalid AWS region in ''`, with no location, and a bad +//! account segment gets the answer for an account that does not exist, which +//! `authz_resource::cross_account` gives, so the gate passes it on. +//! //! The other ARN-bearing fields: `SourceTableArn` (`RestoreTableToPointInTime`) //! and `TableArn` (`ExportTableToPointInTime`) with a bad table segment, -//! `StreamArn` (`DescribeStream`, `GetShardIterator`) and a tag operation's +//! `StreamArn` (`DescribeStream`, `GetShardIterator`), `ListStreams`' +//! `ExclusiveStartStreamArn` and a tag operation's //! stream `ResourceArn` with a bad table segment or U+0000 in the label, //! `BackupArn` (`DescribeBackup`, `DeleteBackup`, `RestoreTableFromBackup`) //! with a bad table segment, and a tag operation's table `ResourceArn` with a @@ -32,6 +40,23 @@ use extenddb_core::error::DynamoDbError; pub(crate) const PATTERN: &str = "[a-zA-Z0-9_.-]+"; +/// The operations whose `TableName` region and account faults get the +/// control-plane answers (see the module docs). Measured 2026-11-06 for each, +/// except `DeleteTable`, which was not sent and takes `UpdateTable`'s answers, +/// and the region fault of `CreateBackup`, which was not sent. +pub(crate) const CONTROL_PLANE: &[&str] = &[ + "CreateTable", + "DescribeTable", + "UpdateTable", + "DeleteTable", + "DescribeTimeToLive", + "UpdateTimeToLive", + "DescribeContinuousBackups", + "UpdateContinuousBackups", + "CreateBackup", + "ListBackups", +]; + const FRAME: &str = "Valid ARN format is \ 'arn:::::resourceType/resourceName', \ where Resource name must have length less than or equal to 255, \ @@ -69,7 +94,8 @@ pub(crate) enum ArnFault { TableSegment, /// A dynamodb ARN whose region segment carries a character outside `[a-z0-9-]`. Region, - /// A dynamodb ARN whose account segment carries a character that is not a digit. + /// An account segment that carries a character that is not a digit, or, + /// in a dynamodb ARN, a non-empty one that is not 12 digits. Account, /// Not a plain table ARN of this service: too few fields, another service, /// an index or stream or backup suffix, or a shape `arn::parse` rejects. @@ -89,7 +115,14 @@ pub(crate) fn table_arn_fault(arn: &str) -> Option { { return Some(ArnFault::Region); } - if !f.account.chars().all(|c| c.is_ascii_digit()) { + // 11 and 13 digits are measured (2026-11-06) in a dynamodb ARN only; an + // empty account and another service's ARN keep the digit rule. + let bad_account = if f.service == "dynamodb" && !f.account.is_empty() { + !extenddb_core::arn::is_account_id(f.account) + } else { + !f.account.chars().all(|c| c.is_ascii_digit()) + }; + if bad_account { return Some(ArnFault::Account); } if f.service != "dynamodb" { @@ -128,6 +161,18 @@ pub(crate) fn check_table_name_arn(operation: &str, arn: &str) -> Result<(), Dyn { return Ok(()); } + if CONTROL_PLANE.contains(&operation) { + match fault { + // The cross-account layer answers it without a catalog lookup. + ArnFault::Account => return Ok(()), + ArnFault::Region => { + return Err(validation(format!( + "1 validation error detected: Invalid AWS region in '{arn}'" + ))); + } + ArnFault::TableSegment | ArnFault::NotPlainTable => {} + } + } Err(one_error(arn, "tableName", arn_constraint(fault))) } @@ -160,7 +205,8 @@ pub(crate) fn check_table_arn_field(operation: &str, arn: &str) -> Result<(), Dy /// A stream ARN whose table segment fails the pattern or whose label carries /// U+0000: `Invalid StreamArn` (measured for `DescribeStream`, -/// `GetShardIterator` and `TagResource`). +/// `GetShardIterator` and `TagResource`, and on 2026-11-06 for the +/// `ListStreams` `ExclusiveStartStreamArn`, with and without `TableName`). pub(crate) fn check_stream_arn(arn: &str) -> Result<(), DynamoDbError> { match parse(arn) { Some(res) @@ -248,6 +294,61 @@ mod tests { ); } + #[test] + fn an_account_segment_of_eleven_or_thirteen_digits_is_an_account_fault() { + for account in ["12345678901", "1234567890123", "12345678901x"] { + let arn = format!("arn:aws:dynamodb:us-east-1:{account}:table/t367name"); + for op in ["GetItem", "PutItem", "UpdateItem", "Query", "Scan"] { + assert!( + message(check_table_name_arn(op, &arn)) + .unwrap() + .ends_with("failed to satisfy constraint: Invalid subscriber ID"), + "{op} {arn}" + ); + } + let m = message(check_transact_member_arn( + &arn, + "transactItems.1.member.get.tableName", + )) + .unwrap(); + assert!(m.ends_with("constraint: Invalid subscriber ID"), "{m}"); + } + // Another service's ARN keeps the digit rule and the frame. + let s3 = "arn:aws:s3:us-east-1:1234567890123:t367name"; + assert!( + message(check_table_name_arn("PutItem", s3)) + .unwrap() + .ends_with(FRAME) + ); + assert!(check_table_name_arn("PutItem", &table("t367name")).is_ok()); + } + + #[test] + fn control_plane_operations_pass_account_faults_on_and_word_region_faults_alone() { + let region = format!("arn:aws:dynamodb:us-ea\u{0}st-1:{ACCT}:table/t367name"); + for op in CONTROL_PLANE { + for account in ["1234\u{0}", "12345678901x", "12345678901", "1234567890123"] { + let arn = format!("arn:aws:dynamodb:us-east-1:{account}:table/t367name"); + assert!(check_table_name_arn(op, &arn).is_ok(), "{op} {arn:?}"); + } + assert_eq!( + message(check_table_name_arn(op, ®ion)).unwrap(), + format!("1 validation error detected: Invalid AWS region in '{region}'"), + "{op}" + ); + // The table segment and the frame are the item operations' answers. + let bad = table("t367\u{0}name"); + assert!( + message(check_table_name_arn(op, &bad)) + .unwrap() + .contains("Table name of ARN must satisfy"), + "{op}" + ); + } + let account = "arn:aws:dynamodb:us-east-1:1234\u{0}:table/t367name"; + assert!(check_table_name_arn("PutItem", account).is_err()); + } + #[test] fn other_shapes_get_the_valid_arn_format_frame() { for arn in [ diff --git a/crates/server/src/authz_resource/cross_account.rs b/crates/server/src/authz_resource/cross_account.rs index 148b0b92e..3ea68edf7 100644 --- a/crates/server/src/authz_resource/cross_account.rs +++ b/crates/server/src/authz_resource/cross_account.rs @@ -33,6 +33,10 @@ //! in more than one account, the caller's included, is refused with //! [`MIXED_ACCOUNTS`] whichever accounts they are. //! +//! An account segment that is not 12 ASCII digits gets the answers for an +//! account that does not exist, without a catalog lookup (measured +//! 2026-11-06; see [`account_in_catalog`]). +//! //! An account exists here when this deployment's catalog holds it. Fields the //! service reads as a name rather than an ARN (`ListStreams` `TableName`, //! `RestoreTableToPointInTime` `SourceTableName`, the `ImportTable` table name, @@ -198,7 +202,17 @@ pub(crate) fn refusal( /// True when this deployment's catalog holds `account`. A deployment without /// a catalog store knows no other account. A lookup failure fails the request /// (it is refused either way) with the authorization layer's internal error. +/// +/// An account segment that is not 12 ASCII digits is not looked up: the +/// management API creates no such account, and on PostgreSQL one that carries +/// U+0000 cannot be bound. The service answers it as an account that does not +/// exist (measured 2026-11-06 with `1234` and U+0000, `12345678901x`, 11 and +/// 13 digits, in `TableArn`, `SourceTableArn`, `ResourceArn`, `StreamArn`, +/// `BackupArn` and the control-plane `TableName`). async fn account_in_catalog(state: &AppState, account: &str) -> Result { + if !extenddb_core::arn::is_account_id(account) { + return Ok(false); + } let Some(store) = state.catalog_store.as_ref() else { return Ok(false); }; diff --git a/crates/server/src/table_name_gate.rs b/crates/server/src/table_name_gate.rs index d3202f1b0..9d3293d2a 100644 --- a/crates/server/src/table_name_gate.rs +++ b/crates/server/src/table_name_gate.rs @@ -32,7 +32,11 @@ //! - `ListStreams` has its own validator: `TableName must be at least 3 //! characters long and at most 255 characters long`, measured for a U+0000 //! name, and on 2026-11-04 for a table ARN and for `abc:def`; applied to -//! any other character outside the pattern (derived). +//! any other character outside the pattern (derived). Its +//! `ExclusiveStartStreamArn` takes the stream ARN rules of `arn_gate` +//! (measured 2026-11-06). U+0000 in the start ARN's account, region or +//! partition is accepted by the service and by storage, which binds only +//! the table segment and the label. //! - A `RequestItems` key outside the pattern: `at 'requestItems'` with the //! map-key constraint list. The service renders its own dump of the whole //! map inside `Value '...'`; the first offending key the parsed map yields @@ -73,7 +77,7 @@ const LIST_STREAMS_NAME: &str = /// missing, non-string or empty name passes. pub(crate) fn check_table_name_chars(operation: &str, input: &Value) -> Result<(), DynamoDbError> { match operation { - "BatchGetItem" | "BatchWriteItem" => check_request_items_keys(input), + "BatchGetItem" | "BatchWriteItem" => check_request_items_keys(operation, input), "TransactGetItems" => check_transact_items(input, &["Get"]), "TransactWriteItems" => { check_transact_items(input, &["Put", "Delete", "Update", "ConditionCheck"]) @@ -111,12 +115,19 @@ pub(crate) fn check_table_name_chars(operation: &str, input: &Value) -> Result<( input.get("ExclusiveStartTableName"), "exclusiveStartTableName", ), - "ListStreams" => match input.get("TableName").and_then(Value::as_str) { - Some(name) if !name.is_empty() && !in_pattern(name) => Err( - DynamoDbError::ValidationException(LIST_STREAMS_NAME.to_owned()), - ), - _ => Ok(()), - }, + "ListStreams" => { + match input.get("TableName").and_then(Value::as_str) { + Some(name) if !name.is_empty() && !in_pattern(name) => { + return Err(DynamoDbError::ValidationException( + LIST_STREAMS_NAME.to_owned(), + )); + } + _ => {} + } + // Storage binds the start ARN's table segment and label; the + // service refuses U+0000 in either (measured 2026-11-06). + with_str(input, "ExclusiveStartStreamArn", arn_gate::check_stream_arn) + } "Query" | "Scan" => { check_name(input.get("TableName"), "tableName", operation)?; match input.get("IndexName").and_then(Value::as_str) { @@ -211,7 +222,15 @@ fn bare_name_error(name: &str, location: &str) -> DynamoDbError { /// A `RequestItems` key outside the pattern, or an ARN key that the ARN rules /// refuse. The sentence from `at 'requestItems'` onward is the service's; the /// offending key alone is rendered where the service renders the whole map. -fn check_request_items_keys(input: &Value) -> Result<(), DynamoDbError> { +/// +/// `BatchWriteItem` refuses a key for its account segment only when the +/// segment carries U+0000; any other bad account segment is passed on to the +/// cross-account answer for an account that does not exist, `Access is +/// denied`. Measured 2026-11-06: a letter and 13 digits got `Access is denied` +/// on every request, and U+0000 and 11 digits got either that or the map-key +/// constraint from one request to the next. `BatchGetItem` refused all four +/// here on every request. +fn check_request_items_keys(operation: &str, input: &Value) -> Result<(), DynamoDbError> { let Some(map) = input.get("RequestItems").and_then(Value::as_object) else { return Ok(()); }; @@ -220,7 +239,14 @@ fn check_request_items_keys(input: &Value) -> Result<(), DynamoDbError> { continue; } if key.starts_with("arn:") { - if arn_gate::table_arn_fault(key).is_some() { + let refused = match arn_gate::table_arn_fault(key) { + None => false, + Some(arn_gate::ArnFault::Account) if operation == "BatchWriteItem" => { + extenddb_core::arn::fields(key).is_some_and(|f| f.account.contains('\u{0}')) + } + Some(_) => true, + }; + if refused { return Err(one_error(key, "requestItems", arn_gate::MAP_KEYS_ARN)); } } else if !in_pattern(key) { @@ -513,6 +539,68 @@ mod tests { } } + #[test] + fn list_streams_checks_the_exclusive_start_stream_arn() { + let own = "arn:aws:dynamodb:us-east-1:123456789012:table/t367name"; + for start in [ + format!("{own}/stream/2026\u{0}"), + format!("{ARN}/stream/2026-11-06T00:00:00.000"), + ] { + for input in [ + json!({ "ExclusiveStartStreamArn": start }), + json!({ "TableName": "t367name", "ExclusiveStartStreamArn": start }), + ] { + assert_eq!( + message("ListStreams", input).as_deref(), + Some("Invalid StreamArn"), + "{start:?}" + ); + } + } + for start in [ + "arn:aws:dynamodb:us-east-1:1234\u{0}:table/t367name/stream/2026-11-06T00:00:00.000", + "arn:aws:dynamodb:us-ea\u{0}st-1:123456789012:table/t367name/stream/2026", + "arn:aws:dynamodb:us-east-1:123456789012:table/t367name/stream/2026", + ] { + assert!( + message("ListStreams", json!({ "ExclusiveStartStreamArn": start })).is_none(), + "{start:?}" + ); + } + } + + #[test] + fn batch_write_passes_an_account_segment_without_nul_on() { + let key = |account: &str| format!("arn:aws:dynamodb:us-east-1:{account}:table/t367name"); + for account in ["12345678901x", "12345678901", "1234567890123"] { + let k = key(account); + assert!( + message( + "BatchWriteItem", + json!({ "RequestItems": { k.as_str(): [] } }) + ) + .is_none(), + "{k}" + ); + assert_eq!( + message( + "BatchGetItem", + json!({ "RequestItems": { k.as_str(): {} } }) + ) + .unwrap(), + one(&k, "requestItems", arn_gate::MAP_KEYS_ARN) + ); + } + let k = key("1234\u{0}"); + for op in ["BatchWriteItem", "BatchGetItem"] { + assert_eq!( + message(op, json!({ "RequestItems": { k.as_str(): [] } })).unwrap(), + one(&k, "requestItems", arn_gate::MAP_KEYS_ARN), + "{op}" + ); + } + } + #[test] fn batch_request_items_keys_are_refused_at_request_items() { for op in ["BatchWriteItem", "BatchGetItem"] { diff --git a/tests/python/test_nul_arn_shapes.py b/tests/python/test_nul_arn_shapes.py index a2f04bd70..a8042ca4e 100644 --- a/tests/python/test_nul_arn_shapes.py +++ b/tests/python/test_nul_arn_shapes.py @@ -12,8 +12,18 @@ or `Invalid AWS region` when that segment is the bad one. The ARNs below are built from a table the test creates, so the account and region are the caller's on either target. + +The account segment rows (`TestAccountSegment...`) and the `ListStreams` +`ExclusiveStartStreamArn` rows were measured against the service on +2026-11-06 (us-east-1). An account segment with U+0000, a letter, 11 or 13 +digits gets the answer for an account that does not exist in every ARN field +the cross-account check reads, and in `TableName` of the control-plane +operations; the item operations, the transaction members and `BatchGetItem` +refuse it as a validation error. """ +import time + import pytest from botocore.exceptions import ClientError @@ -166,3 +176,246 @@ def test_transact_write_items_put_table_name_as_arn(self, raw_client, arns): f"1 validation error detected: Value '{bad}' at " f"'transactItems.1.member.put.tableName' failed to satisfy constraint: {TABLE_OF_ARN}", ) + + +BAD_ACCOUNTS = ["1234" + NUL, "12345678901x", "12345678901", "1234567890123"] +BAD_IDS = ["nul", "letter", "eleven", "thirteen"] +FOREIGN = "999999999999" +OWNER_ONLY = ( + "This action is only supported by accounts that match the resource owner\u2019s account." +) +SHARD = "shardId-00000001700000000000-abcdef12" +LABEL = "2026-11-06T00:00:00.000" + + +@pytest.fixture(scope="module") +def caller(raw_client): + """The partition, region and account of the caller, read from a table the + module creates once.""" + name = unique_name("nulc") + raw_client.create_table( + TableName=name, + BillingMode="PAY_PER_REQUEST", + KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": "pk", "AttributeType": "S"}], + ) + wait_for_active(raw_client, name) + arn = raw_client.describe_table(TableName=name)["Table"]["TableArn"] + _, partition, _, region, account, _ = arn.split(":", 5) + yield {"name": name, "partition": partition, "region": region, "account": account} + try: + raw_client.delete_table(TableName=name) + wait_for_deleted(raw_client, name) + except Exception: + pass + + +def _table_arn(caller, account, table="t830name", region=None): + region = caller["region"] if region is None else region + return f"arn:{caller['partition']}:dynamodb:{region}:{account}:table/{table}" + + +def _assert_denied(exc: ClientError, message: str = "Access is denied") -> None: + resp = exc.response + assert resp["ResponseMetadata"]["HTTPStatusCode"] == 400, resp + assert resp["Error"]["Code"] == "AccessDeniedException", resp["Error"] + assert resp["Error"]["Message"] == message, resp["Error"]["Message"] + + +def _raises(call) -> ClientError: + with pytest.raises(ClientError) as exc: + call() + return exc.value + + +class TestAccountSegmentInArnFields: + """The fields the cross-account check reads. On PostgreSQL an account with + U+0000 answered 500 `Internal error during authorization` before the + account was answered without a catalog lookup.""" + + @pytest.mark.parametrize( + "account", + [BAD_ACCOUNTS[0], FOREIGN, *BAD_ACCOUNTS[1:]], + ids=["nul", "foreign", "letter", "eleven", "thirteen"], + ) + def test_export_and_point_in_time_restore(self, raw_client, caller, account): + arn = _table_arn(caller, account) + _assert_denied( + _raises(lambda: raw_client.export_table_to_point_in_time(TableArn=arn, S3Bucket="bucket1")) + ) + exc = _raises( + lambda: raw_client.restore_table_to_point_in_time( + SourceTableArn=arn, TargetTableName="t830target" + ) + ) + assert_validation(exc, OWNER_ONLY) + + @pytest.mark.parametrize("account", BAD_ACCOUNTS, ids=BAD_IDS) + def test_tag_stream_and_backup_arn_fields(self, raw_client, raw_streams_client, caller, account): + table = _table_arn(caller, account) + stream = f"{table}/stream/{LABEL}" + backup = f"{table}/backup/01700000000000-abcdef12" + for call in [ + lambda: raw_client.tag_resource(ResourceArn=table, Tags=[{"Key": "k", "Value": "v"}]), + lambda: raw_client.untag_resource(ResourceArn=table, TagKeys=["k"]), + lambda: raw_client.list_tags_of_resource(ResourceArn=table), + lambda: raw_client.list_tags_of_resource(ResourceArn=stream), + lambda: raw_streams_client.describe_stream(StreamArn=stream), + lambda: raw_streams_client.get_shard_iterator( + StreamArn=stream, ShardId=SHARD, ShardIteratorType="TRIM_HORIZON" + ), + lambda: raw_client.describe_backup(BackupArn=backup), + lambda: raw_client.restore_table_from_backup( + BackupArn=backup, TargetTableName="t830target" + ), + ]: + _assert_denied(_raises(call)) + + +class TestAccountSegmentInTableName: + """A table ARN in `TableName`, `RequestItems` and `TransactItems` whose + account segment is not 12 digits.""" + + @pytest.mark.parametrize("account", BAD_ACCOUNTS, ids=BAD_IDS) + def test_control_plane_operations_answer_as_for_an_unknown_account( + self, raw_client, caller, account + ): + arn = _table_arn(caller, account) + for call in [ + lambda: raw_client.describe_table(TableName=arn), + lambda: raw_client.update_table(TableName=arn, BillingMode="PAY_PER_REQUEST"), + lambda: raw_client.describe_time_to_live(TableName=arn), + lambda: raw_client.update_time_to_live( + TableName=arn, + TimeToLiveSpecification={"Enabled": True, "AttributeName": "ttl"}, + ), + lambda: raw_client.describe_continuous_backups(TableName=arn), + lambda: raw_client.update_continuous_backups( + TableName=arn, + PointInTimeRecoverySpecification={"PointInTimeRecoveryEnabled": False}, + ), + lambda: raw_client.create_backup(TableName=arn, BackupName="b830"), + lambda: raw_client.list_backups(TableName=arn), + lambda: raw_client.create_table( + TableName=arn, + BillingMode="PAY_PER_REQUEST", + KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": "pk", "AttributeType": "S"}], + ), + ]: + _assert_denied(_raises(call)) + + @pytest.mark.parametrize("account", BAD_ACCOUNTS, ids=BAD_IDS) + def test_item_operations_and_transaction_members_answer_invalid_subscriber_id( + self, raw_client, caller, account + ): + arn = _table_arn(caller, account) + key = {"pk": {"S": "a"}} + for call in [ + lambda: raw_client.get_item(TableName=arn, Key=key), + lambda: raw_client.put_item(TableName=arn, Item=key), + lambda: raw_client.update_item(TableName=arn, Key=key), + lambda: raw_client.query( + TableName=arn, + KeyConditionExpression="pk = :v", + ExpressionAttributeValues={":v": {"S": "a"}}, + ), + lambda: raw_client.scan(TableName=arn), + ]: + _at_table_name(_raises(call), arn, "Invalid subscriber ID") + for op, call in [ + ("get", lambda: raw_client.transact_get_items( + TransactItems=[{"Get": {"TableName": arn, "Key": key}}])), + ("put", lambda: raw_client.transact_write_items( + TransactItems=[{"Put": {"TableName": arn, "Item": key}}])), + ]: + assert_validation( + _raises(call), + f"1 validation error detected: Value '{arn}' at " + f"'transactItems.1.member.{op}.tableName' failed to satisfy constraint: " + "Invalid subscriber ID", + ) + + @pytest.mark.parametrize("account", BAD_ACCOUNTS, ids=BAD_IDS) + def test_batch_keys(self, raw_client, caller, account): + arn = _table_arn(caller, account) + key = {"pk": {"S": "a"}} + calls = { + "BatchGetItem": lambda: raw_client.batch_get_item(RequestItems={arn: {"Keys": [key]}}), + "BatchWriteItem": lambda: raw_client.batch_write_item( + RequestItems={arn: [{"PutRequest": {"Item": key}}]} + ), + } + for op, call in calls.items(): + if op == "BatchWriteItem" and account in (BAD_ACCOUNTS[0], BAD_ACCOUNTS[2]): + # The service answered U+0000 and 11 digits here with either + # `Access is denied` or the map-key constraint from one request + # to the next (2026-11-06); only a 400 is asserted. + exc = _raises(call) + assert exc.response["ResponseMetadata"]["HTTPStatusCode"] == 400 + continue + exc = _raises(call) + if op == "BatchWriteItem": + _assert_denied(exc) + continue + resp = exc.response + assert resp["Error"]["Code"] == "ValidationException", (op, resp["Error"]) + assert resp["Error"]["Message"].endswith( + f"' at 'requestItems' failed to satisfy constraint: {MAP_KEYS_ARN}" + ), (op, resp["Error"]["Message"]) + + def test_control_plane_region_fault_has_no_location(self, raw_client, caller): + arn = _table_arn( + caller, caller["account"], table=caller["name"], region="us-ea" + NUL + "st-1" + ) + for call in [ + lambda: raw_client.describe_table(TableName=arn), + lambda: raw_client.update_table(TableName=arn, BillingMode="PAY_PER_REQUEST"), + lambda: raw_client.describe_time_to_live(TableName=arn), + lambda: raw_client.describe_continuous_backups(TableName=arn), + lambda: raw_client.list_backups(TableName=arn), + ]: + assert_validation( + _raises(call), f"1 validation error detected: Invalid AWS region in '{arn}'" + ) + + +def _list_streams(client, **params): + """ListStreams, sent again after a ThrottlingException (the service's rate + for this operation is low).""" + for attempt in range(5): + try: + return client.list_streams(**params) + except ClientError as e: + if e.response["Error"]["Code"] != "ThrottlingException" or attempt == 4: + raise + time.sleep(2 * (attempt + 1)) + raise AssertionError("unreachable") + + +class TestListStreamsExclusiveStartStreamArn: + """Storage reads the start ARN's table segment and label. U+0000 in either + answered 500 `Internal server error` on PostgreSQL.""" + + def test_nul_in_the_label_or_table_segment_is_invalid_stream_arn( + self, raw_streams_client, caller + ): + own = _table_arn(caller, caller["account"], table=caller["name"]) + for start in [ + f"{own}/stream/2026{NUL}", + f"{_table_arn(caller, caller['account'], table='t830' + NUL + 'name')}/stream/{LABEL}", + ]: + for extra in [{}, {"TableName": caller["name"]}]: + with pytest.raises(ClientError) as exc: + _list_streams(raw_streams_client, ExclusiveStartStreamArn=start, **extra) + assert_validation(exc.value, "Invalid StreamArn") + + def test_nul_in_the_account_or_region_is_accepted(self, raw_streams_client, caller): + for start in [ + f"{_table_arn(caller, '1234' + NUL, table=caller['name'])}/stream/{LABEL}", + f"{_table_arn(caller, caller['account'], table=caller['name'], region='us-ea' + NUL + 'st-1')}" + f"/stream/{LABEL}", + ]: + resp = _list_streams(raw_streams_client, ExclusiveStartStreamArn=start, Limit=1) + assert resp["ResponseMetadata"]["HTTPStatusCode"] == 200 + assert "Streams" in resp From 9234b54acc0fe9e74b6399754b560e8409cdf030 Mon Sep 17 00:00:00 2001 From: Lee <29015211+LeeroyHannigan@users.noreply.github.com> Date: Sat, 7 Nov 2026 01:23:06 +0000 Subject: [PATCH 3/3] fix(server): check a TableName ARN in the order DynamoDB checks it The control-plane operations passed an account segment that is not an account id on to the cross-account check. That check reads the account only from an ARN that extenddb_core::arn::parse accepts. Any other shape, such as arn:aws:dynamodb:us-east-1:1234:tbl, reached the handlers. On PostgreSQL, UpdateTable, DescribeContinuousBackups and UpdateContinuousBackups then answered 500 "Internal server error". DynamoDB answers "Access is denied" for these operations when the account segment is not the caller's. The ARN's shape, vendor and region do not change that answer, and the empty account of arn:aws:s3::: gets it too. The gate now compares the account segment with the caller's account first. It passes an ARN that arn::parse reads on to the cross-account check. It answers any other shape with that check's answer for an account that does not exist. This replaces the DescribeTable exception for another service's ARN. On the item operations DynamoDB checks the shape first, then the vendor, the region, the table segment and the account. table_arn_fault now checks in that order. An ARN of another service with a table/ resource gets "Invalid vendor code". The control-plane operations word it as "Invalid vendor code in ''" for the caller's account. An empty account segment is an account fault like 11 or 13 digits. Before this change, the gate reported an account fault before the shape and the table segment, and a region fault before the shape. ListStreams answers its TableName sentence for a name of 1, 2 or more than 255 characters, before it checks ExclusiveStartStreamArn. DynamoDB answers that sentence first when both members are bad. check_table_name_chars and check_table_name_arn take the caller's account. The new rows in tests/python/test_nul_arn_shapes.py (TestArnCheckOrder and TestListStreamsTableName) encode the answers of DynamoDB in us-east-1, measured on 2026-11-07. Part of #367 --- crates/server/src/arn_gate.rs | 358 ++++++++++++++++++--------- crates/server/src/request_helpers.rs | 2 +- crates/server/src/table_name_gate.rs | 74 +++++- tests/python/test_nul_arn_shapes.py | 234 ++++++++++++++--- 4 files changed, 505 insertions(+), 163 deletions(-) diff --git a/crates/server/src/arn_gate.rs b/crates/server/src/arn_gate.rs index 747409359..05293d4bd 100644 --- a/crates/server/src/arn_gate.rs +++ b/crates/server/src/arn_gate.rs @@ -4,27 +4,32 @@ //! The ARN shapes `table_name_gate` refuses before any catalog lookup, with the //! service's sentences (measured 2026-10-24 unless marked derived). //! -//! A `TableName` may be a table ARN. The service checks the ARN's shape and -//! each of its segments before it resolves the table: +//! A `TableName` may be a table ARN. On the item operations the service checks +//! the shape, the vendor, the region, the table segment and the account, in +//! that order (the order measured 2026-11-07, except the region before the +//! table segment, which is derived): //! //! | `TableName` | sentence at `'tableName'` | //! |---|---| -//! | a table ARN whose table segment fails the pattern | `Table name of ARN must satisfy regular expression pattern: ...` | -//! | a dynamodb ARN with a bad region segment | `Invalid AWS region` | -//! | a dynamodb ARN with a bad account segment (a character that is not a digit, or not 12 digits) | `Invalid subscriber ID` | -//! | anything else that is not a plain table ARN (another service, an index or stream suffix, too few fields) | the `Valid ARN format is ...` frame | +//! | not a plain table ARN (too few fields, a resource that is not `table/`, an index, stream or backup suffix) | the `Valid ARN format is ...` frame | +//! | a plain table ARN of another service | `Invalid vendor code` (measured 2026-11-07) | +//! | a bad region segment | `Invalid AWS region` | +//! | a table segment that fails the pattern | `Table name of ARN must satisfy regular expression pattern: ...` | +//! | an account segment that is not 12 ASCII digits, the empty one included | `Invalid subscriber ID` | //! -//! `DescribeTable` with an ARN of another service is `AccessDeniedException` -//! on the service; that answer is left to the engine, so the frame is not -//! applied to it there. The frame for a well-formed index or stream ARN in -//! `TableName` is derived from the measured index ARN with a bad segment. -//! -//! The table above is measured with the item operations. The control-plane -//! operations in [`CONTROL_PLANE`] answer the region and account faults -//! differently (measured 2026-11-06): a bad region segment is `1 validation -//! error detected: Invalid AWS region in ''`, with no location, and a bad -//! account segment gets the answer for an account that does not exist, which -//! `authz_resource::cross_account` gives, so the gate passes it on. +//! The control-plane operations in [`CONTROL_PLANE`] check the account first +//! (measured 2026-11-07): an ARN whose account segment is not the caller's +//! gets the cross-account answer, whatever its shape, vendor or region. +//! `authz_resource::cross_account` gives that answer for an ARN `arn::parse` +//! reads, so the gate passes those on. For any other shape the gate gives +//! that layer's answer for an account that does not exist, `Access is denied` +//! (derived for an account the catalog holds). This covers `DescribeTable` +//! with `arn:aws:s3:::`, whose empty account segment is not the +//! caller's (AccessDeniedException, measured 2026-10-24). In the caller's +//! account they answer the shape and the table segment as the item operations +//! do, and a vendor or region fault as `1 validation error detected: Invalid +//! vendor code in ''` or `... Invalid AWS region in ''`, with no +//! location (measured 2026-11-06 and 2026-11-07). //! //! The other ARN-bearing fields: `SourceTableArn` (`RestoreTableToPointInTime`) //! and `TableArn` (`ExportTableToPointInTime`) with a bad table segment, @@ -40,10 +45,14 @@ use extenddb_core::error::DynamoDbError; pub(crate) const PATTERN: &str = "[a-zA-Z0-9_.-]+"; -/// The operations whose `TableName` region and account faults get the -/// control-plane answers (see the module docs). Measured 2026-11-06 for each, -/// except `DeleteTable`, which was not sent and takes `UpdateTable`'s answers, -/// and the region fault of `CreateBackup`, which was not sent. +/// The operations whose `TableName` ARN gets the control-plane answers (see +/// the module docs). The account answers were measured 2026-11-07 for each, +/// except `DeleteTable`, which was not sent and takes `UpdateTable`'s +/// answers, and `CreateTable` with a well-formed account of another caller, +/// which was not sent. The region fault was measured 2026-11-06 for each +/// except `CreateBackup`, and the vendor fault and the frame in the caller's +/// account 2026-11-07 for `DescribeTable`, `DescribeTimeToLive`, +/// `DescribeContinuousBackups` and `ListBackups`; the others are derived. pub(crate) const CONTROL_PLANE: &[&str] = &[ "CreateTable", "DescribeTable", @@ -87,90 +96,94 @@ fn one_error(value: &str, location: &str, constraint: &str) -> DynamoDbError { )) } -/// What is wrong with an `arn:` value read as a table ARN. +/// What is wrong with an `arn:` value read as a table ARN, in the order the +/// service checks it on the item operations. #[derive(Debug, PartialEq, Eq)] pub(crate) enum ArnFault { - /// A plain table ARN whose table segment fails the pattern. - TableSegment, - /// A dynamodb ARN whose region segment carries a character outside `[a-z0-9-]`. + /// Not a plain table ARN: too few fields, a resource that is not + /// `table/`, an index, stream or backup suffix, or a shape + /// `arn::parse` rejects. + NotPlainTable, + /// A plain table ARN of another service. + Vendor, + /// A region segment that carries a character outside `[a-z0-9-]`. Region, - /// An account segment that carries a character that is not a digit, or, - /// in a dynamodb ARN, a non-empty one that is not 12 digits. + /// A table segment that fails the pattern. + TableSegment, + /// An account segment that is not 12 ASCII digits, the empty one included. Account, - /// Not a plain table ARN of this service: too few fields, another service, - /// an index or stream or backup suffix, or a shape `arn::parse` rejects. - NotPlainTable, } -/// Inspect an `arn:` value the way the service checks a `TableName` ARN. -/// `None` means a well-formed plain table ARN whose segments all pass. +/// Inspect an `arn:` value the way the service checks a `TableName` ARN on +/// the item operations. `None` means a well-formed plain table ARN whose +/// segments all pass. pub(crate) fn table_arn_fault(arn: &str) -> Option { - let Some(f) = fields(arn) else { - return Some(ArnFault::NotPlainTable); + let res = match parse(arn) { + Some(res) if res.sub.is_none() => res, + _ => return Some(ArnFault::NotPlainTable), }; - if !f + if res.service != "dynamodb" { + return Some(ArnFault::Vendor); + } + if !res .region .chars() .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') { return Some(ArnFault::Region); } - // 11 and 13 digits are measured (2026-11-06) in a dynamodb ARN only; an - // empty account and another service's ARN keep the digit rule. - let bad_account = if f.service == "dynamodb" && !f.account.is_empty() { - !extenddb_core::arn::is_account_id(f.account) - } else { - !f.account.chars().all(|c| c.is_ascii_digit()) - }; - if bad_account { - return Some(ArnFault::Account); - } - if f.service != "dynamodb" { - return Some(ArnFault::NotPlainTable); - } - match parse(arn) { - Some(res) if res.sub.is_none() => { - (!in_pattern(&res.table)).then_some(ArnFault::TableSegment) - } - _ => Some(ArnFault::NotPlainTable), + if !in_pattern(&res.table) { + return Some(ArnFault::TableSegment); } + // 11 and 13 digits measured 2026-11-06, the empty account 2026-11-07. + (!extenddb_core::arn::is_account_id(&res.account)).then_some(ArnFault::Account) } fn arn_constraint(fault: ArnFault) -> &'static str { match fault { + ArnFault::NotPlainTable => FRAME, + ArnFault::Vendor => "Invalid vendor code", + ArnFault::Region => "Invalid AWS region", ArnFault::TableSegment => { "Table name of ARN must satisfy regular expression pattern: [a-zA-Z0-9_.-]+" } - ArnFault::Region => "Invalid AWS region", ArnFault::Account => "Invalid subscriber ID", - ArnFault::NotPlainTable => FRAME, } } -/// A `TableName` that is an ARN, for `operation`. `Ok` passes the value on to -/// the handlers, which resolve a well-formed table ARN. -pub(crate) fn check_table_name_arn(operation: &str, arn: &str) -> Result<(), DynamoDbError> { +/// A `TableName` that is an ARN, for `operation` called from `account_id`. +/// `Ok` passes the value on: a well-formed table ARN to the handlers, which +/// resolve it, and on a control-plane operation an ARN of another account +/// that `arn::parse` reads to `authz_resource::cross_account`. +pub(crate) fn check_table_name_arn( + operation: &str, + arn: &str, + account_id: &str, +) -> Result<(), DynamoDbError> { + let control_plane = CONTROL_PLANE.contains(&operation); + if control_plane && fields(arn).is_some_and(|f| f.account != account_id) { + if parse(arn).is_some() { + return Ok(()); + } + // `cross_account` cannot read this shape: give its answer for an + // account that does not exist (it names no principal). + return Err(crate::authz_resource::cross_account::refusal( + operation, operation, arn, false, "", + )); + } let Some(fault) = table_arn_fault(arn) else { return Ok(()); }; - // DescribeTable answers a foreign-service ARN with AccessDeniedException on - // the service; the engine's own answer stands for that one shape. - if operation == "DescribeTable" - && fault == ArnFault::NotPlainTable - && fields(arn).is_some_and(|f| f.service != "dynamodb") - { - return Ok(()); - } - if CONTROL_PLANE.contains(&operation) { - match fault { - // The cross-account layer answers it without a catalog lookup. - ArnFault::Account => return Ok(()), - ArnFault::Region => { - return Err(validation(format!( - "1 validation error detected: Invalid AWS region in '{arn}'" - ))); - } - ArnFault::TableSegment | ArnFault::NotPlainTable => {} + if control_plane { + let segment = match fault { + ArnFault::Vendor => Some("vendor code"), + ArnFault::Region => Some("AWS region"), + _ => None, + }; + if let Some(segment) = segment { + return Err(validation(format!( + "1 validation error detected: Invalid {segment} in '{arn}'" + ))); } } Err(one_error(arn, "tableName", arn_constraint(fault))) @@ -263,32 +276,45 @@ mod tests { } } + /// The gate's answer for `arn` on `op`, called from `ACCT`. + fn arn_check(op: &str, arn: &str) -> Result<(), DynamoDbError> { + check_table_name_arn(op, arn, ACCT) + } + fn access_denied(r: Result<(), DynamoDbError>) -> bool { + matches!(r, Err(DynamoDbError::AccessDeniedException(m)) if m == "Access is denied") + } + fn at_table_name(arn: &str, constraint: &str) -> String { + format!( + "1 validation error detected: Value '{arn}' at 'tableName' failed to satisfy \ + constraint: {constraint}" + ) + } + #[test] fn a_plain_table_arn_with_a_bad_segment_gets_the_table_name_of_arn_sentence() { let arn = table("t367\u{0}name"); - let m = message(check_table_name_arn("PutItem", &arn)).expect("refused"); + let m = message(arn_check("PutItem", &arn)).expect("refused"); assert_eq!( m, - format!( - "1 validation error detected: Value '{arn}' at 'tableName' failed to satisfy \ - constraint: Table name of ARN must satisfy regular expression pattern: \ - [a-zA-Z0-9_.-]+" + at_table_name( + &arn, + "Table name of ARN must satisfy regular expression pattern: [a-zA-Z0-9_.-]+" ) ); - assert!(check_table_name_arn("PutItem", &table("t367name")).is_ok()); + assert!(arn_check("PutItem", &table("t367name")).is_ok()); } #[test] fn region_and_account_faults_name_their_segment() { let region = format!("arn:aws:dynamodb:us-ea\u{0}st-1:{ACCT}:table/t367name"); assert!( - message(check_table_name_arn("PutItem", ®ion)) + message(arn_check("PutItem", ®ion)) .unwrap() .ends_with("failed to satisfy constraint: Invalid AWS region") ); let account = "arn:aws:dynamodb:us-east-1:1234\u{0}56789012:table/t367name"; assert!( - message(check_table_name_arn("PutItem", account)) + message(arn_check("PutItem", account)) .unwrap() .ends_with("failed to satisfy constraint: Invalid subscriber ID") ); @@ -296,11 +322,11 @@ mod tests { #[test] fn an_account_segment_of_eleven_or_thirteen_digits_is_an_account_fault() { - for account in ["12345678901", "1234567890123", "12345678901x"] { + for account in ["12345678901", "1234567890123", "12345678901x", ""] { let arn = format!("arn:aws:dynamodb:us-east-1:{account}:table/t367name"); for op in ["GetItem", "PutItem", "UpdateItem", "Query", "Scan"] { assert!( - message(check_table_name_arn(op, &arn)) + message(arn_check(op, &arn)) .unwrap() .ends_with("failed to satisfy constraint: Invalid subscriber ID"), "{op} {arn}" @@ -313,40 +339,144 @@ mod tests { .unwrap(); assert!(m.ends_with("constraint: Invalid subscriber ID"), "{m}"); } - // Another service's ARN keeps the digit rule and the frame. - let s3 = "arn:aws:s3:us-east-1:1234567890123:t367name"; - assert!( - message(check_table_name_arn("PutItem", s3)) - .unwrap() - .ends_with(FRAME) + assert!(arn_check("PutItem", &table("t367name")).is_ok()); + } + + /// Measured 2026-11-07 with GetItem and TransactGetItems: the shape first, + /// then the vendor, the region, the table segment and the account. + #[test] + fn item_operations_check_the_shape_and_vendor_before_the_segments() { + let cases = [ + ("arn:aws:dynamodb:us-east-1:1234\u{0}:tbl", FRAME), + ("arn:aws:dynamodb:us-east-1:12345678901:table/t/", FRAME), + ( + "arn:aws:dynamodb:us-east-1:1234567890123:table/t/index/gsi1", + FRAME, + ), + ("arn:aws:dynamodb:us-ea\u{0}st-1:123456789012:tbl", FRAME), + ("arn:aws:s3:us-east-1:1234567890123:t367name", FRAME), + ( + "arn:aws:s3:us-east-1:12345678901x:table/t", + "Invalid vendor code", + ), + ( + "arn:aws:s3:us-ea\u{0}st-1:123456789012:table/t", + "Invalid vendor code", + ), + ( + "arn:aws:dynamodb:us-ea\u{0}st-1:1234\u{0}:table/t367name", + "Invalid AWS region", + ), + ( + "arn:aws:dynamodb:us-east-1:12345678901:table/t367\u{0}name", + "Table name of ARN must satisfy regular expression pattern: [a-zA-Z0-9_.-]+", + ), + ( + "arn:aws:dynamodb:us-east-1:1234\u{0}:table/t367\u{0}name", + "Table name of ARN must satisfy regular expression pattern: [a-zA-Z0-9_.-]+", + ), + ( + "arn:aws:dynamodb:us-east-1::table/t367name", + "Invalid subscriber ID", + ), + ]; + for (arn, constraint) in cases { + assert_eq!( + message(arn_check("GetItem", arn)).as_deref(), + Some(at_table_name(arn, constraint).as_str()), + "{arn:?}" + ); + let m = message(check_transact_member_arn( + arn, + "transactItems.1.member.get.tableName", + )) + .unwrap(); + assert!(m.ends_with(&format!("constraint: {constraint}")), "{m}"); + } + } + + /// Measured 2026-11-07: an ARN of another account gets the cross-account + /// answer before any other check. An ARN `arn::parse` reads is passed on to + /// `cross_account`; for any other shape the gate gives its answer. + #[test] + fn control_plane_operations_answer_another_account_before_the_shape() { + for op in CONTROL_PLANE { + for account in [ + "1234\u{0}", + "12345678901x", + "12345678901", + "1234567890123", + "999999999999", + ] { + for resource in [ + "table/t367name", + "table/t/index/gsi1", + "table/t/backup/01-ab", + ] { + let arn = format!("arn:aws:dynamodb:us-east-1:{account}:{resource}"); + assert!(arn_check(op, &arn).is_ok(), "{op} {arn:?}"); + } + let s3 = format!("arn:aws:s3:us-east-1:{account}:table/t"); + assert!(arn_check(op, &s3).is_ok(), "{op} {s3:?}"); + for unread in [ + format!("arn:aws:dynamodb:us-east-1:{account}:tbl"), + format!("arn:aws:dynamodb:us-east-1:{account}:table/t/"), + format!("arn:aws:dynamodb:us-east-1:{account}:table/t/stream/a/b"), + format!("arn:aws:dynamodb:us-ea\u{0}st-1:{account}:tbl"), + format!("arn:aws:s3:us-east-1:{account}:t"), + ] { + assert!(access_denied(arn_check(op, &unread)), "{op} {unread:?}"); + } + } + assert!( + access_denied(arn_check(op, "arn:aws:s3:::t367\u{0}name")), + "{op}" + ); + } + // The item operations keep their answers for the same shapes. + let unread = "arn:aws:dynamodb:us-east-1:1234\u{0}:tbl"; + assert_eq!( + message(arn_check("PutItem", unread)).unwrap(), + at_table_name(unread, FRAME) ); - assert!(check_table_name_arn("PutItem", &table("t367name")).is_ok()); } #[test] - fn control_plane_operations_pass_account_faults_on_and_word_region_faults_alone() { + fn control_plane_operations_word_vendor_and_region_faults_alone() { let region = format!("arn:aws:dynamodb:us-ea\u{0}st-1:{ACCT}:table/t367name"); + let vendor = format!("arn:aws:s3:us-ea\u{0}st-1:{ACCT}:table/t367\u{0}name"); for op in CONTROL_PLANE { - for account in ["1234\u{0}", "12345678901x", "12345678901", "1234567890123"] { - let arn = format!("arn:aws:dynamodb:us-east-1:{account}:table/t367name"); - assert!(check_table_name_arn(op, &arn).is_ok(), "{op} {arn:?}"); - } assert_eq!( - message(check_table_name_arn(op, ®ion)).unwrap(), + message(arn_check(op, ®ion)).unwrap(), format!("1 validation error detected: Invalid AWS region in '{region}'"), "{op}" ); - // The table segment and the frame are the item operations' answers. + assert_eq!( + message(arn_check(op, &vendor)).unwrap(), + format!("1 validation error detected: Invalid vendor code in '{vendor}'"), + "{op}" + ); + // The shape and the table segment are the item operations' answers. + for arn in [ + format!("arn:aws:dynamodb:us-ea\u{0}st-1:{ACCT}:tbl"), + format!("arn:aws:s3:us-east-1:{ACCT}:t"), + format!("{}/index/gsi1", table("t367name")), + format!("{}/", table("t367name")), + ] { + assert_eq!( + message(arn_check(op, &arn)).unwrap(), + at_table_name(&arn, FRAME), + "{op}" + ); + } let bad = table("t367\u{0}name"); assert!( - message(check_table_name_arn(op, &bad)) + message(arn_check(op, &bad)) .unwrap() .contains("Table name of ARN must satisfy"), "{op}" ); } - let account = "arn:aws:dynamodb:us-east-1:1234\u{0}:table/t367name"; - assert!(check_table_name_arn("PutItem", account).is_err()); } #[test] @@ -360,15 +490,8 @@ mod tests { "arn:\u{0}".to_owned(), format!("{}/", table("t367\u{0}name")), ] { - let m = - message(check_table_name_arn("Query", &arn)).unwrap_or_else(|| panic!("{arn:?}")); - assert_eq!( - m, - format!( - "1 validation error detected: Value '{arn}' at 'tableName' failed to satisfy \ - constraint: {FRAME}" - ) - ); + let m = message(arn_check("Query", &arn)).unwrap_or_else(|| panic!("{arn:?}")); + assert_eq!(m, at_table_name(&arn, FRAME)); } let m = message(check_transact_member_arn( &table("t367\u{0}name"), @@ -376,17 +499,12 @@ mod tests { )) .unwrap(); assert!(m.contains("at 'transactItems.1.member.put.tableName' failed to satisfy constraint: Table name of ARN"), "{m}"); - } - - #[test] - fn describe_table_leaves_a_foreign_service_arn_to_the_engine() { - assert!(check_table_name_arn("DescribeTable", "arn:aws:s3:::t367\u{0}name").is_ok()); assert!( - check_table_name_arn( + message(arn_check( "DescribeTable", &format!("{}/index/ix", table("t367\u{0}name")) - ) - .is_err() + )) + .is_some() ); } diff --git a/crates/server/src/request_helpers.rs b/crates/server/src/request_helpers.rs index 244fde437..381f14511 100755 --- a/crates/server/src/request_helpers.rs +++ b/crates/server/src/request_helpers.rs @@ -114,7 +114,7 @@ pub(crate) async fn authorize_request( // here, before the key info and resource tag lookups below and before the // developer-mode return, the cross-account refusal and the ARN shape // checks (see `table_name_gate`). - crate::table_name_gate::check_table_name_chars(operation, input)?; + crate::table_name_gate::check_table_name_chars(operation, input, account_id)?; let table_name = extract_table_name(input); let mut authorized_key_info = extenddb_engine::AuthorizedKeyInfo::new(); diff --git a/crates/server/src/table_name_gate.rs b/crates/server/src/table_name_gate.rs index 9d3293d2a..1e4fb4273 100644 --- a/crates/server/src/table_name_gate.rs +++ b/crates/server/src/table_name_gate.rs @@ -32,9 +32,13 @@ //! - `ListStreams` has its own validator: `TableName must be at least 3 //! characters long and at most 255 characters long`, measured for a U+0000 //! name, and on 2026-11-04 for a table ARN and for `abc:def`; applied to -//! any other character outside the pattern (derived). Its +//! any other character outside the pattern (derived). The gate also gives +//! it for a name of 1, 2 or more than 255 characters, which the engine does +//! not check (measured 2026-11-07 with 1, 2 and 256; 3 and 255 pass). Its //! `ExclusiveStartStreamArn` takes the stream ARN rules of `arn_gate` -//! (measured 2026-11-06). U+0000 in the start ARN's account, region or +//! (measured 2026-11-06), after `TableName`: with both bad the service +//! answers the `TableName` sentence in either member order (measured +//! 2026-11-07). U+0000 in the start ARN's account, region or //! partition is accepted by the service and by storage, which binds only //! the table segment and the label. //! - A `RequestItems` key outside the pattern: `at 'requestItems'` with the @@ -74,8 +78,13 @@ const LIST_STREAMS_NAME: &str = /// Refuse a table name outside the service's character pattern, in any field /// the operation carries one, before the name is used in any lookup. A -/// missing, non-string or empty name passes. -pub(crate) fn check_table_name_chars(operation: &str, input: &Value) -> Result<(), DynamoDbError> { +/// missing, non-string or empty name passes. `account_id` is the caller's +/// account, which the control-plane ARN rules compare with the ARN's. +pub(crate) fn check_table_name_chars( + operation: &str, + input: &Value, + account_id: &str, +) -> Result<(), DynamoDbError> { match operation { "BatchGetItem" | "BatchWriteItem" => check_request_items_keys(operation, input), "TransactGetItems" => check_transact_items(input, &["Get"]), @@ -117,7 +126,10 @@ pub(crate) fn check_table_name_chars(operation: &str, input: &Value) -> Result<( ), "ListStreams" => { match input.get("TableName").and_then(Value::as_str) { - Some(name) if !name.is_empty() && !in_pattern(name) => { + Some(name) + if !name.is_empty() + && (!in_pattern(name) || !(3..=255).contains(&name.len())) => + { return Err(DynamoDbError::ValidationException( LIST_STREAMS_NAME.to_owned(), )); @@ -129,7 +141,7 @@ pub(crate) fn check_table_name_chars(operation: &str, input: &Value) -> Result<( with_str(input, "ExclusiveStartStreamArn", arn_gate::check_stream_arn) } "Query" | "Scan" => { - check_name(input.get("TableName"), "tableName", operation)?; + check_name(input.get("TableName"), "tableName", operation, account_id)?; match input.get("IndexName").and_then(Value::as_str) { // Pattern only: the length clauses for an index name are unmeasured. Some(index) if !index.is_empty() && !in_pattern(index) => { @@ -138,7 +150,7 @@ pub(crate) fn check_table_name_chars(operation: &str, input: &Value) -> Result<( _ => Ok(()), } } - _ => check_name(input.get("TableName"), "tableName", operation), + _ => check_name(input.get("TableName"), "tableName", operation, account_id), } } @@ -167,12 +179,17 @@ fn one_error(value: &str, location: &str, constraint: &str) -> DynamoDbError { /// A table name field: a bare name outside the pattern is refused with the /// pattern clause (and the length clause when that fails too); an `arn:` value /// goes through the ARN rules. -fn check_name(value: Option<&Value>, location: &str, operation: &str) -> Result<(), DynamoDbError> { +fn check_name( + value: Option<&Value>, + location: &str, + operation: &str, + account_id: &str, +) -> Result<(), DynamoDbError> { let Some(name) = value.and_then(Value::as_str) else { return Ok(()); }; if name.starts_with("arn:") { - return arn_gate::check_table_name_arn(operation, name); + return arn_gate::check_table_name_arn(operation, name, account_id); } if name.is_empty() || in_pattern(name) { return Ok(()); @@ -307,7 +324,7 @@ mod tests { const PAT: &str = "Member must satisfy regular expression pattern: [a-zA-Z0-9_.-]+"; fn message(operation: &str, input: Value) -> Option { - match check_table_name_chars(operation, &input) { + match check_table_name_chars(operation, &input, "123456789012") { Ok(()) => None, Err(DynamoDbError::ValidationException(m)) => Some(m), Err(other) => panic!("unexpected error {other:?}"), @@ -495,6 +512,43 @@ mod tests { assert!(message("ListStreams", json!({})).is_none()); } + /// Measured 2026-11-07: 1, 2 and 256 characters get the sentence, alone + /// and beside a bad `ExclusiveStartStreamArn`, in either member order; 3 + /// and 255 characters pass. + #[test] + fn list_streams_refuses_a_name_of_a_bad_length_before_the_start_arn() { + let start = "arn:aws:dynamodb:us-east-1:123456789012:table/t367name/stream/2026\u{0}"; + for name in [ + "a".to_owned(), + "ab".to_owned(), + "a".repeat(256), + NUL.to_owned(), + ] { + for input in [ + json!({ "TableName": name }), + json!({ "TableName": name, "ExclusiveStartStreamArn": start }), + json!({ "ExclusiveStartStreamArn": start, "TableName": name }), + ] { + assert_eq!( + message("ListStreams", input).as_deref(), + Some(LIST_STREAMS_NAME), + "{name:?}" + ); + } + } + for name in ["abc".to_owned(), "a".repeat(255)] { + assert!(message("ListStreams", json!({ "TableName": name })).is_none()); + assert_eq!( + message( + "ListStreams", + json!({ "TableName": name, "ExclusiveStartStreamArn": start }) + ) + .as_deref(), + Some("Invalid StreamArn") + ); + } + } + #[test] fn arn_bearing_fields_are_checked_before_authorization() { let m = message("DescribeTable", json!({ "TableName": ARN })).expect("refused"); diff --git a/tests/python/test_nul_arn_shapes.py b/tests/python/test_nul_arn_shapes.py index a8042ca4e..f7a4919b3 100644 --- a/tests/python/test_nul_arn_shapes.py +++ b/tests/python/test_nul_arn_shapes.py @@ -15,11 +15,13 @@ The account segment rows (`TestAccountSegment...`) and the `ListStreams` `ExclusiveStartStreamArn` rows were measured against the service on -2026-11-06 (us-east-1). An account segment with U+0000, a letter, 11 or 13 -digits gets the answer for an account that does not exist in every ARN field -the cross-account check reads, and in `TableName` of the control-plane -operations; the item operations, the transaction members and `BatchGetItem` -refuse it as a validation error. +2026-11-06 (us-east-1), and the `TestArnCheckOrder` and +`TestListStreamsTableName` rows on 2026-11-07. An account segment with +U+0000, a letter, 11 or 13 digits gets the answer for an account that does +not exist in every ARN field the cross-account check reads, and in +`TableName` of the control-plane operations whatever the ARN's shape; the +item operations, the transaction members and `BatchGetItem` refuse it as a +validation error after the shape, vendor, region and table segment checks. """ import time @@ -103,12 +105,11 @@ def test_foreign_service_arn_on_item_operations_gets_the_frame(self, raw_client) with pytest.raises(ClientError) as exc: raw_client.get_item(TableName=arn, Key={"pk": {"S": "a"}}) _at_table_name(exc.value, arn, FRAME) - # DescribeTable answers AccessDeniedException on the service (measured - # 2026-10-24) and the engine's own ValidationException on ExtendDB; - # neither is a 500, and the row is not asserted beyond that. + # DescribeTable answers the empty account segment first, as another + # account (measured 2026-10-24 and 2026-11-07; see TestArnCheckOrder). with pytest.raises(ClientError) as exc: raw_client.describe_table(TableName=arn) - assert exc.value.response["ResponseMetadata"]["HTTPStatusCode"] == 400 + _assert_denied(exc.value) def test_bad_account_and_region_segments_name_their_constraint(self, raw_client, arns): acct = arns["account"] @@ -228,6 +229,47 @@ def _raises(call) -> ClientError: return exc.value +def _arn(caller, account, resource, service="dynamodb", region=None): + region = caller["region"] if region is None else region + return f"arn:{caller['partition']}:{service}:{region}:{account}:{resource}" + + +def _control_plane_reads(client, arn): + return [ + lambda: client.describe_table(TableName=arn), + lambda: client.describe_time_to_live(TableName=arn), + lambda: client.describe_continuous_backups(TableName=arn), + lambda: client.list_backups(TableName=arn), + ] + + +def _control_plane_calls(client, arn, create=True): + """Every control-plane operation the gate answers, with `TableName` set to + `arn`. Only ARNs the service refuses are sent.""" + calls = _control_plane_reads(client, arn) + [ + lambda: client.update_table(TableName=arn, BillingMode="PAY_PER_REQUEST"), + lambda: client.update_time_to_live( + TableName=arn, + TimeToLiveSpecification={"Enabled": True, "AttributeName": "ttl"}, + ), + lambda: client.update_continuous_backups( + TableName=arn, + PointInTimeRecoverySpecification={"PointInTimeRecoveryEnabled": False}, + ), + lambda: client.create_backup(TableName=arn, BackupName="b830"), + ] + if create: + calls.append( + lambda: client.create_table( + TableName=arn, + BillingMode="PAY_PER_REQUEST", + KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": "pk", "AttributeType": "S"}], + ) + ) + return calls + + class TestAccountSegmentInArnFields: """The fields the cross-account check reads. On PostgreSQL an account with U+0000 answered 500 `Internal error during authorization` before the @@ -280,29 +322,7 @@ class TestAccountSegmentInTableName: def test_control_plane_operations_answer_as_for_an_unknown_account( self, raw_client, caller, account ): - arn = _table_arn(caller, account) - for call in [ - lambda: raw_client.describe_table(TableName=arn), - lambda: raw_client.update_table(TableName=arn, BillingMode="PAY_PER_REQUEST"), - lambda: raw_client.describe_time_to_live(TableName=arn), - lambda: raw_client.update_time_to_live( - TableName=arn, - TimeToLiveSpecification={"Enabled": True, "AttributeName": "ttl"}, - ), - lambda: raw_client.describe_continuous_backups(TableName=arn), - lambda: raw_client.update_continuous_backups( - TableName=arn, - PointInTimeRecoverySpecification={"PointInTimeRecoveryEnabled": False}, - ), - lambda: raw_client.create_backup(TableName=arn, BackupName="b830"), - lambda: raw_client.list_backups(TableName=arn), - lambda: raw_client.create_table( - TableName=arn, - BillingMode="PAY_PER_REQUEST", - KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}], - AttributeDefinitions=[{"AttributeName": "pk", "AttributeType": "S"}], - ), - ]: + for call in _control_plane_calls(raw_client, _table_arn(caller, account)): _assert_denied(_raises(call)) @pytest.mark.parametrize("account", BAD_ACCOUNTS, ids=BAD_IDS) @@ -380,6 +400,126 @@ def test_control_plane_region_fault_has_no_location(self, raw_client, caller): ) +R_NUL = "us-ea" + NUL + "st-1" +SHAPES = { + "tbl": ("tbl", "dynamodb"), + "slash": ("table/t830name/", "dynamodb"), + "streamab": ("table/t830name/stream/a/b", "dynamodb"), + "index": ("table/t830name/index/gsi1", "dynamodb"), + "stream": (f"table/t830name/stream/{LABEL}", "dynamodb"), + "s3t": ("t", "s3"), + "s3table": ("table/t830name", "s3"), +} + + +class TestArnCheckOrder: + """The order in which the service checks a `TableName` ARN, measured + 2026-11-07. The control-plane operations answer an account that is not + the caller's before anything else. The item operations check the shape, + the vendor, the region, the table segment and the account, in that order. + On PostgreSQL the control-plane rows with U+0000 in the account of a shape + `arn::parse` rejects answered 500 `Internal server error` before the gate + answered them itself.""" + + @pytest.mark.parametrize( + "account,shape", + [ + (BAD_ACCOUNTS[0], "tbl"), + (BAD_ACCOUNTS[0], "slash"), + (BAD_ACCOUNTS[0], "streamab"), + (BAD_ACCOUNTS[0], "s3t"), + (BAD_ACCOUNTS[0], "index"), + (BAD_ACCOUNTS[1], "tbl"), + (BAD_ACCOUNTS[1], "s3table"), + (BAD_ACCOUNTS[2], "slash"), + (BAD_ACCOUNTS[3], "s3t"), + (FOREIGN, "tbl"), + (FOREIGN, "index"), + (FOREIGN, "s3table"), + ], + ids=lambda v: {BAD_ACCOUNTS[i]: BAD_IDS[i] for i in range(4)}.get(v, v), + ) + def test_control_plane_answers_another_account_before_the_shape( + self, raw_client, caller, account, shape + ): + resource, service = SHAPES[shape] + arn = _arn(caller, account, resource, service) + # CreateTable with a well-formed account is not sent to the service. + for call in _control_plane_calls(raw_client, arn, create=account != FOREIGN): + _assert_denied(_raises(call)) + + def test_control_plane_empty_account_is_another_account(self, raw_client): + for arn in ["arn:aws:s3:::t830" + NUL + "name", "arn:aws:dynamodb:us-east-1::table/t830name"]: + for call in _control_plane_reads(raw_client, arn)[:3]: + _assert_denied(_raises(call)) + + def test_control_plane_in_the_callers_account(self, raw_client, caller): + own = caller["account"] + for arn in [ + _arn(caller, own, "tbl"), + _arn(caller, own, "table/t830name/"), + _arn(caller, own, "table/t830name/index/gsi1"), + _arn(caller, own, "t", "s3"), + _arn(caller, own, "tbl", region=R_NUL), + ]: + for call in _control_plane_reads(raw_client, arn): + _at_table_name(_raises(call), arn, FRAME) + for arn in [ + _arn(caller, own, "table/t830name", "s3"), + _arn(caller, own, "table/t830name", "s3", region=R_NUL), + ]: + for call in _control_plane_reads(raw_client, arn): + assert_validation( + _raises(call), f"1 validation error detected: Invalid vendor code in '{arn}'" + ) + + def test_item_operations_check_the_shape_vendor_region_and_table_segment_first( + self, raw_client, caller + ): + own = caller["account"] + cases = [ + (_arn(caller, BAD_ACCOUNTS[0], "tbl"), FRAME), + (_arn(caller, BAD_ACCOUNTS[2], "table/t830name/"), FRAME), + (_arn(caller, BAD_ACCOUNTS[3], "table/t830name/index/gsi1"), FRAME), + (_arn(caller, BAD_ACCOUNTS[1], SHAPES["stream"][0]), FRAME), + (_arn(caller, BAD_ACCOUNTS[0], "t", "s3"), FRAME), + (_arn(caller, own, "tbl", region=R_NUL), FRAME), + (_arn(caller, BAD_ACCOUNTS[1], "table/t830name", "s3"), "Invalid vendor code"), + (_arn(caller, own, "table/t830name", "s3"), "Invalid vendor code"), + (_arn(caller, BAD_ACCOUNTS[0], "table/t830name", region=R_NUL), "Invalid AWS region"), + (_arn(caller, BAD_ACCOUNTS[0], "table/t830" + NUL + "name"), TABLE_OF_ARN), + (_arn(caller, BAD_ACCOUNTS[2], "table/t830" + NUL + "name"), TABLE_OF_ARN), + (_arn(caller, "", "table/t830name"), "Invalid subscriber ID"), + ] + key = {"pk": {"S": "a"}} + for arn, constraint in cases: + _at_table_name(_raises(lambda: raw_client.get_item(TableName=arn, Key=key)), arn, constraint) + assert_validation( + _raises( + lambda: raw_client.transact_get_items( + TransactItems=[{"Get": {"TableName": arn, "Key": key}}] + ) + ), + f"1 validation error detected: Value '{arn}' at " + f"'transactItems.1.member.get.tableName' failed to satisfy constraint: {constraint}", + ) + + def test_empty_account_in_batch_keys(self, raw_client, caller): + arn = _arn(caller, "", "table/t830name") + key = {"pk": {"S": "a"}} + exc = _raises(lambda: raw_client.batch_get_item(RequestItems={arn: {"Keys": [key]}})) + assert exc.response["Error"]["Code"] == "ValidationException", exc.response["Error"] + assert exc.response["Error"]["Message"].endswith( + f"' at 'requestItems' failed to satisfy constraint: {MAP_KEYS_ARN}" + ), exc.response["Error"]["Message"] + # BatchWriteItem answered `Access is denied` three times and the + # map-key constraint once (2026-11-07); only a 400 is asserted. + exc = _raises( + lambda: raw_client.batch_write_item(RequestItems={arn: [{"PutRequest": {"Item": key}}]}) + ) + assert exc.response["ResponseMetadata"]["HTTPStatusCode"] == 400 + + def _list_streams(client, **params): """ListStreams, sent again after a ThrottlingException (the service's rate for this operation is low).""" @@ -419,3 +559,33 @@ def test_nul_in_the_account_or_region_is_accepted(self, raw_streams_client, call resp = _list_streams(raw_streams_client, ExclusiveStartStreamArn=start, Limit=1) assert resp["ResponseMetadata"]["HTTPStatusCode"] == 200 assert "Streams" in resp + + +LIST_STREAMS_NAME = "TableName must be at least 3 characters long and at most 255 characters long" + + +class TestListStreamsTableName: + """`ListStreams` answers its `TableName` sentence for a name of a bad + length and before a bad `ExclusiveStartStreamArn`, in either member order + (measured 2026-11-07).""" + + def test_a_bad_table_name_is_answered_before_a_bad_start_arn(self, raw_streams_client, caller): + start = f"{_table_arn(caller, caller['account'], table=caller['name'])}/stream/2026{NUL}" + for name in ["t830" + NUL + "name", "a b", "ab", "a" * 256]: + for params in [ + {"TableName": name, "ExclusiveStartStreamArn": start}, + {"ExclusiveStartStreamArn": start, "TableName": name}, + ]: + with pytest.raises(ClientError) as exc: + _list_streams(raw_streams_client, **params) + assert_validation(exc.value, LIST_STREAMS_NAME) + + def test_table_name_length(self, raw_streams_client): + for name in ["a", "ab", "a" * 256]: + with pytest.raises(ClientError) as exc: + _list_streams(raw_streams_client, TableName=name) + assert_validation(exc.value, LIST_STREAMS_NAME) + for name in ["abc", "a" * 255]: + resp = _list_streams(raw_streams_client, TableName=name) + assert resp["ResponseMetadata"]["HTTPStatusCode"] == 200 + assert resp["Streams"] == []