Skip to content

Commit ab6d68a

Browse files
schmugclaude
andauthored
security: bound attacker-controlled scan fan-out and rate-limit write race (#539)
* security: bound attacker-controlled scan fan-out and rate-limit write race Hardens three DoS / race-condition findings surfaced by a deep security audit and tracked as private draft advisories. All three are reachable by an unauthenticated request and turn one cheap, rate-limited request into disproportionate backend work. - DKIM selector fan-out (GHSA-6fqp-4vhc-59mf): parseSelectors and the MCP parseSelectorsFromArray now cap the custom selector list to 16 items and 63 chars each (RFC 1035 label limit) before analyzeDkim fires one DNS lookup per selector. The MCP inputSchema advertises maxItems/maxLength to match, but enforcement is server-side regardless (the schema is advisory). - MTA-STS unbounded body (GHSA-p676-gc7j-96mx): fetchPolicy now reads the policy via arrayBuffer() truncated to MAX_POLICY_BYTES (64KB), mirroring the ceiling security-txt already applies, so an attacker-controlled mta-sts.<domain> can't stream an unbounded body into the isolate. The redirect:"manual" posture (RFC 8461 §3.3) is unchanged. - Rate-limit counter write (GHSA-v7qc-7qh8-h69g): the Cache-API write is awaited before returning instead of deferred via waitUntil, closing the intra-isolate read-modify-write window the deferral widened. A fully atomic cross-isolate counter (Durable Object / native binding) remains tracked in the advisory and is out of scope here. Also gitignores .security-scans/ so point-in-time scan reports are never committed to this public repo. Tests: 1305 passing, 0 failing. Typecheck + biome lint clean. Adds regression tests for each cap and for the awaited rate-limit write. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: schmug <38227427+schmug@users.noreply.github.com> * security: scope this PR to the per-analyzer DoS caps; drop the rate-limit write-race fix (superseded by #549) The Cache-API await fix here (GHSA-v7qc-7qh8-h69g) is only a partial mitigation; #549 fully supersedes it with an atomic Durable Object rate limiter and deletes the checkRateLimitCache path this touched. Revert the rate-limit.ts change, the rateLimitMiddleware hunk in index.ts, and the f30 test so this PR scopes cleanly to the per-analyzer caps that nothing else duplicates: DKIM selector count/length (GHSA-6fqp-4vhc-59mf) and MTA-STS policy body size (GHSA-p676-gc7j-96mx). Avoids a same-line conflict with #549's middleware rewrite. Signed-off-by: schmug <38227427+schmug@users.noreply.github.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Signed-off-by: schmug <38227427+schmug@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent bf4729f commit ab6d68a

7 files changed

Lines changed: 136 additions & 9 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ TRIAGE.json
2020
TRIAGE.md
2121
.triage-state/
2222
.threat-model-state/
23+
.security-scans/
2324

2425
# CycloneDX SBOM is generated in CI per release and attached as a release
2526
# asset — never committed (release.yml builds it from the lockfile).

src/analyzers/mta-sts.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@ import { queryTxt } from "../dns/client.js";
22
import type { ScanBudget } from "../dns/scan-budget.js";
33
import type { MtaStsPolicy, MtaStsResult, Validation } from "./types.js";
44

5+
// Cap the fetched policy body before decoding it (GHSA-p676-gc7j-96mx). RFC 8461
6+
// policies are tiny, but an attacker who controls mta-sts.<domain> could
7+
// otherwise stream an unbounded body into the isolate. This mirrors the
8+
// MAX_BODY_BYTES ceiling that src/analyzers/security-txt.ts already applies.
9+
export const MAX_POLICY_BYTES = 64 * 1024;
10+
511
export async function analyzeMtaSts(
612
domain: string,
713
budget?: ScanBudget,
@@ -119,7 +125,18 @@ async function fetchPolicy(domain: string): Promise<MtaStsPolicy | null> {
119125
if ((resp.type as string) === "opaqueredirect") return null;
120126
if (!resp.ok) return null;
121127

122-
const text = await resp.text();
128+
// Bound the body before decoding — reading via arrayBuffer()+slice caps
129+
// memory regardless of a lying Content-Length or a slow infinite stream
130+
// (the 3s AbortSignal bounds time, not bytes). Same control as security-txt.
131+
const buffer = await resp.arrayBuffer();
132+
const slice =
133+
buffer.byteLength > MAX_POLICY_BYTES
134+
? buffer.slice(0, MAX_POLICY_BYTES)
135+
: buffer;
136+
const text = new TextDecoder("utf-8", {
137+
fatal: false,
138+
ignoreBOM: false,
139+
}).decode(slice);
123140
return parsePolicy(text);
124141
} catch {
125142
return null;

src/index.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1453,12 +1453,27 @@ export { normalizeDomain };
14531453
// is dropped silently — an invalid selector cannot match a real DKIM key.
14541454
const VALID_SELECTOR = /^[A-Za-z0-9._-]+$/;
14551455

1456+
// DoS guard (GHSA-6fqp-4vhc-59mf): every custom selector becomes one concurrent
1457+
// DNS lookup in analyzeDkim, so an unbounded attacker-supplied list is a DNS
1458+
// amplification vector charged against a single rate-limit token. Bound both
1459+
// the per-item length (RFC 1035 label limit) and the count. 16 custom selectors
1460+
// is generous given ~37 built-in COMMON_SELECTORS. parseSelectorsFromArray in
1461+
// src/mcp/handler.ts mirrors these limits for the MCP path.
1462+
export const MAX_SELECTOR_LENGTH = 63;
1463+
export const MAX_SELECTORS = 16;
1464+
14561465
export function parseSelectors(raw: string | undefined): string[] {
14571466
if (!raw) return [];
14581467
return raw
14591468
.split(",")
14601469
.map((s) => s.trim())
1461-
.filter((s) => s.length > 0 && VALID_SELECTOR.test(s));
1470+
.filter(
1471+
(s) =>
1472+
s.length > 0 &&
1473+
s.length <= MAX_SELECTOR_LENGTH &&
1474+
VALID_SELECTOR.test(s),
1475+
)
1476+
.slice(0, MAX_SELECTORS);
14621477
}
14631478

14641479
// Cron handler — runs nightly per the `[triggers] crons` entry in wrangler.toml.

src/mcp/handler.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,22 @@ import type { ScoringConfig } from "../shared/scoring.js";
1111
// DKIM selector charset per RFC 6376 §3.1 — mirrors VALID_SELECTOR in index.ts.
1212
const VALID_SELECTOR = /^[A-Za-z0-9._-]+$/;
1313

14-
function parseSelectorsFromArray(raw: string[]): string[] {
15-
return raw.filter((s) => s.length > 0 && VALID_SELECTOR.test(s));
14+
// Mirrors MAX_SELECTOR_LENGTH / MAX_SELECTORS in src/index.ts
15+
// (GHSA-6fqp-4vhc-59mf). Enforced server-side regardless of the inputSchema
16+
// maxItems/maxLength below, which are advisory only — handleToolCall never
17+
// validates arguments against the schema.
18+
const MAX_SELECTOR_LENGTH = 63;
19+
const MAX_SELECTORS = 16;
20+
21+
export function parseSelectorsFromArray(raw: string[]): string[] {
22+
return raw
23+
.filter(
24+
(s) =>
25+
s.length > 0 &&
26+
s.length <= MAX_SELECTOR_LENGTH &&
27+
VALID_SELECTOR.test(s),
28+
)
29+
.slice(0, MAX_SELECTORS);
1630
}
1731

1832
export const MCP_PROTOCOL_VERSION = "2025-03-26";
@@ -46,7 +60,12 @@ const SCAN_DOMAIN_TOOL = {
4660
},
4761
dkim_selectors: {
4862
type: "array",
49-
items: { type: "string", pattern: "^[A-Za-z0-9._-]+$" },
63+
maxItems: MAX_SELECTORS,
64+
items: {
65+
type: "string",
66+
pattern: "^[A-Za-z0-9._-]+$",
67+
maxLength: MAX_SELECTOR_LENGTH,
68+
},
5069
description:
5170
"Extra DKIM selectors to probe beyond the built-in defaults.",
5271
},

test/index.test.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { beforeEach, describe, expect, it, vi } from "vitest";
22
import { COMMON_SELECTORS } from "../src/analyzers/dkim.js";
3-
import { app, normalizeDomain, parseSelectors } from "../src/index.js";
3+
import {
4+
app,
5+
MAX_SELECTOR_LENGTH,
6+
MAX_SELECTORS,
7+
normalizeDomain,
8+
parseSelectors,
9+
} from "../src/index.js";
410
import { _memoryStore } from "../src/rate-limit.js";
511
import { LEARN_SIBLINGS } from "../src/views/learn.js";
612

@@ -124,6 +130,21 @@ describe("parseSelectors", () => {
124130
it("returns single selector", () => {
125131
expect(parseSelectors("google")).toEqual(["google"]);
126132
});
133+
134+
// f27 / GHSA-6fqp-4vhc-59mf — attacker-controlled selector lists must not
135+
// fan out into one DNS lookup each with no ceiling.
136+
it("caps the number of selectors at MAX_SELECTORS", () => {
137+
const many = Array.from({ length: 500 }, (_, i) => `s${i}`).join(",");
138+
const result = parseSelectors(many);
139+
expect(result.length).toBe(MAX_SELECTORS);
140+
expect(result[0]).toBe("s0"); // keeps the first N, deterministically
141+
});
142+
143+
it("drops selectors longer than MAX_SELECTOR_LENGTH", () => {
144+
const tooLong = "a".repeat(MAX_SELECTOR_LENGTH + 1);
145+
const ok = "b".repeat(MAX_SELECTOR_LENGTH);
146+
expect(parseSelectors(`${tooLong},${ok},google`)).toEqual([ok, "google"]);
147+
});
127148
});
128149

129150
describe("normalizeDomain — extended edge cases", () => {

test/mcp.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, expect, it, vi } from "vitest";
2+
import { MAX_SELECTORS } from "../src/index.js";
23
import {
34
handleMcpRequest,
45
MCP_PROTOCOL_VERSION,
@@ -214,6 +215,26 @@ describe("handleMcpRequest — tools/call", () => {
214215
expect(vi.mocked(scan)).toHaveBeenCalled();
215216
});
216217

218+
// f27 / GHSA-6fqp-4vhc-59mf — the MCP path must enforce the selector count
219+
// cap server-side (the inputSchema maxItems is advisory only).
220+
it("caps dkim_selectors at MAX_SELECTORS before calling scan", async () => {
221+
const { scan } = await import("../src/orchestrator.js");
222+
vi.mocked(scan).mockClear();
223+
const many = Array.from({ length: 500 }, (_, i) => `s${i}`);
224+
const { json } = await rpc({
225+
jsonrpc: "2.0",
226+
id: 71,
227+
method: "tools/call",
228+
params: {
229+
name: "scan_domain",
230+
arguments: { domain: "example.com", dkim_selectors: many },
231+
},
232+
});
233+
expect((json.result as { isError: boolean }).isError).toBe(false);
234+
const selectorsArg = vi.mocked(scan).mock.calls[0]?.[1] as string[];
235+
expect(selectorsArg.length).toBe(MAX_SELECTORS);
236+
});
237+
217238
it("preserves JSON-RPC id across the full call", async () => {
218239
const { json } = await rpc({
219240
jsonrpc: "2.0",

test/mta-sts.test.ts

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ vi.mock("../src/dns/client.js", () => ({
55
queryMx: vi.fn(),
66
}));
77

8-
import { analyzeMtaSts } from "../src/analyzers/mta-sts.js";
8+
import { analyzeMtaSts, MAX_POLICY_BYTES } from "../src/analyzers/mta-sts.js";
99
import { queryTxt } from "../src/dns/client.js";
1010

1111
const mockQueryTxt = vi.mocked(queryTxt);
@@ -15,11 +15,22 @@ beforeEach(() => {
1515
vi.restoreAllMocks();
1616
});
1717

18+
// fetchPolicy reads the body via resp.arrayBuffer() (size-capped, mirroring
19+
// security-txt) — so the mock must expose arrayBuffer, not just text.
1820
function mockFetchPolicy(body: string | null, ok = true) {
1921
vi.spyOn(globalThis, "fetch").mockResolvedValue(
2022
body === null
21-
? ({ ok: false, text: async () => "" } as Response)
22-
: ({ ok, text: async () => body } as Response),
23+
? ({
24+
ok: false,
25+
text: async () => "",
26+
arrayBuffer: async () => new ArrayBuffer(0),
27+
} as Response)
28+
: ({
29+
ok,
30+
text: async () => body,
31+
arrayBuffer: async () =>
32+
new TextEncoder().encode(body).buffer as ArrayBuffer,
33+
} as Response),
2334
);
2435
}
2536

@@ -57,6 +68,28 @@ describe("analyzeMtaSts", () => {
5768
).toBe(true);
5869
});
5970

71+
// f29 / GHSA-p676-gc7j-96mx — an attacker who controls mta-sts.<domain> must
72+
// not be able to stream an unbounded body into memory. The fetch body is
73+
// capped at MAX_POLICY_BYTES; anything past the cap is never parsed.
74+
it("caps the policy body and ignores content beyond MAX_POLICY_BYTES", async () => {
75+
mockQueryTxt.mockResolvedValue({
76+
entries: ["v=STSv1; id=20240101"],
77+
raw: "v=STSv1; id=20240101",
78+
});
79+
// Valid directives in the first <64KB; a sentinel mx line pushed past the
80+
// cap by a giant colon-less (therefore ignored-by-parser) filler line.
81+
const prefix =
82+
"version: STSv1\nmode: enforce\nmx: legit.example.com\nmax_age: 86400\n";
83+
const filler = `${"#".repeat(MAX_POLICY_BYTES)}\n`;
84+
const sentinel = "mx: sneaky.evil.example\n";
85+
mockFetchPolicy(prefix + filler + sentinel);
86+
87+
const result = await analyzeMtaSts("example.com");
88+
expect(result.policy?.mx).toContain("legit.example.com");
89+
// The sentinel sits beyond the byte cap, so it must never reach the parser.
90+
expect(result.policy?.mx).not.toContain("sneaky.evil.example");
91+
});
92+
6093
it("passes when DNS record found with v=STSv1", async () => {
6194
mockQueryTxt.mockResolvedValue({
6295
entries: ["v=STSv1; id=20240101"],

0 commit comments

Comments
 (0)