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
43 changes: 43 additions & 0 deletions .github/workflows/cifuzz.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: CIFuzz
on:
pull_request:
paths:
- '**.go'
- '.github/workflows/cifuzz.yml'
push:
branches: [main, master]

permissions:
contents: read
security-events: write

jobs:
fuzzing:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
sanitizer: [address]
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Checking checkout steps and credential persistence settings..."
nl -ba .github/workflows/cifuzz.yml | sed -n '18,32p'

echo
echo "Searching all workflows for checkout without persist-credentials:false..."
rg -n -C2 'uses:\s*actions/checkout@' .github/workflows
rg -n -C2 'persist-credentials:\s*false' .github/workflows || true

Repository: ory/hydra

Length of output: 177


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== cifuzz checkout step context ==="
sed -n '1,120p' .github/workflows/cifuzz.yml | sed -n '15,40p'

echo
echo "=== Find all checkout usages in workflows ==="
rg -n --hidden --glob '.github/workflows/**' 'uses:\s*actions/checkout@' .github/workflows

echo
echo "=== Find persist-credentials false usages ==="
rg -n --hidden --glob '.github/workflows/**' 'persist-credentials:\s*false' .github/workflows || true

echo
echo "=== Check whether cifuzz.yml checkout has with: persist-credentials ==="
python3 - <<'PY'
import re, pathlib
p = pathlib.Path(".github/workflows/cifuzz.yml")
txt = p.read_text()
# crude: locate checkout block and see if persist-credentials false appears within next ~20 lines
m = re.search(r'uses:\s*actions/checkout@[^\n]*', txt)
if not m:
    print("No actions/checkout usage found in cifuzz.yml")
    raise SystemExit(0)
start = m.start()
lines = txt[:start].count("\n")
block = txt.splitlines()
# locate line index of the checkout
idx = None
for i,l in enumerate(block):
    if re.search(r'uses:\s*actions/checkout@', l):
        idx = i; break
print(f"checkout line index (0-based): {idx}")
if idx is not None:
    window = block[idx:idx+25]
    has = any(re.search(r'persist-credentials:\s*false', l) for l in window)
    print("persist-credentials:false present in next 25 lines:", has)
PY

Repository: ory/hydra

Length of output: 1944


🏁 Script executed:

#!/bin/bash
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
p = Path(".github/workflows/cifuzz.yml")
lines = p.read_text().splitlines()
for i in range(15,30):
    l = lines[i-1]
    # show leading whitespace counts
    lead = len(l) - len(l.lstrip(' '))
    print(f"{i:>3} lead={lead} |{l}|")
PY

Repository: ory/hydra

Length of output: 743


Disable checkout credential persistence in cifuzz workflow

.github/workflows/cifuzz.yml uses actions/checkout without persist-credentials: false, which can leave the workflow token in git config for subsequent steps/actions.

🔧 Proposed hardening patch
-    - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+    - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+      with:
+        persist-credentials: false
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
🧰 Tools
🪛 zizmor (1.25.2)

[warning] 22-22: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false

(artipacked)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/cifuzz.yml at line 22, The checkout step using "uses:
actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683" leaves the workflow
token persisted in git config; update that checkout step to set
persist-credentials: false so the token is not stored for subsequent steps
(modify the checkout step in the cifuzz workflow to include persist-credentials:
false alongside the existing uses line).

Source: Linters/SAST tools

- name: Build Fuzzers (${{ matrix.sanitizer }})
id: build
uses: google/oss-fuzz/infra/cifuzz/actions/build_fuzzers@ba0e2e0399a10b7b42afb16e7a6c4ccd3ff52431
with:
oss-fuzz-project-name: 'hydra'
language: go
sanitizer: ${{ matrix.sanitizer }}
- name: Run Fuzzers (${{ matrix.sanitizer }})
uses: google/oss-fuzz/infra/cifuzz/actions/run_fuzzers@ba0e2e0399a10b7b42afb16e7a6c4ccd3ff52431
with:
oss-fuzz-project-name: 'hydra'
language: go
fuzz-seconds: 300
sanitizer: ${{ matrix.sanitizer }}
output-sarif: true
- name: Upload Sarif
if: always() && steps.build.outcome == 'success'
uses: github/codeql-action/upload-sarif@601d5b1bcb3e5ef5eea97a6d0dcdbbb8c2b80116
with:
sarif_file: cifuzz-sarif/results.sarif
category: fuzz-${{ matrix.sanitizer }}
216 changes: 216 additions & 0 deletions fuzz_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
//go:build go1.18
// +build go1.18

// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0

package hydra_test

import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/sha512"
"hash"
"testing"

tokenhmac "github.com/ory/hydra/v2/fosite/token/hmac"
fositejwt "github.com/ory/hydra/v2/fosite/token/jwt"
)

// testConfig satisfies all HMAC strategy config interfaces.
type testConfig struct {
secret []byte
}

func (c *testConfig) GetTokenEntropy(_ context.Context) int { return 32 }
func (c *testConfig) GetGlobalSecret(_ context.Context) ([]byte, error) { return c.secret, nil }
func (c *testConfig) GetRotatedGlobalSecrets(_ context.Context) ([][]byte, error) {
return nil, nil
}
func (c *testConfig) GetHMACHasher(_ context.Context) func() hash.Hash {
return sha512.New512_256
}

func mustMakeSecret(tb testing.TB) []byte {
tb.Helper()
secret := make([]byte, 32)
if _, err := rand.Read(secret); err != nil {
tb.Fatal(err)
}
return secret
}

// FuzzHMACTokenValidate tests HMAC token validation with arbitrary
// attacker-controlled token strings. This is the pre-auth boundary
// for every OAuth2 access token, refresh token, and authorization
// code in Hydra. The token comes from the Authorization: Bearer header.
func FuzzHMACTokenValidate(f *testing.F) {
secret := mustMakeSecret(f)
cfg := &testConfig{secret: secret}
strategy := &tokenhmac.HMACStrategy{Config: cfg}

// Generate a valid token for the seed corpus
validToken, _, err := strategy.Generate(context.Background())
if err == nil {
f.Add(validToken)
}

f.Add("") // empty
f.Add(".") // just separator
f.Add("abc.def") // invalid base64
f.Add("AAAA.AAAA") // valid base64, wrong signature
f.Add("not-a-token") // no separator
f.Add(string(make([]byte, 10000))) // large input

f.Fuzz(func(t *testing.T, token string) {
if len(token) > 1<<16 {
return
}
// Validate should never panic, only return errors
_ = strategy.Validate(context.Background(), token)
})
}

