Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 40 additions & 11 deletions .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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/**'
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
62 changes: 62 additions & 0 deletions fuzz/dicts/policy_document.dict
Original file line number Diff line number Diff line change
@@ -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"
16 changes: 7 additions & 9 deletions fuzz/fuzz_targets/sigv4_timestamp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
1 change: 1 addition & 0 deletions fuzz/seeds/policy_document/not_principal_operators
Original file line number Diff line number Diff line change
@@ -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"}}}}
1 change: 1 addition & 0 deletions fuzz/seeds/policy_document/principal_condition
Original file line number Diff line number Diff line change
@@ -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"}}}]}
2 changes: 1 addition & 1 deletion fuzz/seeds/sigv4_timestamp/aws_vector_date
Original file line number Diff line number Diff line change
@@ -1 +1 @@
20150830T123600Z
20150830T123600Z
2 changes: 1 addition & 1 deletion fuzz/seeds/sigv4_timestamp/valid_date
Original file line number Diff line number Diff line change
@@ -1 +1 @@
20260415T120000Z
20260415T120000Z
19 changes: 0 additions & 19 deletions fuzz/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]) {
Expand Down
Loading