diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index b734b82b8..2a903160d 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: @@ -20,6 +21,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 +41,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 @@ -104,10 +110,28 @@ 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. + # 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 + # 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 @@ -117,15 +141,20 @@ 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 - - - 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 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/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/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 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]) {