diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 000000000..b734b82b8 --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -0,0 +1,131 @@ +# Copyright 2026 ExtendDB contributors +# SPDX-License-Identifier: Apache-2.0 +# +# Fuzz the request parsers that read untrusted input: the SigV4 authorization +# header, timestamp and signature verification, the expression tokenizer and +# parsers, the expression evaluator, and the IAM policy document parser. +# +# Pull requests that touch crates/core, crates/auth or the fuzz crate run each +# target for a short time as a smoke test. A daily schedule runs each target +# for longer. Each run starts from the committed seeds in fuzz/seeds and writes +# the inputs it generates under $RUNNER_TEMP, outside the checkout. A crash or +# a timeout uploads the reproducing input under fuzz/artifacts. +name: Fuzz + +on: + schedule: + - cron: '23 5 * * *' + workflow_dispatch: + pull_request: + paths: + - 'Cargo.toml' + - 'Cargo.lock' + - 'crates/core/**' + - 'crates/auth/**' + - 'crates/cache/**' + - 'fuzz/**' + - '.github/workflows/fuzz.yml' + +permissions: + contents: read + +# Supersede an in-flight run of the SAME pull request when a new push arrives, +# so repeated pushes do not stack up on a capped pool. Non-PR runs get a group +# unique to the run (run_id). +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + fuzz: + # Dedicated ExtendDB runner group (Ubuntu 24.04, 4 vCPU / 16 GB, capped at + # 20 concurrent). + runs-on: extenddb_ubuntu-2404_4-core + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + target: + - sigv4_authorization + - sigv4_timestamp + - sigv4_verify + - expression_parse + - expression_evaluate + - policy_document + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master + with: + # Pinned nightly: cargo-fuzz needs the sanitizer and coverage + # instrumentation that only nightly provides. + toolchain: nightly-2026-11-01 + + - name: Generate fuzz/Cargo.lock from Cargo.lock + # fuzz/ is its own workspace, and its lockfile is not committed. It + # starts as a copy of Cargo.lock, and cargo metadata adds the crates + # only fuzz/ uses, so the targets build each shared crate at the + # version the server builds. A version bump or dependency update in + # Cargo.lock needs no change here. For each crate that both lockfiles + # list, the awk check fails when fuzz/Cargo.lock has it at a version + # Cargo.lock does not have. + run: | + set -euo pipefail + cp Cargo.lock fuzz/Cargo.lock + cargo metadata --format-version 1 --manifest-path fuzz/Cargo.toml > /dev/null + versions() { + awk '/^\[\[package\]\]/ { p = 1; next } + p && /^name = / { n = $3 } + p && /^version = / { print n, $3; p = 0 }' "$1" | tr -d '"' | sort -u + } + versions Cargo.lock > "$RUNNER_TEMP/root-versions" + versions fuzz/Cargo.lock > "$RUNNER_TEMP/fuzz-versions" + awk 'NR == FNR { root[$0] = 1; name[$1] = 1; next } + ($1 in name) && !($0 in root) { + print "fuzz/Cargo.lock has " $1 " " $2 ", which Cargo.lock does not have" + bad = 1 + } + END { exit bad }' "$RUNNER_TEMP/root-versions" "$RUNNER_TEMP/fuzz-versions" + + - name: Install cargo-fuzz from its .crate with a sha256 check + # Download, extract and install outside the checkout. Inside it, cargo + # install finds the root [workspace], which does not list the crate, + # and stops. + run: | + set -euo pipefail + cd "$RUNNER_TEMP" + version=0.13.2 + sha256=5acfd01930e49823e58c30dd8012d3338a620377d7c7d4cc140ca4b2169400e2 + curl -sSfL -o cargo-fuzz.crate \ + "https://static.crates.io/crates/cargo-fuzz/cargo-fuzz-${version}.crate" + echo "${sha256} cargo-fuzz.crate" | sha256sum -c - + mkdir -p cargo-fuzz-src + tar -xzf cargo-fuzz.crate -C cargo-fuzz-src + cargo install --locked --path "cargo-fuzz-src/cargo-fuzz-${version}" + + - name: Fuzz ${{ matrix.target }} + # libFuzzer writes the inputs it generates to the first corpus + # directory and only reads the second, so the committed seeds are not + # modified. The first directory has to exist before libFuzzer starts. + run: | + set -euo pipefail + if [ "${{ github.event_name }}" = "pull_request" ]; then + seconds=60 + else + seconds=600 + fi + corpus="$RUNNER_TEMP/corpus/${{ matrix.target }}" + mkdir -p "$corpus" + cargo fuzz run "${{ matrix.target }}" "$corpus" "fuzz/seeds/${{ matrix.target }}" -- \ + -max_total_time="${seconds}" \ + -rss_limit_mb=4096 \ + -timeout=25 + + - name: Upload the reproducing input on failure + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: fuzz-artifacts-${{ matrix.target }} + path: fuzz/artifacts + if-no-files-found: ignore diff --git a/fuzz/.gitignore b/fuzz/.gitignore new file mode 100644 index 000000000..7c8a7bfe8 --- /dev/null +++ b/fuzz/.gitignore @@ -0,0 +1,9 @@ +# Build output and fuzzing runtime state. The committed seeds are in seeds/. +# cargo fuzz writes generated inputs to corpus/ when it is given no corpus +# directory, so corpus/ is ignored. Cargo.lock is generated from the root +# Cargo.lock before each build (see Cargo.toml). +target +artifacts +corpus +coverage +Cargo.lock diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 000000000..87f576eff --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,84 @@ +# Copyright 2026 ExtendDB contributors +# SPDX-License-Identifier: Apache-2.0 +# +# cargo-fuzz harness for the request parsers that read untrusted input. This is +# its own workspace (see the [workspace] section at the end) so the root +# workspace, its Cargo.lock and its license notices are unaffected. +# +# fuzz/Cargo.lock is not committed. Before a local build, create it the way the +# Fuzz workflow does, so every crate shared with the server is at the server's +# version: copy Cargo.lock to fuzz/Cargo.lock, then run +# `cargo metadata --format-version 1 --manifest-path fuzz/Cargo.toml`. +[package] +name = "extenddb-fuzz" +version = "0.0.0" +publish = false +edition = "2021" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +# Pinned for reproducibility. libfuzzer-sys brings its own libFuzzer runtime, +# built from source by its cc build script, so no system libFuzzer is needed. +libfuzzer-sys = "=0.4.13" +# Used only to turn fuzzer bytes into structured items, maps and headers. +arbitrary = "=1.5.0" +# The sigv4 verify and timestamp entry points take axum::http::HeaderMap, which +# is a re-export of this crate's HeaderMap. The same major version resolves in +# the dependency graph, so the type matches. +http = "1" + +extenddb-core = { path = "../crates/core" } +extenddb-auth = { path = "../crates/auth" } + +[lib] +path = "src/lib.rs" + +[[bin]] +name = "sigv4_authorization" +path = "fuzz_targets/sigv4_authorization.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "sigv4_timestamp" +path = "fuzz_targets/sigv4_timestamp.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "sigv4_verify" +path = "fuzz_targets/sigv4_verify.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "expression_parse" +path = "fuzz_targets/expression_parse.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "expression_evaluate" +path = "fuzz_targets/expression_evaluate.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "policy_document" +path = "fuzz_targets/policy_document.rs" +test = false +doc = false +bench = false + +# Independent workspace so the root workspace does not pick up the fuzz crate or +# its dependencies. +[workspace] +members = ["."] +resolver = "2" diff --git a/fuzz/fuzz_targets/expression_evaluate.rs b/fuzz/fuzz_targets/expression_evaluate.rs new file mode 100644 index 000000000..a93040720 --- /dev/null +++ b/fuzz/fuzz_targets/expression_evaluate.rs @@ -0,0 +1,55 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +// Mirrors evaluating a parsed expression against an item, which every storage +// backend does, for example crates/storage-sqlite/src/data/query.rs and +// update_item.rs call evaluate_condition and apply_update_validated. The first +// byte selects a condition or an update; the generated maps and item stand in +// for ExpressionAttributeNames, ExpressionAttributeValues and the stored item; +// the remaining bytes are the expression string. The expression is bounded by +// max_expression_length_bytes the way the server bounds a request expression. +// +// Every generated number, in the item or in the values map, goes through +// extenddb_core::validation::number::validate_and_normalize_number. A number +// it refuses is dropped, and a number it keeps is in its normalized form. + +#![no_main] + +use arbitrary::{Arbitrary, Unstructured}; +use libfuzzer_sys::fuzz_target; + +use extenddb_core::expression::{ + apply_update_validated, evaluate_condition, parse_condition_with_depth_limit, + parse_update_from, tokenize_for, tokenize_with_limit, ExpressionKind, +}; +use extenddb_core::limits::LimitsConfig; + +fuzz_target!(|data: &[u8]| { + let mut u = Unstructured::new(data); + let is_update = bool::arbitrary(&mut u).unwrap_or(false); + let maps = extenddb_fuzz::arb_expression_maps(&mut u); + let mut item = extenddb_fuzz::arb_item(&mut u); + let rest = u.take_rest(); + let Ok(expr) = std::str::from_utf8(rest) else { + return; + }; + let limits = LimitsConfig::default(); + if expr.len() > limits.max_expression_length_bytes { + return; + } + let max_tokens = limits.max_expression_tokens; + if is_update { + if let Ok(tokens) = tokenize_for(expr, max_tokens, ExpressionKind::Update) { + if let Ok(actions) = parse_update_from(&tokens, expr) { + // Empty vector-index and attribute-definition slices: a table + // without vector indexes, which is the common case and a valid + // call (see the Cassandra update path). + let _ = apply_update_validated(&actions, &mut item, &maps, &[], &[]); + } + } + } else if let Ok(tokens) = tokenize_with_limit(expr, max_tokens) { + if let Ok(ast) = parse_condition_with_depth_limit(&tokens, limits.max_expression_depth) { + let _ = evaluate_condition(&ast, &item, &maps); + } + } +}); diff --git a/fuzz/fuzz_targets/expression_parse.rs b/fuzz/fuzz_targets/expression_parse.rs new file mode 100644 index 000000000..d29e12086 --- /dev/null +++ b/fuzz/fuzz_targets/expression_parse.rs @@ -0,0 +1,64 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +// Mirrors the engine expression pipeline in +// crates/engine/src/expression_helpers.rs, which the server runs on +// ConditionExpression, KeyConditionExpression, ProjectionExpression and +// UpdateExpression: a byte-size check against max_expression_length_bytes, then +// tokenize with max_expression_tokens, then parse with max_expression_depth. +// The first input byte selects the parser; the rest is the expression string. +// The limits are LimitsConfig::default(), the server's defaults. The reserved +// word check is left out so the parsers are reached on every token stream the +// tokenizer can emit. +// +// The authorization leading-keys check in crates/server/src/leading_keys.rs +// parses a KeyConditionExpression with its own sequence of calls and no +// byte-size check. This target does not run that sequence. + +#![no_main] + +use libfuzzer_sys::fuzz_target; + +use extenddb_core::expression::{ + parse_condition_with_depth_limit, parse_key_condition, parse_projection, parse_update_from, + tokenize_for, tokenize_with_limit, ExpressionKind, +}; +use extenddb_core::limits::LimitsConfig; + +fuzz_target!(|data: &[u8]| { + if data.is_empty() { + return; + } + let which = data[0] % 4; + let Ok(expr) = std::str::from_utf8(&data[1..]) else { + return; + }; + let limits = LimitsConfig::default(); + // The engine rejects an over-size expression before tokenizing. + if expr.len() > limits.max_expression_length_bytes { + return; + } + let max_tokens = limits.max_expression_tokens; + match which { + 0 => { + if let Ok(tokens) = tokenize_with_limit(expr, max_tokens) { + let _ = parse_condition_with_depth_limit(&tokens, limits.max_expression_depth); + } + } + 1 => { + if let Ok(tokens) = tokenize_for(expr, max_tokens, ExpressionKind::KeyCondition) { + let _ = parse_key_condition(&tokens); + } + } + 2 => { + if let Ok(tokens) = tokenize_for(expr, max_tokens, ExpressionKind::Projection) { + let _ = parse_projection(&tokens); + } + } + _ => { + if let Ok(tokens) = tokenize_for(expr, max_tokens, ExpressionKind::Update) { + let _ = parse_update_from(&tokens, expr); + } + } + } +}); diff --git a/fuzz/fuzz_targets/policy_document.rs b/fuzz/fuzz_targets/policy_document.rs new file mode 100644 index 000000000..c2949bc4c --- /dev/null +++ b/fuzz/fuzz_targets/policy_document.rs @@ -0,0 +1,26 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +// Mirrors the parse paths for identity policies and permissions boundaries. On +// a write, crates/server/src/management/iam_policy.rs and +// permissions_boundary.rs call PolicyDocument::from_json_for_write; on the +// request path, crates/server/src/authz_cache.rs calls PolicyDocument::from_json +// on a stored identity policy, permissions boundary or session policy. +// from_json caps the document at 6,144 bytes itself. Both are driven here with +// the same arbitrary JSON text. A trust policy is checked on write by +// validate_trust_policy in crates/server/src/management/assume_role.rs, which +// this target does not call. AssumeRole evaluates its conditions with +// parse_conditions, which from_json also calls. + +#![no_main] + +use libfuzzer_sys::fuzz_target; + +use extenddb_auth::policy::document::PolicyDocument; + +fuzz_target!(|data: &[u8]| { + if let Ok(json) = std::str::from_utf8(data) { + let _ = PolicyDocument::from_json(json); + let _ = PolicyDocument::from_json_for_write(json); + } +}); diff --git a/fuzz/fuzz_targets/sigv4_authorization.rs b/fuzz/fuzz_targets/sigv4_authorization.rs new file mode 100644 index 000000000..b48312714 --- /dev/null +++ b/fuzz/fuzz_targets/sigv4_authorization.rs @@ -0,0 +1,19 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +// Mirrors the server authentication entry point. In crates/server/src/handler.rs +// the handler calls state.auth.authenticate, which in crates/auth/src/lib.rs +// calls sigv4::parse::parse_authorization on the Authorization header string. +// parse_authorization caps the header at 8 KB itself, so no extra bound is +// applied here. + +#![no_main] + +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + // The header arrives as an HTTP header value, which is read as a string. + if let Ok(header) = std::str::from_utf8(data) { + let _ = extenddb_auth::sigv4::parse::parse_authorization(header); + } +}); diff --git a/fuzz/fuzz_targets/sigv4_timestamp.rs b/fuzz/fuzz_targets/sigv4_timestamp.rs new file mode 100644 index 000000000..746d50227 --- /dev/null +++ b/fuzz/fuzz_targets/sigv4_timestamp.rs @@ -0,0 +1,22 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +// Mirrors the server authentication path. In crates/auth/src/lib.rs +// authenticate calls sigv4::verify::validate_timestamp on the request headers, +// which reads the x-amz-date header and parses an ISO-8601 basic timestamp. The +// handler feeds the request's own headers. Here the x-amz-date value is chosen +// by the fuzzer; a few other arbitrary headers are added, which the parser +// ignores. + +#![no_main] + +use arbitrary::Unstructured; +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + let mut u = Unstructured::new(data); + let mut headers = extenddb_fuzz::build_header_map(&mut u); + let date = u.take_rest(); + extenddb_fuzz::insert_header(&mut headers, "x-amz-date", date); + let _ = extenddb_auth::sigv4::verify::validate_timestamp(&headers); +}); diff --git a/fuzz/fuzz_targets/sigv4_verify.rs b/fuzz/fuzz_targets/sigv4_verify.rs new file mode 100644 index 000000000..613ef64ce --- /dev/null +++ b/fuzz/fuzz_targets/sigv4_verify.rs @@ -0,0 +1,48 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +// Mirrors BuiltinAuthProvider::authenticate in crates/auth/src/lib.rs. It reads +// the Authorization header from the request headers as a string, parses it +// with sigv4::parse::parse_authorization, and calls +// sigv4::verify::verify_signature with "POST", "/", an empty query string, the +// request headers and the request body. Here a fixed secret key takes the +// place of the credential store lookup. The timestamp window check is left +// out because it depends on the clock; the sigv4_timestamp target covers it. +// +// Input layout, with lines separated by '\n': +// - The first line is the Authorization header value. The target also +// stores it in the header map as authorization, as the server's map +// holds it. +// - Each following line, up to the first empty line, is one request header +// written as name:value. Spaces and tabs around the value are trimmed. +// - The bytes after the empty line are the request body. +// A line that is not a valid header name and value is skipped, as the HTTP +// layer would reject it before authentication runs. +// +// The seed in fuzz/seeds/sigv4_verify carries a valid signature for its own +// headers and body under this secret key, so it reaches the Ok(()) return of +// verify_signature. + +#![no_main] + +use libfuzzer_sys::fuzz_target; + +use extenddb_auth::sigv4::parse::parse_authorization; +use extenddb_auth::sigv4::verify::verify_signature; + +// The AWS documentation example secret key. It is not a real credential; it +// only has to be a fixed string so the derived signature is deterministic. +const SECRET_KEY: &str = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"; + +fuzz_target!(|data: &[u8]| { + let (headers, body) = extenddb_fuzz::request_from_lines(data); + // authenticate rejects a request whose Authorization header is missing or + // is not a string before it parses anything. + let Some(header) = headers.get("authorization").and_then(|v| v.to_str().ok()) else { + return; + }; + let Ok(parsed) = parse_authorization(header) else { + return; + }; + let _ = verify_signature(&parsed, SECRET_KEY, "POST", "/", "", &headers, body); +}); diff --git a/fuzz/seeds/expression_evaluate/condition b/fuzz/seeds/expression_evaluate/condition new file mode 100644 index 000000000..86ca2c1f1 --- /dev/null +++ b/fuzz/seeds/expression_evaluate/condition @@ -0,0 +1 @@ + attribute_not_exists(a) \ No newline at end of file diff --git a/fuzz/seeds/expression_evaluate/condition_with_maps b/fuzz/seeds/expression_evaluate/condition_with_maps new file mode 100644 index 000000000..a688d9187 Binary files /dev/null and b/fuzz/seeds/expression_evaluate/condition_with_maps differ diff --git a/fuzz/seeds/expression_evaluate/update b/fuzz/seeds/expression_evaluate/update new file mode 100644 index 000000000..e99dea23b --- /dev/null +++ b/fuzz/seeds/expression_evaluate/update @@ -0,0 +1 @@ +! REMOVE a \ No newline at end of file diff --git a/fuzz/seeds/expression_evaluate/update_with_numbers b/fuzz/seeds/expression_evaluate/update_with_numbers new file mode 100644 index 000000000..275ba35e5 Binary files /dev/null and b/fuzz/seeds/expression_evaluate/update_with_numbers differ diff --git a/fuzz/seeds/expression_parse/condition b/fuzz/seeds/expression_parse/condition new file mode 100644 index 000000000..7b0aa039a --- /dev/null +++ b/fuzz/seeds/expression_parse/condition @@ -0,0 +1 @@ +@price > :min \ No newline at end of file diff --git a/fuzz/seeds/expression_parse/key_condition b/fuzz/seeds/expression_parse/key_condition new file mode 100644 index 000000000..d15dec151 --- /dev/null +++ b/fuzz/seeds/expression_parse/key_condition @@ -0,0 +1 @@ +Apk = :v \ No newline at end of file diff --git a/fuzz/seeds/expression_parse/projection b/fuzz/seeds/expression_parse/projection new file mode 100644 index 000000000..53b6e1ca8 --- /dev/null +++ b/fuzz/seeds/expression_parse/projection @@ -0,0 +1 @@ +Ba, b.c, d[0] \ No newline at end of file diff --git a/fuzz/seeds/expression_parse/update b/fuzz/seeds/expression_parse/update new file mode 100644 index 000000000..6044bbd2f --- /dev/null +++ b/fuzz/seeds/expression_parse/update @@ -0,0 +1 @@ +CSET a = :v REMOVE b \ No newline at end of file diff --git a/fuzz/seeds/policy_document/allow_all b/fuzz/seeds/policy_document/allow_all new file mode 100644 index 000000000..167807b2b --- /dev/null +++ b/fuzz/seeds/policy_document/allow_all @@ -0,0 +1 @@ +{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"dynamodb:*","Resource":"*"}]} \ No newline at end of file diff --git a/fuzz/seeds/policy_document/deny_not_action b/fuzz/seeds/policy_document/deny_not_action new file mode 100644 index 000000000..15fcf6d9f --- /dev/null +++ b/fuzz/seeds/policy_document/deny_not_action @@ -0,0 +1 @@ +{"Version":"2012-10-17","Statement":[{"Effect":"Deny","NotAction":["dynamodb:GetItem"],"Resource":"*"}]} \ No newline at end of file diff --git a/fuzz/seeds/policy_document/ip_condition b/fuzz/seeds/policy_document/ip_condition new file mode 100644 index 000000000..c60072fe4 --- /dev/null +++ b/fuzz/seeds/policy_document/ip_condition @@ -0,0 +1 @@ +{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"dynamodb:GetItem","Resource":"*","Condition":{"IpAddress":{"aws:SourceIp":["10.0.0.0/8","2001:db8::/32"]}}}]} \ No newline at end of file diff --git a/fuzz/seeds/sigv4_authorization/valid_vector b/fuzz/seeds/sigv4_authorization/valid_vector new file mode 100644 index 000000000..03b94863a --- /dev/null +++ b/fuzz/seeds/sigv4_authorization/valid_vector @@ -0,0 +1 @@ +AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20260415/us-east-1/dynamodb/aws4_request, SignedHeaders=content-type;host;x-amz-date;x-amz-target, Signature=abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890 \ No newline at end of file diff --git a/fuzz/seeds/sigv4_authorization/wrong_algo b/fuzz/seeds/sigv4_authorization/wrong_algo new file mode 100644 index 000000000..808463d38 --- /dev/null +++ b/fuzz/seeds/sigv4_authorization/wrong_algo @@ -0,0 +1 @@ +AWS4-HMAC-SHA512 Credential=AKID/20260415/us-east-1/dynamodb/aws4_request, SignedHeaders=host, Signature=abc \ No newline at end of file diff --git a/fuzz/seeds/sigv4_timestamp/aws_vector_date b/fuzz/seeds/sigv4_timestamp/aws_vector_date new file mode 100644 index 000000000..9792a4cd6 --- /dev/null +++ b/fuzz/seeds/sigv4_timestamp/aws_vector_date @@ -0,0 +1 @@ + 20150830T123600Z \ No newline at end of file diff --git a/fuzz/seeds/sigv4_timestamp/valid_date b/fuzz/seeds/sigv4_timestamp/valid_date new file mode 100644 index 000000000..acd28449f --- /dev/null +++ b/fuzz/seeds/sigv4_timestamp/valid_date @@ -0,0 +1 @@ + 20260415T120000Z \ No newline at end of file diff --git a/fuzz/seeds/sigv4_verify/signed_request b/fuzz/seeds/sigv4_verify/signed_request new file mode 100644 index 000000000..352b8599f --- /dev/null +++ b/fuzz/seeds/sigv4_verify/signed_request @@ -0,0 +1,7 @@ +AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20150830/us-east-1/dynamodb/aws4_request, SignedHeaders=content-type;host;x-amz-date;x-amz-target, Signature=34f4239d34842310969b2eacd173c943cac9f842d5a661117fa63667b71870b7 +content-type:application/x-amz-json-1.0 +host:dynamodb.us-east-1.amazonaws.com +x-amz-date:20150830T123600Z +x-amz-target:DynamoDB_20120810.ListTables + +{} \ No newline at end of file diff --git a/fuzz/src/lib.rs b/fuzz/src/lib.rs new file mode 100644 index 000000000..dce3cf6e2 --- /dev/null +++ b/fuzz/src/lib.rs @@ -0,0 +1,235 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Shared helpers for the ExtendDB parser fuzz targets. +//! +//! These turn fuzzer bytes into the structured inputs the parsers and +//! evaluators take: HTTP header maps, a request split into headers and body, +//! items, and expression maps. They live here, outside the product crates, so +//! the six fuzz targets can share them. Nothing here changes product behavior. +//! +//! Byte consumption is intentionally simple and front to back: a single byte +//! gives a small count or selector, then that many sub values are read. At end +//! of input `arbitrary` yields zero bytes, so every count collapses to zero and +//! every generator terminates. + +use std::collections::{BTreeMap, BTreeSet, HashMap}; + +use arbitrary::{Arbitrary, Unstructured}; +use http::{HeaderMap, HeaderName, HeaderValue}; + +use extenddb_core::expression::ExpressionMaps; +use extenddb_core::types::{AttributeValue, Item}; +use extenddb_core::validation::number::validate_and_normalize_number; + +/// Maximum attribute-value nesting a generator produces. The server accepts up +/// to 32 levels. A lower cap keeps each generated input small while still +/// exercising nested list and map walks. +const MAX_DEPTH: u8 = 5; + +/// One byte from the fuzzer. At end of input `arbitrary` returns zero. +fn byte(u: &mut Unstructured) -> u8 { + u8::arbitrary(u).unwrap_or(0) +} + +/// Up to 31 raw bytes, length chosen by one control byte. +fn bytes_vec(u: &mut Unstructured) -> Vec { + let len = (byte(u) & 0x1f) as usize; + u.bytes(len).map(<[u8]>::to_vec).unwrap_or_default() +} + +/// A short string built from raw bytes, lossily decoded. +fn arb_string(u: &mut Unstructured) -> String { + String::from_utf8_lossy(&bytes_vec(u)).into_owned() +} + +const IDENT_CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_"; + +/// A 1 to 8 character identifier made of expression-name characters, so a +/// placeholder or name reference the fuzzer puts in an expression has a chance +/// of resolving against the generated maps. +fn arb_ident(u: &mut Unstructured) -> String { + let len = (byte(u) & 0x7) as usize + 1; + let raw = u.bytes(len).map(<[u8]>::to_vec).unwrap_or_default(); + if raw.is_empty() { + return "a".to_owned(); + } + raw.iter() + .map(|b| IDENT_CHARS[(*b as usize) % IDENT_CHARS.len()] as char) + .collect() +} + +/// Generate a number string, either a valid integer or arbitrary text, and +/// pass it through `validate_and_normalize_number`. Returns the normalized +/// form, or `None` when that function refuses the string. +/// fuzz_targets/expression_evaluate.rs describes the filter. +fn arb_number(u: &mut Unstructured) -> Option { + let raw = if byte(u) & 1 == 0 { + i64::arbitrary(u).unwrap_or(0).to_string() + } else { + arb_string(u) + }; + validate_and_normalize_number(&raw).ok() +} + +fn arb_string_set(u: &mut Unstructured) -> BTreeSet { + let n = (byte(u) & 0x3) as usize + 1; + (0..n).map(|_| arb_string(u)).collect() +} + +/// A number set made of the numbers `arb_number` keeps, or `None` when it +/// keeps none, because a set may not be empty. +fn arb_number_set(u: &mut Unstructured) -> Option> { + let n = (byte(u) & 0x3) as usize + 1; + let set: BTreeSet = (0..n).filter_map(|_| arb_number(u)).collect(); + (!set.is_empty()).then_some(set) +} + +fn arb_binary_set(u: &mut Unstructured) -> BTreeSet> { + let n = (byte(u) & 0x3) as usize + 1; + (0..n).map(|_| bytes_vec(u)).collect() +} + +fn arb_list(u: &mut Unstructured, depth: u8) -> Vec { + let n = (byte(u) & 0x3) as usize; + (0..n) + .filter_map(|_| arb_attribute_value(u, depth + 1)) + .collect() +} + +fn arb_value_map(u: &mut Unstructured, depth: u8) -> BTreeMap { + let n = (byte(u) & 0x3) as usize; + (0..n) + .filter_map(|_| { + let name = arb_string(u); + arb_attribute_value(u, depth + 1).map(|value| (name, value)) + }) + .collect() +} + +/// Build one `AttributeValue`, or `None` for a number or number set that +/// `arb_number` drops. A list or map leaves out the members that are `None`. +/// Recursive variants (`L`, `M`) are only offered below the depth cap. Set +/// variants are always non-empty, which is what the wire deserializer would +/// accept. +pub fn arb_attribute_value(u: &mut Unstructured, depth: u8) -> Option { + let variants: u8 = if depth < MAX_DEPTH { 10 } else { 8 }; + let value = match byte(u) % variants { + 0 => AttributeValue::S(arb_string(u)), + 1 => AttributeValue::N(arb_number(u)?), + 2 => AttributeValue::B(bytes_vec(u)), + 3 => AttributeValue::Bool(byte(u) & 1 == 1), + 4 => AttributeValue::Null, + 5 => AttributeValue::SS(arb_string_set(u)), + 6 => AttributeValue::NS(arb_number_set(u)?), + 7 => AttributeValue::BS(arb_binary_set(u)), + 8 => AttributeValue::L(arb_list(u, depth)), + _ => AttributeValue::M(arb_value_map(u, depth)), + }; + Some(value) +} + +/// Build an item of up to seven attributes. An attribute whose value is `None` +/// is left out. +pub fn arb_item(u: &mut Unstructured) -> Item { + let n = (byte(u) & 0x7) as usize; + (0..n) + .filter_map(|_| { + let name = arb_string(u); + arb_attribute_value(u, 0).map(|value| (name, value)) + }) + .collect() +} + +/// Build the resolved `ExpressionAttributeNames` and `ExpressionAttributeValues` +/// maps. Keys are bare (no `#` or `:` prefix), matching what the engine stores. +/// A value that is `None` is left out. +pub fn arb_expression_maps(u: &mut Unstructured) -> ExpressionMaps { + let name_count = (byte(u) & 0x7) as usize; + let mut names = HashMap::new(); + for _ in 0..name_count { + names.insert(arb_ident(u), arb_string(u)); + } + let value_count = (byte(u) & 0x7) as usize; + let mut values = HashMap::new(); + for _ in 0..value_count { + let key = arb_ident(u); + if let Some(value) = arb_attribute_value(u, 0) { + values.insert(key, value); + } + } + ExpressionMaps::new(names, values) +} + +/// Build a header map of up to three arbitrary name and value pairs. Bytes that +/// are not a valid header name or value are skipped, as the HTTP layer would +/// reject them before these parsers ever run. +pub fn build_header_map(u: &mut Unstructured) -> HeaderMap { + let mut headers = HeaderMap::new(); + let count = (byte(u) & 0x3) as usize; + for _ in 0..count { + let name = bytes_vec(u); + let value = bytes_vec(u); + if let (Ok(n), Ok(v)) = ( + HeaderName::from_bytes(&name), + HeaderValue::from_bytes(&value), + ) { + headers.insert(n, v); + } + } + headers +} + +/// Set one fixed-name header from raw bytes, skipping bytes that are not a valid +/// header value. +pub fn insert_header(headers: &mut HeaderMap, name: &'static str, value: &[u8]) { + if let Ok(v) = HeaderValue::from_bytes(value) { + headers.insert(HeaderName::from_static(name), v); + } +} + +/// Split a `sigv4_verify` input into request headers and a body, using the +/// line layout described in fuzz_targets/sigv4_verify.rs. The first line is +/// stored as the `authorization` header. Each following line up to the first +/// empty line is one `name:value` header, appended in order. A line that is +/// not a valid header name and value is skipped, as the HTTP layer would +/// reject it. The bytes after the empty line are the body. +/// The most request headers hyper's HTTP/1 parser accepts by default. +const MAX_HEADERS: usize = 100; + +pub fn request_from_lines(data: &[u8]) -> (HeaderMap, &[u8]) { + let (head, body) = match data.windows(2).position(|pair| pair == b"\n\n") { + Some(end) => (&data[..end], &data[end + 2..]), + None => (data, &data[data.len()..]), + }; + let mut lines = head.split(|b| *b == b'\n'); + let mut headers = HeaderMap::new(); + if let Some(authorization) = lines.next() { + insert_header(&mut headers, "authorization", authorization); + } + // hyper parses at most MAX_HEADERS request headers, and the server keeps + // that default, so lines past the limit are ignored. + for line in lines.take(MAX_HEADERS - 1) { + let Some(colon) = line.iter().position(|b| *b == b':') else { + continue; + }; + if let (Ok(name), Ok(value)) = ( + HeaderName::from_bytes(&line[..colon]), + HeaderValue::from_bytes(trim_ows(&line[colon + 1..])), + ) { + headers.append(name, value); + } + } + (headers, body) +} + +/// Strip the spaces and tabs HTTP allows around a header value. +fn trim_ows(mut value: &[u8]) -> &[u8] { + while let [b' ' | b'\t', rest @ ..] = value { + value = rest; + } + while let [rest @ .., b' ' | b'\t'] = value { + value = rest; + } + value +}