// FuzzHMACTokenSignature tests the Signature extraction method
// with arbitrary token strings. Signature is used to look up
// tokens in storage — a crash here is a DoS vector.
func FuzzHMACTokenSignature(f *testing.F) {
secret := mustMakeSecret(f)
cfg := &testConfig{secret: secret}
strategy := &tokenhmac.HMACStrategy{Config: cfg}

// Create a real token for valid case
validToken, _, err := strategy.Generate(context.Background())
if err == nil {
f.Add(validToken, validToken)
}

f.Fuzz(func(t *testing.T, token, compare string) {
_ = strategy.Signature(token)
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// FuzzHMACGenerateForString tests HMAC generation for arbitrary
// string inputs. Used to hash client secrets and other sensitive
// strings — a crash here can break secret hashing.
func FuzzHMACGenerateForString(f *testing.F) {
secret := mustMakeSecret(f)
cfg := &testConfig{secret: secret}
strategy := &tokenhmac.HMACStrategy{Config: cfg}

f.Add("test-string")
f.Add("")
f.Add(string(make([]byte, 10000)))

f.Fuzz(func(t *testing.T, text string) {
_, _ = strategy.GenerateHMACForString(context.Background(), text)
})
Comment on lines +106 to +108

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add input size cap for consistency and DoS prevention.

Unlike the other fuzz functions in this file, FuzzHMACGenerateForString does not cap the input size. This could allow the fuzzer to generate very large strings that exhaust memory or slow down fuzzing.

🛡️ Proposed fix to add size cap
 	f.Fuzz(func(t *testing.T, text string) {
+		if len(text) > 1<<16 {
+			return
+		}
 		_, _ = strategy.GenerateHMACForString(context.Background(), text)
 	})
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
f.Fuzz(func(t *testing.T, text string) {
_, _ = strategy.GenerateHMACForString(context.Background(), text)
})
f.Fuzz(func(t *testing.T, text string) {
if len(text) > 1<<16 {
return
}
_, _ = strategy.GenerateHMACForString(context.Background(), text)
})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@fuzz_test.go` around lines 117 - 119, The fuzz harness for
GenerateHMACForString lacks an input-size cap and can be DoS'ed by huge strings;
modify the Fuzz function wrapping strategy.GenerateHMACForString to early-return
or t.Skip when the fuzzed string exceeds a safe length (e.g., 1KB–4KB) by
checking len(text) and skipping/returning before calling
strategy.GenerateHMACForString, so the test stays consistent with other fuzz
functions and avoids memory exhaustion.

}

// FuzzJWTValidate tests JWT token validation with arbitrary
// attacker-controlled JWT strings. This is the pre-auth boundary
// for JWT access tokens and OpenID Connect ID tokens.
func FuzzJWTValidate(f *testing.F) {
// Generate a test RSA key
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
f.Fatal(err)
}

signer := &fositejwt.DefaultSigner{
GetPrivateKey: func(_ context.Context) (interface{}, error) {
return key, nil
},
}

// Generate valid token as seed
claims := fositejwt.MapClaims{
"sub": "test-user",
"iss": "https://hydra.example.com",
}
validToken, _, err := signer.Generate(context.Background(), claims, &fositejwt.Headers{})
if err == nil {
f.Add(validToken)
}

f.Add("") // empty
f.Add("eyJ...") // garbage
f.Add("a.b.c") // 3-part but invalid
f.Add("header.payload") // 2-part

f.Fuzz(func(t *testing.T, token string) {
if len(token) > 1<<16 {
return
}
// Validate should never panic
_, _ = signer.Validate(context.Background(), token)
})
}

// FuzzJWTDecode tests JWT token decoding with arbitrary token strings.
// Decode is used even before Validate in many code paths.
func FuzzJWTDecode(f *testing.F) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
f.Fatal(err)
}

signer := &fositejwt.DefaultSigner{
GetPrivateKey: func(_ context.Context) (interface{}, error) {
return key, nil
},
}

claims := fositejwt.MapClaims{"sub": "test"}
validToken, _, err := signer.Generate(context.Background(), claims, &fositejwt.Headers{})
if err == nil {
f.Add(validToken)
}

f.Add("")
f.Add("...")
f.Add(string(make([]byte, 100000)))

f.Fuzz(func(t *testing.T, token string) {
if len(token) > 1<<16 {
return
}
Comment on lines +173 to +178

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Seed size exceeds fuzz cap.

The seed on line 184 adds a 100,000-byte string, but the fuzz function caps inputs at 64KB (1<<16 = 65,536 bytes). The seed will be immediately discarded by the size check.

♻️ Proposed fix to align seed with cap
-	f.Add(string(make([]byte, 100000)))
+	f.Add(string(make([]byte, 1<<16)))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
f.Add(string(make([]byte, 100000)))
f.Fuzz(func(t *testing.T, token string) {
if len(token) > 1<<16 {
return
}
f.Add(string(make([]byte, 1<<16)))
f.Fuzz(func(t *testing.T, token string) {
if len(token) > 1<<16 {
return
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@fuzz_test.go` around lines 184 - 189, The seed added via
f.Add(string(make([]byte, 100000))) exceeds the fuzz input cap checked in the
Fuzz callback (len(token) > 1<<16), so it will be discarded; change the seed to
be at or below the cap (<= 1<<16 bytes) or remove it entirely. Locate the f.Add
call and replace the 100000-byte seed with a smaller seed (e.g., make([]byte,
1<<16) or a smaller realistic input) so it passes the size check in the
Fuzz(func(t *testing.T, token string)) block.

// Decode should never panic
_, _ = signer.Decode(context.Background(), token)
})
}

// FuzzJWTGetSignature tests JWT signature extraction with arbitrary
// token strings. The signature is used to identify tokens for revocation.
func FuzzJWTGetSignature(f *testing.F) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
f.Fatal(err)
}

signer := &fositejwt.DefaultSigner{
GetPrivateKey: func(_ context.Context) (interface{}, error) {
return key, nil
},
}

validToken, _, err := signer.Generate(
context.Background(),
fositejwt.MapClaims{"sub": "test"},
&fositejwt.Headers{},
)
if err == nil {
f.Add(validToken)
}

f.Fuzz(func(t *testing.T, token string) {
if len(token) > 1<<16 {
return
}
_, _ = signer.GetSignature(context.Background(), token)
})
}

// Ensure deterministic per-fuzz, not per-package
var _ = tokenhmac.RandomBytes
Loading