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
77 changes: 77 additions & 0 deletions .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
name: fuzz

# Coverage-guided fuzzing of the untrusted-input parsers (Jazzer, Apache-2.0).
#
# - Pull requests / pushes: run each Jazzer @FuzzTest in fuzzing mode for a
# short, bounded budget (maxDuration = 60s per target, set on the annotation)
# so the job stays cheap. Newly discovered crashers fail the build.
# - Nightly (cron): a longer budget for deeper exploration.
#
# Regression replay of the seed corpus also runs as part of the normal
# `mvn test` build (JAZZER_FUZZ unset) and needs no libFuzzer driver.

on:
pull_request:
paths:
- 'src/**'
- 'pom.xml'
- '.github/workflows/fuzz.yml'
push:
branches: [ main ]
paths:
- 'src/**'
- 'pom.xml'
- '.github/workflows/fuzz.yml'
schedule:
- cron: '0 3 * * *' # nightly deep run
workflow_dispatch: {}

permissions:
contents: read

concurrency:
group: fuzz-${{ github.ref }}
cancel-in-progress: true

jobs:
fuzz:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
target:
- ArtifactTokenParserFuzzTest
- DocumentValidationFuzzTest
- TenantClaimsFuzzTest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5

- name: Set up JDK 21
uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9
with:
distribution: temurin
java-version: '21'
cache: maven

# Nightly scheduled runs fuzz longer; PR/push runs use the 60s annotation budget.
- name: Fuzz ${{ matrix.target }}
env:
JAZZER_FUZZ: '1'
run: |
EXTRA=""
if [ "${{ github.event_name }}" = "schedule" ]; then
# Deeper nightly exploration (10 min per target).
EXTRA="-Djazzer.max_total_time=600"
fi
mvn -B -ntp -Dtest='${{ matrix.target }}' $EXTRA test

- name: Upload crash reproducers
if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: fuzz-findings-${{ matrix.target }}
path: |
**/crash-*
**/timeout-*
**/oom-*
if-no-files-found: ignore
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,9 @@ certs/
*.crt
*.cer
*.jks

# Jazzer generated corpus
.cifuzz-corpus/

# CodeGraph local index
.codegraph/
68 changes: 68 additions & 0 deletions docs/fuzzing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Fuzzing

