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}" + )