From 293477c9ed28ca1f68ebd4b5bb48eb4ccf51d596 Mon Sep 17 00:00:00 2001 From: Lee <29015211+LeeroyHannigan@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:13:45 +0000 Subject: [PATCH 1/4] ci(fuzz): run the Fuzz job only in ExtendDB repositories A fork inherits the daily schedule but not the ExtendDB runner group, so in a fork that enables Actions the schedule queues six jobs for a runner label that the fork does not have. The job now runs only when the repository owner is ExtendDB. The runner group belongs to the organization, so the check reads the repository owner. A pull request from a fork still runs, because its workflow runs in the base repository. The root .cargo/config.toml sets the resolver that cargo metadata uses when the workflow builds fuzz/Cargo.lock, so the pull_request paths now include .cargo/. --- .github/workflows/fuzz.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index b734b82b8..97ab2f3a5 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -20,6 +20,8 @@ on: paths: - 'Cargo.toml' - 'Cargo.lock' + # The resolver setting in .cargo/config.toml applies to the fuzz build. + - '.cargo/**' - 'crates/core/**' - 'crates/auth/**' - 'crates/cache/**' @@ -38,6 +40,9 @@ concurrency: jobs: fuzz: + # A fork inherits the schedule but not the runner group below, so the job + # runs only in repositories that the ExtendDB organization owns. + if: github.repository_owner == 'ExtendDB' # Dedicated ExtendDB runner group (Ubuntu 24.04, 4 vCPU / 16 GB, capped at # 20 concurrent). runs-on: extenddb_ubuntu-2404_4-core From ad9ca95b42a064fac8c4096e2b8872787cd9e926 Mon Sep 17 00:00:00 2001 From: Lee <29015211+LeeroyHannigan@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:13:45 +0000 Subject: [PATCH 2/4] ci(fuzz): keep failing fuzz inputs out of the public log The log and the artifacts of a workflow run in a public repository are public. When an input crashed, timed out or ran out of memory, the workflow uploaded it as an artifact, libFuzzer printed it in the log as bytes and as base64 when it was 256 bytes or less, and cargo-fuzz printed it in full. libFuzzer also wrote an input that ran for more than 10 seconds, and printed it under the same size limit, on a run that passed. The SigV4 targets parse a request before the caller is authenticated, so such an input can be a denial of service that SECURITY.md asks to be reported privately. The Fuzz step now writes the cargo-fuzz and libFuzzer output to a file under $RUNNER_TEMP and prints only the final statistics of a clean run. It fails with a fixed message when libFuzzer exits with an error or leaves any file under fuzz/artifacts, so a slow input fails the job too. The upload step is removed. A new Build step runs cargo fuzz build before the Fuzz step, so build errors stay in the log. Tests: a local run of the step script printed only the statistics for a clean run. With an out-of-memory failure it printed only the error line, and the lines that print the input stayed in the log file. --- .github/workflows/fuzz.yml | 40 +++++++++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index 97ab2f3a5..72b410ed4 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -8,8 +8,9 @@ # 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. +# the inputs it generates under $RUNNER_TEMP, outside the checkout. An input +# that crashes, times out, runs out of memory or runs slowly fails the job, and +# the job does not publish that input (see the Fuzz step). name: Fuzz on: @@ -109,10 +110,27 @@ jobs: tar -xzf cargo-fuzz.crate -C cargo-fuzz-src cargo install --locked --path "cargo-fuzz-src/cargo-fuzz-${version}" + - name: Build ${{ matrix.target }} + # Its own step, so the build output stays in the log. The Fuzz step + # keeps the libFuzzer output out of it. + run: cargo fuzz build "${{ matrix.target }}" + - 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. + # + # The log of a run in a public repository is public. When an input + # crashes, times out, runs out of memory or runs for more than 10 + # seconds, libFuzzer writes it under fuzz/artifacts and prints it in + # the log as bytes and as base64 (up to 256 bytes), and cargo-fuzz + # prints a failing input in full. The SigV4 targets parse a request + # before the caller is authenticated, so such an input can be a denial + # of service that SECURITY.md asks to be reported privately. This step + # writes the cargo-fuzz and libFuzzer output to a file, prints only the + # final statistics of a clean run, and uploads nothing. Any file under + # fuzz/artifacts fails the job, a slow input too, because no log shows + # it. run: | set -euo pipefail if [ "${{ github.event_name }}" = "pull_request" ]; then @@ -122,15 +140,15 @@ jobs: fi corpus="$RUNNER_TEMP/corpus/${{ matrix.target }}" mkdir -p "$corpus" + log="$RUNNER_TEMP/fuzz-${{ matrix.target }}.log" + status=0 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 + -timeout=25 \ + -print_final_stats=1 > "$log" 2>&1 || status=$? + if [ "$status" -ne 0 ] || [ -n "$(find fuzz/artifacts -type f -print -quit 2>/dev/null)" ]; then + echo "::error::The ${{ matrix.target }} fuzz run failed: an input crashed, timed out, ran out of memory or ran slowly, or libFuzzer stopped. This job does not publish the libFuzzer log or the input. Run cargo fuzz run ${{ matrix.target }} locally to reproduce it, and report a security defect privately, as SECURITY.md describes." + exit 1 + fi + grep -E '^(Done |stat::)' "$log" || true From fd707bb216f03c686c2b7caf18e08a1dae3b01ea Mon Sep 17 00:00:00 2001 From: Lee <29015211+LeeroyHannigan@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:13:45 +0000 Subject: [PATCH 3/4] test(fuzz): give sigv4_timestamp only the x-amz-date value validate_timestamp reads one header, x-amz-date. The target built up to three arbitrary headers before it, so part of each input went to header names and values that validate_timestamp does not read. The whole input is now the x-amz-date value. build_header_map, which only this target used, is removed. The two seeds lose the leading byte that set the header count to zero. Tests: a local 30-second run from the seeds made 4.7 million executions and found no failing input. --- fuzz/fuzz_targets/sigv4_timestamp.rs | 16 +++++++--------- fuzz/seeds/sigv4_timestamp/aws_vector_date | 2 +- fuzz/seeds/sigv4_timestamp/valid_date | 2 +- fuzz/src/lib.rs | 19 ------------------- 4 files changed, 9 insertions(+), 30 deletions(-) diff --git a/fuzz/fuzz_targets/sigv4_timestamp.rs b/fuzz/fuzz_targets/sigv4_timestamp.rs index 746d50227..42837f11d 100644 --- a/fuzz/fuzz_targets/sigv4_timestamp.rs +++ b/fuzz/fuzz_targets/sigv4_timestamp.rs @@ -3,20 +3,18 @@ // 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. +// which reads the x-amz-date header and parses an ISO-8601 basic timestamp. It +// reads no other header, so the whole input is the x-amz-date value. An input +// that is not a valid header value leaves the header out, as the HTTP layer +// would reject it before authentication runs. #![no_main] -use arbitrary::Unstructured; +use http::HeaderMap; 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 mut headers = HeaderMap::new(); + extenddb_fuzz::insert_header(&mut headers, "x-amz-date", data); let _ = extenddb_auth::sigv4::verify::validate_timestamp(&headers); }); diff --git a/fuzz/seeds/sigv4_timestamp/aws_vector_date b/fuzz/seeds/sigv4_timestamp/aws_vector_date index 9792a4cd6..8dc9d83ff 100644 --- a/fuzz/seeds/sigv4_timestamp/aws_vector_date +++ b/fuzz/seeds/sigv4_timestamp/aws_vector_date @@ -1 +1 @@ - 20150830T123600Z \ No newline at end of file +20150830T123600Z \ No newline at end of file diff --git a/fuzz/seeds/sigv4_timestamp/valid_date b/fuzz/seeds/sigv4_timestamp/valid_date index acd28449f..188fdb62d 100644 --- a/fuzz/seeds/sigv4_timestamp/valid_date +++ b/fuzz/seeds/sigv4_timestamp/valid_date @@ -1 +1 @@ - 20260415T120000Z \ No newline at end of file +20260415T120000Z \ No newline at end of file diff --git a/fuzz/src/lib.rs b/fuzz/src/lib.rs index dce3cf6e2..6f3461369 100644 --- a/fuzz/src/lib.rs +++ b/fuzz/src/lib.rs @@ -161,25 +161,6 @@ pub fn arb_expression_maps(u: &mut Unstructured) -> ExpressionMaps { 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]) { From 76931500cff9ce26d5dd5fb23ff84b3a376c9936 Mon Sep 17 00:00:00 2001 From: Lee <29015211+LeeroyHannigan@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:13:45 +0000 Subject: [PATCH 4/4] test(fuzz): add a dictionary and two seeds for policy_document Most mutations of the three policy seeds break the JSON, and no seed has a Principal, NotPrincipal or Sid key. In four local 60-second runs from those seeds, no input in the final corpus had a statement with one of those keys, so the runs did not reach parse_principal_value or the Sid check in from_json_for_write. fuzz/dicts/policy_document.dict lists the policy keys, the Effect and Version values, the condition operators with their ForAllValues:, ForAnyValue: and IfExists forms, and CIDR forms. The Fuzz step passes the dictionary of a target with -dict when fuzz/dicts has one. One new seed has a Principal object, NotResource, a valid Sid, and ForAllValues and IfExists conditions. The other is a single statement object with NotPrincipal, NotAction, a Sid that the write path refuses, and the other operator families. Tests: the two seeds raise the start coverage that libFuzzer reports from 397 to 532. In four local 60-second runs with the seeds and the dictionary, each final corpus had 63 to 117 statements with a Principal or NotPrincipal key and 92 to 122 with a Sid. The final coverage was 1472 to 1675, against 1341 to 1595 from the old seeds. --- .github/workflows/fuzz.yml | 6 ++ fuzz/dicts/policy_document.dict | 62 +++++++++++++++++++ .../policy_document/not_principal_operators | 1 + .../seeds/policy_document/principal_condition | 1 + 4 files changed, 70 insertions(+) create mode 100644 fuzz/dicts/policy_document.dict create mode 100644 fuzz/seeds/policy_document/not_principal_operators create mode 100644 fuzz/seeds/policy_document/principal_condition diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index 72b410ed4..2a903160d 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -119,6 +119,7 @@ jobs: # 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. + # A target with a dictionary in fuzz/dicts gets it with -dict. # # The log of a run in a public repository is public. When an input # crashes, times out, runs out of memory or runs for more than 10 @@ -140,9 +141,14 @@ jobs: fi corpus="$RUNNER_TEMP/corpus/${{ matrix.target }}" mkdir -p "$corpus" + dict="" + if [ -f "fuzz/dicts/${{ matrix.target }}.dict" ]; then + dict="-dict=fuzz/dicts/${{ matrix.target }}.dict" + fi log="$RUNNER_TEMP/fuzz-${{ matrix.target }}.log" status=0 cargo fuzz run "${{ matrix.target }}" "$corpus" "fuzz/seeds/${{ matrix.target }}" -- \ + ${dict:+"$dict"} \ -max_total_time="${seconds}" \ -rss_limit_mb=4096 \ -timeout=25 \ diff --git a/fuzz/dicts/policy_document.dict b/fuzz/dicts/policy_document.dict new file mode 100644 index 000000000..1c3d27718 --- /dev/null +++ b/fuzz/dicts/policy_document.dict @@ -0,0 +1,62 @@ +# Copyright 2026 ExtendDB contributors +# SPDX-License-Identifier: Apache-2.0 +# +# libFuzzer dictionary for the policy_document target. Each entry is a token +# that crates/auth/src/policy/document.rs matches: the policy keys, the Effect +# and Version values, the condition operators with their ForAllValues:, +# ForAnyValue: and IfExists forms, and the CIDR forms that IpAddress and +# NotIpAddress values are checked against. The Fuzz workflow passes the file +# with -dict. Keys carry their JSON quotes, so a token that libFuzzer inserts +# can form a valid key. + +"\"Version\"" +"\"2012-10-17\"" +"\"2008-10-17\"" +"\"Statement\"" +"\"Sid\"" +"\"Effect\"" +"\"Allow\"" +"\"Deny\"" +"\"Action\"" +"\"NotAction\"" +"\"Resource\"" +"\"NotResource\"" +"\"Principal\"" +"\"NotPrincipal\"" +"\"AWS\"" +"\"Condition\"" +"\"*\"" +"ForAllValues:" +"ForAnyValue:" +"IfExists" +"StringEquals" +"StringNotEquals" +"StringEqualsIgnoreCase" +"StringNotEqualsIgnoreCase" +"StringLike" +"StringNotLike" +"NumericEquals" +"NumericNotEquals" +"NumericLessThan" +"NumericLessThanEquals" +"NumericGreaterThan" +"NumericGreaterThanEquals" +"DateEquals" +"DateNotEquals" +"DateLessThan" +"DateLessThanEquals" +"DateGreaterThan" +"DateGreaterThanEquals" +"Bool" +"Null" +"ArnEquals" +"ArnNotEquals" +"ArnLike" +"ArnNotLike" +"IpAddress" +"NotIpAddress" +"aws:SourceIp" +"10.0.0.0/8" +"192.0.2.1/32" +"2001:db8::/32" +"::/0" diff --git a/fuzz/seeds/policy_document/not_principal_operators b/fuzz/seeds/policy_document/not_principal_operators new file mode 100644 index 000000000..c303bc67c --- /dev/null +++ b/fuzz/seeds/policy_document/not_principal_operators @@ -0,0 +1 @@ +{"Statement":{"Sid":"Deny_1","Effect":"Deny","NotPrincipal":"*","NotAction":["dynamodb:DeleteItem","dynamodb:Scan"],"Resource":["*"],"Condition":{"ForAnyValue:StringEquals":{"aws:TagKeys":["env","team"]},"Null":{"aws:ResourceTag/env":"true"},"DateGreaterThan":{"aws:CurrentTime":"2020-01-01T00:00:00Z"},"NumericLessThanEquals":{"aws:EpochTime":"1700000000"},"Bool":{"aws:SecureTransport":"false"},"ArnLike":{"aws:PrincipalArn":"arn:aws:iam::*:role/*"},"NotIpAddressIfExists":{"aws:SourceIp":"192.0.2.0/24"}}}} \ No newline at end of file diff --git a/fuzz/seeds/policy_document/principal_condition b/fuzz/seeds/policy_document/principal_condition new file mode 100644 index 000000000..59ba34fa3 --- /dev/null +++ b/fuzz/seeds/policy_document/principal_condition @@ -0,0 +1 @@ +{"Version":"2012-10-17","Statement":[{"Sid":"AllowTeam1","Effect":"Allow","Principal":{"AWS":["arn:aws:iam::123456789012:root"]},"Action":"sts:AssumeRole","NotResource":"arn:aws:dynamodb:us-east-1:123456789012:table/Secret","Condition":{"ForAllValues:StringLike":{"dynamodb:LeadingKeys":["${aws:userid}*"]},"StringEqualsIfExists":{"aws:PrincipalTag/team":"blue"}}}]} \ No newline at end of file