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
12 changes: 12 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
version: 2
updates:
- package-ecosystem: maven
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 5
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 5
36 changes: 36 additions & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: CodeQL

on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: '41 3 * * 1'

permissions:
contents: read
security-events: write

Check failure

Code scanning / Scorecard

Token-Permissions High

score is 9: topLevel 'security-events' permission set to 'write'
Remediation tip: Visit https://app.stepsecurity.io/secureworkflow.
Tick the 'Restrict permissions for GITHUB_TOKEN'
Untick other options
NOTE: If you want to resolve multiple issues at once, you can visit https://app.stepsecurity.io/securerepo instead.
Click Remediation section below for further remediation help
actions: read

jobs:
analyze:
name: Analyze Java
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
- name: Set up JDK 21
uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9
with:
distribution: temurin
java-version: '21'
cache: maven
- uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9
with:
languages: java-kotlin
build-mode: manual
- name: Build for CodeQL
run: mvn -B -ntp -DskipTests package
- uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9
with:
category: /language:java-kotlin
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 @@ -30,3 +30,9 @@ certs/
*.crt
*.cer
*.jks

# Jazzer generated corpus
.cifuzz-corpus/

# CodeGraph local index
.codegraph/
11 changes: 11 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Security Policy

## Supported Versions

Security updates are applied to the current `main` branch and the latest deployable Clearfolio Viewer release.

## Reporting a Vulnerability

Please report suspected vulnerabilities by opening a private vulnerability report in this repository's Security tab. If private reporting is unavailable, email the maintainers listed in the organization profile with a minimal reproduction, affected version, and impact summary.

We aim to acknowledge reports within 3 business days, provide a remediation plan after triage, and publish fixes before disclosing detailed exploit information.
55 changes: 55 additions & 0 deletions docs/fuzzing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# 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`).

## 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.
15 changes: 15 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
<argLine></argLine>
<pdfbox.version>3.0.3</pdfbox.version>
<pdfjs.dist.version>4.10.38</pdfjs.dist.version>
<jazzer.version>0.30.0</jazzer.version>
<jackson-bom.version>2.21.5</jackson-bom.version>
</properties>

<dependencies>
Expand Down Expand Up @@ -66,6 +68,19 @@
<scope>test</scope>
</dependency>

<!--
Jazzer (Apache-2.0) coverage-guided fuzzing, integrated with JUnit 5.
In normal `mvn test` runs the @FuzzTest methods replay their seed corpus
as fast regression tests. With JAZZER_FUZZ=1 they run libFuzzer-driven
fuzzing for a bounded duration (see .github/workflows/fuzz.yml).
-->
<dependency>
<groupId>com.code-intelligence</groupId>
<artifactId>jazzer-junit</artifactId>
<version>${jazzer.version}</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
@@ -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);
}
}
}
Loading
Loading