Coverage-guided fuzzing of the viewer's untrusted-input parsers, using
[Jazzer](https://github.com/CodeIntelligenceTesting/jazzer) (Apache-2.0)
integrated with JUnit 5 (`jazzer-junit`).

## Research basis

The seed-corpus and bounded coverage-guided strategy is grounded in Marcel
Böhme, Van-Thuan Pham, and Abhik Roychoudhury, “Coverage-based Greybox
Fuzzing as Markov Chain,” *Proceedings of the 2016 ACM SIGSAC Conference on
Computer and Communications Security (CCS '16)*, pp. 1032–1043,
doi:10.1145/2976749.2978428. The preserved source PDF is
`docs/papers/boehme-2016-coverage-based-greybox-fuzzing-as-markov-chain.pdf`
(SHA-256 `fb3a7a74280659f86f83a934cae7bd35660e4c699dbd1dbd6825834bfe132151`).
Its core result motivates retaining low-frequency-path crash seeds as durable
regressions while giving scheduled fuzzing a longer exploration budget than
per-PR fuzzing.

## Targets

The surfaces were selected with CodeGraph as the highest-value untrusted-input
boundaries (attacker-controlled tokens, filenames, and request headers). Each
harness asserts that arbitrary input only ever produces the *documented*
failure mode -- never an unhandled runtime exception.

| Harness | Surface under test | Invariant |
| --- | --- | --- |
| `ArtifactTokenParserFuzzTest` | `ArtifactLinkService.verifyReadToken` -- splits, HMAC-verifies, Base64URL-decodes and parses 10 token fields (UUID, epoch `Instant`s). Forges valid signatures to reach the deep parse path. | Only `ArtifactTokenException` (a mapped 4xx) escapes. |
| `DocumentValidationFuzzTest` | `DefaultDocumentValidationService.validateOrThrow` -- filename/extension parsing + policy-override headers. | Only `IllegalArgumentException` (incl. `UnsupportedDocumentFormatException`) escapes. |
| `TenantClaimsFuzzTest` | `TenantContext.fromHeaders` + `TenantAccessService.require` -- tenant/subject/permission headers and the signed-claims timestamp/signature. | `fromHeaders` never throws; `require` only fails with `ResponseStatusException`. |

Seed corpora live under
`src/test/resources/com/clearfolio/viewer/fuzz/<HarnessName>Inputs/<method>/`.

## How it runs

- **Normal build (`mvn test`)**: the `@FuzzTest` methods run in *regression*
mode -- they replay the seed corpus (and any committed crash reproducers) as
fast, deterministic unit tests. No libFuzzer driver required, so this works on
every developer platform and keeps the default build cheap.

- **Fuzzing mode (`JAZZER_FUZZ=1`)**: libFuzzer drives each target for the
bounded budget declared on its `@FuzzTest(maxDuration = "60s")` annotation.

```bash
# Regression replay of all fuzz seeds (part of the normal suite):
mvn test -Dtest='*FuzzTest'

# Actively fuzz a single target for its bounded budget:
JAZZER_FUZZ=1 mvn test -Dtest='ArtifactTokenParserFuzzTest'
```

## CI

`.github/workflows/fuzz.yml` fuzzes each target for 60s on pull requests and
pushes to `main`, and for 10 minutes per target nightly. A newly discovered
crasher fails the job; the reproducer is uploaded as a build artifact. Commit
the reproducer into the harness's `Inputs/<method>/` directory to turn it into a
permanent regression test.

## Findings

The initial harness immediately surfaced an unhandled `java.time.DateTimeException`
in the artifact-token parser: `Instant.ofEpochSecond(Long.parseLong(...))` threw
on an out-of-range (but syntactically valid) epoch second, escaping the
`catch (IllegalArgumentException)` block and turning a would-be 401 into a 500.
Fixed by also catching `DateTimeException` in `ArtifactLinkService.parseAndVerify`.
Binary file not shown.
8 changes: 8 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,14 @@
<scope>test</scope>
</dependency>

<!-- JUnit 5 engine for @FuzzTest regression replay and active fuzzing. -->
<dependency>
<groupId>com.code-intelligence</groupId>
<artifactId>jazzer-junit</artifactId>
<version>0.30.0</version>
<scope>test</scope>
</dependency>

</dependencies>

<build>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.Clock;
import java.time.DateTimeException;
import java.time.Instant;
import java.util.Arrays;
import java.util.Base64;
Expand Down Expand Up @@ -357,7 +358,9 @@ private ArtifactTokenClaims parseAndVerify(String token) {
Instant.ofEpochSecond(Long.parseLong(decode(parts[8]))),
Instant.ofEpochSecond(Long.parseLong(decode(parts[9])))
);
} catch (IllegalArgumentException ex) {
} catch (IllegalArgumentException | DateTimeException ex) {
// IllegalArgumentException: malformed Base64URL, UUID, or numeric fields.
// DateTimeException: epoch-second value outside the supported Instant range.
throw new ArtifactTokenException(HttpStatus.UNAUTHORIZED, "artifact token invalid");
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,11 @@ private String extensionOf(final String fileName) {
throw new IllegalArgumentException("File name contains null byte.");
}

String normalized = java.nio.file.Path.of(fileName.strip()).getFileName().toString();
java.nio.file.Path leafName = java.nio.file.Path.of(fileName.strip()).getFileName();
if (leafName == null) {
return "";
}
String normalized = leafName.toString();
int lastDot = normalized.lastIndexOf('.');
if (lastDot <= 0 || lastDot == normalized.length() - 1) {
return "";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package com.clearfolio.viewer.fuzz;

import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.UUID;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

import com.code_intelligence.jazzer.api.FuzzedDataProvider;
import com.code_intelligence.jazzer.junit.FuzzTest;

import com.clearfolio.viewer.artifact.ArtifactLinkService;
import com.clearfolio.viewer.artifact.ArtifactTokenException;
import com.clearfolio.viewer.artifact.InMemoryArtifactStore;

/**
* Fuzzes {@link ArtifactLinkService}'s artifact-token verification path.
*
* <p>The token string is fully attacker-controlled (query parameter or bearer
* header). {@link ArtifactLinkService#verifyReadToken} splits it, HMAC-verifies
* it, then Base64URL-decodes and parses ten fields (including a {@link UUID},
* two {@code Long}s and two {@link java.time.Instant}s). The invariant under
* test: for <em>any</em> input the method must fail with the controlled
* {@link ArtifactTokenException} (mapped to a 4xx) and never leak an
* unhandled runtime exception (which would surface as a 500).
*
* <p>To exercise the deep decode/parse path -- not just the signature gate --
* the harness forges structurally valid, correctly-signed tokens using a known
* secret, mirroring {@code ArtifactLinkService.sign}. This is what surfaced the
* previously uncaught {@link java.time.DateTimeException} thrown by
* {@code Instant.ofEpochSecond} on out-of-range epoch seconds.
*/
final class ArtifactTokenParserFuzzTest {

private static final String SECRET = "clearfolio-fuzz-artifact-secret";
private static final Base64.Encoder ENCODER = Base64.getUrlEncoder().withoutPadding();

private final ArtifactLinkService service =
new ArtifactLinkService(new InMemoryArtifactStore(), SECRET);

@FuzzTest(maxDuration = "60s")
void verifyReadTokenNeverThrowsUnhandled(FuzzedDataProvider data) {
boolean forgeSignature = data.consumeBoolean();
UUID routeDocId = new UUID(data.consumeLong(), data.consumeLong());

String token;
if (forgeSignature) {
// Build a token with a valid HMAC so parsing runs to completion.
String version = data.consumeBoolean() ? "v1" : data.consumeString(8);
UUID docId = new UUID(data.consumeLong(), data.consumeLong());
String[] fields = {
version,
data.consumeString(24), // tokenId
data.consumeString(24), // tenantId
data.consumeString(24), // subjectId
data.consumeBoolean() ? docId.toString()
: data.consumeString(40), // docId (valid UUID or garbage)
data.consumeString(24), // scope
data.consumeString(24), // purpose
data.consumeString(64), // artifactChecksum
Long.toString(data.consumeLong()), // issuedAt epoch seconds
Long.toString(data.consumeLong()), // expiresAt epoch seconds
};
token = sign(fields);
} else {
// Fully arbitrary token: exercises the split + signature-gate path.
token = data.consumeRemainingAsString();
}

try {
service.verifyReadToken(routeDocId, null, new byte[0], token);
} catch (ArtifactTokenException expected) {
// Controlled failure -> 4xx. Correct behavior for hostile input.
}
}

private static String sign(String[] fields) {
StringBuilder payload = new StringBuilder();
for (int i = 0; i < fields.length; i++) {
if (i > 0) {
payload.append('.');
}
payload.append(ENCODER.encodeToString(fields[i].getBytes(StandardCharsets.UTF_8)));
}
String signature = hmac(payload.toString());
return payload + "." + signature;
}

private static String hmac(String payload) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return ENCODER.encodeToString(mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)));
} catch (java.security.GeneralSecurityException ex) {
throw new IllegalStateException("test HMAC failed", ex);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.clearfolio.viewer.fuzz;

import com.code_intelligence.jazzer.api.FuzzedDataProvider;
import com.code_intelligence.jazzer.junit.FuzzTest;

import com.clearfolio.viewer.config.ConversionProperties;
import com.clearfolio.viewer.controller.InMemoryMultipartFile;
import com.clearfolio.viewer.service.DefaultDocumentValidationService;
import com.clearfolio.viewer.service.PolicyOverrideRequest;

/**
* Fuzzes {@link DefaultDocumentValidationService#validateOrThrow}, the upload
* gate that runs before any conversion work.
*
* <p>Every input is untrusted: the original filename (parsed for its
* extension), the raw file bytes, and the three policy-override HTTP headers.
* The documented contract is that invalid uploads are rejected with an
* {@link IllegalArgumentException} (its subclass
* {@code UnsupportedDocumentFormatException} included) -- so the invariant is
* that no <em>other</em> throwable ever escapes for arbitrary input.
*/
final class DocumentValidationFuzzTest {

private final DefaultDocumentValidationService validator = createValidator();

private static DefaultDocumentValidationService createValidator() {
ConversionProperties properties = new ConversionProperties();
properties.setPolicyOverrideSecret("fuzz-test-secret");
return new DefaultDocumentValidationService(properties);
}

@FuzzTest(maxDuration = "60s")
void validateOrThrowOnlyRejectsWithIllegalArgument(FuzzedDataProvider data) {
String filename = data.consumeBoolean() ? null : data.consumeString(64);
String contentType = data.consumeBoolean() ? null : data.consumeString(32);
byte[] content = data.consumeBytes(data.consumeInt(0, 4096));

String policyOverride = data.consumeBoolean() ? null : data.consumeString(16);
String approvalToken = data.consumeBoolean() ? null : data.consumeString(32);
String approverId = data.consumeRemainingAsString();

InMemoryMultipartFile file =
new InMemoryMultipartFile("file", filename, contentType, content);
PolicyOverrideRequest override =
PolicyOverrideRequest.of(policyOverride, approvalToken, approverId);

try {
validator.validateOrThrow(file, override);
} catch (IllegalArgumentException expected) {
// Documented rejection path (covers UnsupportedDocumentFormatException).
}
}
}
Loading
Loading