Skip to content
Merged
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
110 changes: 110 additions & 0 deletions .github/workflows/fuzz-nightly.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
name: Fuzz nightly

# libFuzzer over the byte-facing parsers (traceparent header, PayPal decimal
# amounts, config ${VAR} expansion, request-path matching). Nightly, not
# per-PR: fuzzing pays off through accumulated wall-clock and a GROWING
# corpus, so the corpus is carried between runs via the Actions cache and
# each run extends it.
#
# Deliberately NOT the vcpkg/GCC production toolchain: libFuzzer requires
# Clang, and rebuilding the vcpkg world under a second compiler would blow
# any nightly budget. tests/fuzz/CMakeLists.txt is a standalone project that
# compiles the parser TUs directly (std-only; nlohmann/json from the distro
# package is the single third-party header dependency) — configure+build is
# minutes. Rationale in docs/TESTING.md, "Fuzzing".
#
# A crash/OOM/timeout fails the job; the reproducer input is uploaded as the
# `fuzz-reproducers` artifact. Triage: download it and re-run
# ./build-fuzz/<target> <reproducer-file>

on:
schedule:
- cron: '41 2 * * *' # daily, off the top of the hour
workflow_dispatch:
inputs:
seconds_per_target:
description: 'Fuzzing time per target (seconds)'
required: false
default: '120'

permissions:
contents: read

concurrency:
group: fuzz-nightly
cancel-in-progress: false

jobs:
fuzz:
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4

- name: Install clang + nlohmann-json
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends clang nlohmann-json3-dev
clang++ --version

# The corpus grows across runs — that accumulation IS the value of a
# nightly fuzzer. actions/cache entries are immutable, so every run
# saves under a fresh key (run_id) and the next run restores the newest
# one via the key prefix.
- name: Restore corpus
uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: fuzz-corpus
key: fuzz-corpus-v1-${{ github.run_id }}
restore-keys: |
fuzz-corpus-v1-

- name: Configure + build fuzzers (standalone, no vcpkg)
run: |
cmake -S tests/fuzz -B build-fuzz \
-DCMAKE_CXX_COMPILER=clang++
cmake --build build-fuzz -j"$(nproc)"

- name: Run fuzz targets
env:
UBSAN_OPTIONS: print_stacktrace=1
SECONDS_PER_TARGET: ${{ github.event.inputs.seconds_per_target || '120' }}
run: |
set -uo pipefail
status=0
for t in fuzz_traceparent fuzz_decimal_cents fuzz_config_expand fuzz_path_match; do
echo "::group::$t (${SECONDS_PER_TARGET}s)"
mkdir -p "fuzz-corpus/$t" "fuzz-reproducers/$t"
# First dir is read-write (new inputs land there and get cached);
# the checked-in seed corpus is the read-only second dir.
if ! "./build-fuzz/$t" \
-max_total_time="$SECONDS_PER_TARGET" \
-timeout=10 \
-rss_limit_mb=2048 \
-print_final_stats=1 \
-artifact_prefix="fuzz-reproducers/$t/" \
"fuzz-corpus/$t" "tests/fuzz/corpus/$t"; then
echo "::error title=$t crashed::reproducer input uploaded in the fuzz-reproducers artifact (dir $t/)"
status=1
fi
echo "::endgroup::"
done
exit "$status"

# Save even on a crash — the corpus that found a bug is the most
# valuable one to keep (default cache post-save skips failed jobs).
- name: Save corpus
if: always()
uses: actions/cache/save@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: fuzz-corpus
key: fuzz-corpus-v1-${{ github.run_id }}

- name: Upload crash reproducers
if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: fuzz-reproducers
path: fuzz-reproducers/
if-no-files-found: ignore
retention-days: 30
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,9 @@ all three images keyless with cosign by digest and attests the SBOMs
automerged — bumps need both sha384 SRI hashes re-pinned by hand, recipe in
the comment at the pin); the nightly `swagger-sri-guard` job in
gates-nightly.yml re-downloads the pinned files and fails on pin/hash drift.
`fuzz-nightly.yml` runs libFuzzer over the byte-facing parsers
(`tests/fuzz/`, standalone Clang build — no vcpkg/app_core; keep those
parser TUs std-only, see docs/TESTING.md "Fuzzing").

An opt-in rendered-artifact gate for forks that render documents ships as
`scripts/check-artifact.py` + `scripts/render-artifacts.sh` + a mandatory
Expand Down
13 changes: 13 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,19 @@ if(E2E_SOURCES)
set_tests_properties(e2e_bucket PROPERTIES LABELS "e2e")
endif()

# ── libFuzzer harnesses (opt-in; Clang only) ────────────────────────────────
# OFF by default and completely inert when OFF — the normal (GCC) build is
# untouched. tests/fuzz/CMakeLists.txt hard-errors on a non-Clang compiler,
# and its normal entry point is a STANDALONE configure into build-fuzz that
# needs no vcpkg at all (the harnesses compile the parser TUs directly
# instead of linking app_core) — see the comment there and docs/TESTING.md,
# "Fuzzing". This option exists for a vcpkg-under-Clang tree that wants the
# fuzzers inside the main build graph.
option(ENABLE_FUZZERS "Build the libFuzzer harnesses in tests/fuzz (requires Clang)" OFF)
if(ENABLE_FUZZERS)
add_subdirectory(tests/fuzz)
endif()

# Installation
install(TARGETS ${PROJECT_NAME} ${PROJECT_NAME}_worker
RUNTIME DESTINATION bin
Expand Down
2 changes: 1 addition & 1 deletion docs/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ question instead of grepping the tree.
| [`EXAMPLES.md`](EXAMPLES.md) | End-to-end CRUD walkthrough for adding your own resource: migration → DTO → repository → controller → tests |
| [`CONVENTIONS.md`](CONVENTIONS.md) | Canonical "add a domain entity" checklist (what `new-resource.sh` follows) + what NOT to abstract |
| [`CONFIG.md`](CONFIG.md) | Single table mapping every JSON key ↔ env var ↔ default |
| [`TESTING.md`](TESTING.md) | Test buckets (unit/integration/api/e2e), day-to-day loops incl. the `.devcontainer` zero-setup inner loop, coverage, the disabled-race note |
| [`TESTING.md`](TESTING.md) | Test buckets (unit/integration/api/e2e), day-to-day loops incl. the `.devcontainer` zero-setup inner loop, coverage, nightly libFuzzer targets (`tests/fuzz/`), the disabled-race note |
| [`ORGS.md`](ORGS.md) | Multi-tenancy starter kit (`scripts/add-orgs.sh`): two role layers, fail-closed org context, claim/switch semantics, deny-by-default matrix |
| [`UPSTREAM.md`](UPSTREAM.md) | Fork↔template sync: `scripts/sync-upstream.sh` (three-way tarball patching for degit forks, `.template-version` stamp), git merge for full-history forks, the backport-candidate discipline for giving generic fixes back |
| [`BENCHMARKS.md`](BENCHMARKS.md) | How to measure latency/throughput/footprint (`make bench` + presets) + a results template |
Expand Down
49 changes: 49 additions & 0 deletions docs/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,55 @@ number reflects the DB/cache/auth/jobs code too — not just unit-reachable line
The integration and e2e buckets need Postgres + Redis (`make up` first); without
them those buckets are skipped and the reported coverage drops accordingly.

## Fuzzing

The parsers that eat **external bytes** have libFuzzer harnesses in
`tests/fuzz/`, run nightly by `.github/workflows/fuzz-nightly.yml`
(cron + `workflow_dispatch`, ~120 s per target):

| Target | Parser under test | External input it models |
|---|---|---|
| `fuzz_traceparent` | `Observability::Trace::parse_traceparent` | `traceparent` header, straight off the network on every request |
| `fuzz_decimal_cents` | `Billing::detail::parse_decimal_to_cents` (+ round-trip via `cents_to_decimal_string`) | amount strings in PayPal API/webhook bodies |
| `fuzz_config_expand` | `Config::detail::expand_string` / `substitute_env_placeholders` | `${VAR}` placeholders in config files (fixed, cleared env for determinism) |
| `fuzz_path_match` | `Utils::Strings::path_is_public` + CSV split/merge, `Api::normalize_path_for_metrics` | request paths and public-path CSV config |

Harnesses check invariants, not just "no crash": accepted traceparents must be
canonical and format→parse round-trip; accepted amounts must be non-negative
and survive a cents→string→cents round trip; path normalization must stay
rooted and idempotent.

**Toolchain decision (why a separate build dir).** libFuzzer requires Clang;
CI's production toolchain is GCC + vcpkg, and rebuilding the vcpkg dependency
world under a second compiler is a non-starter for a nightly. So the fuzzers
deliberately do **not** link `app_core`: each harness compiles the specific
parser TU directly, and those TUs are kept std-only for exactly this reason
(`Trace.cpp`, `billing/PayPalParse.cpp`, `utils/Strings.cpp`;
`utils/ConfigExpand.cpp` additionally needs header-only nlohmann/json, taken
from the distro package). If you move a fuzzed parser into a TU with heavier
includes, the fuzz build breaks — that's the tripwire telling you to keep the
byte-facing surface dependency-free.

Run locally (needs clang; never the normal build dir):

```bash
cmake -S tests/fuzz -B build-fuzz -DCMAKE_CXX_COMPILER=clang++
cmake --build build-fuzz -j
./build-fuzz/fuzz_traceparent tests/fuzz/corpus/fuzz_traceparent # + libFuzzer flags
```

Seed corpora live in `tests/fuzz/corpus/<target>/` (valid + boundary inputs).
The nightly job carries a **growing** corpus between runs via the Actions
cache and treats the seeds as a read-only starting set. A crash fails the job
and uploads the reproducer input as the `fuzz-reproducers` artifact; triage by
re-running `./build-fuzz/<target> <reproducer-file>`.

Not fuzzed (documented limitation): everything behind the network seam —
`verify_webhook_signature` beyond its pure header/JSON handling
(`find_header_ci` is compiled into the decimal target's TU but the
curl-touching flow is not), `parse_capture_response` (nlohmann-based, lives in
the curl TU), and anything needing Drogon types.

## Known gaps (be honest about these before you rely on them)

- **No behavioral coverage** for Kafka messaging, SMTP delivery (the Mailer is
Expand Down
1 change: 1 addition & 0 deletions src/api/Middleware.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
#include "api/RequestUtils.hpp"
#include "observability/Observability.hpp"
#include "observability/Trace.hpp"
#include "observability/TraceOtel.hpp"
#include "security/ApiKeys.hpp"
#include "security/Auth.hpp"
#include "security/Csrf.hpp"
Expand Down
122 changes: 122 additions & 0 deletions src/api/PathNormalize.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/**
* @file PathNormalize.hpp
* @brief Request-path normalization + UUID-shape validation — the drogon-free
* half of RequestUtils.hpp.
* @details Split out so the fuzz_path_match libFuzzer harness (tests/fuzz)
* can include it without <drogon/HttpRequest.h> (and therefore
* without the vcpkg dependency world). RequestUtils.hpp includes
* this header, so existing includers see the exact same names in
* the exact same namespaces.
*/

#pragma once

#include <cctype>
#include <string>
#include <string_view>
#include <vector>

namespace Api {

namespace detail {

/// True if @p s is a canonical 8-4-4-4-12 lowercase/uppercase-hex UUID.
inline bool is_uuid_segment(std::string_view s) {
if (s.size() != 36)
return false;
for (size_t i = 0; i < 36; ++i) {
const char c = s[i];
if (i == 8 || i == 13 || i == 18 || i == 23) {
if (c != '-')
return false;
} else if (!std::isxdigit(static_cast<unsigned char>(c))) {
return false;
}
}
return true;
}

} // namespace detail

/**
* @brief Validate UUID format (8-4-4-4-12 hex)
*/
inline bool is_valid_uuid(const std::string& str) {
return detail::is_uuid_segment(str);
}

/**
* @brief Normalize a request path for metric/trace cardinality AND log
* redaction. Replaces UUID segments with ":id" and the single-use
* token after the account confirm/reset/change-email routes with
* ":token".
* @details Two jobs in one: (a) raw ids/tokens would mint a new Prometheus
* label and Jaeger operation per entity (cardinality blow-up);
* (b) the account tokens are credentials — logging the raw path
* would drop password-reset tokens into the access log. A manual
* segment scan (no std::regex) keeps this cheap on the hot path.
*/
inline std::string normalize_path_for_metrics(const std::string& path) {
// Split into segments, rewrite, rejoin. Empty input / "/" returns as-is.
std::vector<std::string_view> segs;
size_t i = 0;
while (i < path.size()) {
if (path[i] == '/') {
++i;
continue;
}
size_t j = path.find('/', i);
if (j == std::string::npos)
j = path.size();
segs.emplace_back(path.data() + i, j - i);
i = j;
}

// Optional API version segment (/api/v<N>/... — see ADR 0006), so the token
// routes are detected whether or not a version is present.
auto is_version_seg = [](const auto& s) {
if (s.size() < 2 || s[0] != 'v')
return false;
for (size_t k = 1; k < s.size(); ++k)
if (s[k] < '0' || s[k] > '9')
return false;
return true;
};
const size_t base = (segs.size() >= 2 && is_version_seg(segs[1])) ? 2 : 1; // index of <resource> after /api[/vN]

// The account token routes: /api[/vN]/account/<verb>/<token> where verb is one
// of the token-bearing apply endpoints (the *-request / *-resend variants
// are single segments and won't match this shape).
const size_t token_idx = base + 2;
const bool account_token_route = segs.size() == base + 3 && segs[0] == "api" && segs[base] == "account" &&
(segs[base + 1] == "confirm" || segs[base + 1] == "reset-password" ||
segs[base + 1] == "change-email" || segs[base + 1] == "join-from-invite");

std::string out;
out.reserve(path.size());
for (size_t k = 0; k < segs.size(); ++k) {
out += '/';
if (account_token_route && k == token_idx) {
out += ":token";
continue;
}
// Bucket id-shaped segments so per-id paths don't explode metric
// cardinality: uuids (e.g. /api/admin/users/<uuid>) AND all-digit ids
// (e.g. /api/admin/roles/5 — integer PKs added with the roles routes).
bool all_digits = !segs[k].empty();
for (char c : segs[k])
if (c < '0' || c > '9') {
all_digits = false;
break;
}
if (all_digits || detail::is_uuid_segment(segs[k]))
out += ":id";
else
out.append(segs[k].data(), segs[k].size());
}
if (out.empty())
out = "/";
return out;
}

} // namespace Api
Loading
Loading