AntFleet

Anatomy · 70f6bb2c-0

Unhandled missing OPTIN_HMAC_SECRET causes 500 instead of a friendly error

lowmaintainabilityclosed in a58382a
repo e24ef98c·PR #9·reviewed 1 week ago·closed 1 week ago

The vulnerable code

apps/web/lib/optin-token.ts:0-0

0import { createHmac, timingSafeEqual } from "node:crypto";
1
2// Stateless signed token used by the Onboarder's self-serve
3// public-receipts opt-in link. No DB table — the token *is* the bearer
4// of the (install, owner, repo) tuple, and the HMAC binds it to the
5// server's OPTIN_HMAC_SECRET. Idempotent flip on click means replay
6// inside the validity window is a no-op, not an exploit.
7
8export const OPTIN_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000;
9
10export type OptInPayload = {
11 installationId: number;
12 owner: string;
13 repo: string;
14};
15
16type SignedPayload = OptInPayload & { exp: number };
17
18function getSecret(): string {
19 const secret = process.env["OPTIN_HMAC_SECRET"];
20 if (secret === undefined || secret.length === 0) {
21 throw new Error("OPTIN_HMAC_SECRET is required to sign or verify opt-in tokens");
22 }
23 return secret;
24}
25
26function base64urlEncode(input: Buffer | string): string {
27 const buf = typeof input === "string" ? Buffer.from(input, "utf8") : input;
28 return buf
29 .toString("base64")
30 .replace(/=+$/u, "")
31 .replace(/\+/gu, "-")
32 .replace(/\//gu, "_");
33}
34
35function base64urlDecode(input: string): Buffer {
36 const padded = input.replace(/-/gu, "+").replace(/_/gu, "/");
37 const pad = padded.length % 4 === 0 ? "" : "=".repeat(4 - (padded.length % 4));
38 return Buffer.from(padded + pad, "base64");
39}
40
41function hmacFor(secret: string, payload: string): Buffer {
42 return createHmac("sha256", secret).update(payload).digest();
43}
44
45export function signToken(
46 payload: OptInPayload,
47 now: Date = new Date(),
48): string {
49 const signed: SignedPayload = {
50 installationId: payload.installationId,
51 owner: payload.owner,
52 repo: payload.repo,
53 exp: now.getTime() + OPTIN_TOKEN_TTL_MS,
54 };
55 const encodedPayload = base64urlEncode(JSON.stringify(signed));
56 const mac = hmacFor(getSecret(), encodedPayload);
57 return `${encodedPayload}.${base64urlEncode(mac)}`;
58}
59
60export type VerifyResult =
61 | { kind: "ok"; payload: OptInPayload }
62 | { kind: "expired" }
63 | { kind: "invalid" };
64
65export function verifyTokenDetailed(
66 token: string,
67 now: Date = new Date(),
68): VerifyResult {
69 if (typeof token !== "string" || token.length === 0) return { kind: "invalid" };
70 const dot = token.indexOf(".");
71 if (dot === -1 || dot === token.length - 1) return { kind: "invalid" };
72 const encodedPayload = token.slice(0, dot);
73 const encodedMac = token.slice(dot + 1);
74
75 const expectedMac = hmacFor(getSecret(), encodedPayload);
76 let providedMac: Buffer;
77 try {
78 providedMac = base64urlDecode(encodedMac);
79 } catch {
80 return { kind: "invalid" };
81 }
82 if (providedMac.length !== expectedMac.length) return { kind: "invalid" };
83 if (!timingSafeEqual(expectedMac, providedMac)) return { kind: "invalid" };
84
85 let parsed: unknown;
86 try {
87 parsed = JSON.parse(base64urlDecode(encodedPayload).toString("utf8"));
88 } catch {
89 return { kind: "invalid" };
90 }
91 if (typeof parsed !== "object" || parsed === null) return { kind: "invalid" };
92 const obj = parsed as Record<string, unknown>;
93 const installationId = obj["installationId"];
94 const owner = obj["owner"];
95 const repo = obj["repo"];
96 const exp = obj["exp"];
97 if (
98 typeof installationId !== "number" ||
99 typeof owner !== "string" ||
100 typeof repo !== "string" ||
101 typeof exp !== "number"
102 ) {
103 return { kind: "invalid" };
104 }
105 if (now.getTime() >= exp) return { kind: "expired" };
106 return { kind: "ok", payload: { installationId, owner, repo } };
107}
108
109export function verifyToken(
110 token: string,
111 now: Date = new Date(),
112): OptInPayload | null {
113 const result = verifyTokenDetailed(token, now);
114 return result.kind === "ok" ? result.payload : null;
115}
116
117// Short stable correlation handle for logs. Never log the raw token —
118// it's a bearer credential for 30 days.
119export function tokenLogId(token: string): string {
120 return createHmac("sha256", "antfleet.optin.log").update(token).digest("hex").slice(0, 12);
121}
122
123// Build the absolute URL the Onboarder injects into the first-review
124// summary comment. action defaults to "enable"; pass "disable" for the
125// reversal link.
126export function buildOptInUrl(args: {
127 baseUrl: string;
128 payload: OptInPayload;
129 action?: "enable" | "disable";
130 now?: Date;
131}): string {
132 const token = signToken(args.payload, args.now);
133 const url = new URL("/api/opt-in", args.baseUrl);
134 url.searchParams.set("t", token);
135 if (args.action === "disable") {
136 url.searchParams.set("action", "disable");
137 }
138 return url.toString();
139}
140

The reasoning

Opus

Output unavailable for this row.

GPT-5

Unhandled missing OPTIN_HMAC_SECRET causes 500 instead of a friendly error

lowmaintainabilityhigh
  • apps/web/lib/optin-token.ts
  • apps/web/app/api/opt-in/route.ts
If OPTIN_HMAC_SECRET is unset in the environment, verifyTokenDetailed() will throw inside getSecret(). The route does not catch this, yielding a 500 without a clear user-facing explanation. While this is a deploy misconfiguration, it’s better to fail with a controlled HTML response and a clear log.

Recommendation

Wrap token verification in try/catch. On error, log and return htmlResponse(500, errorPage("Server misconfigured", "...")), or validate presence of OPTIN_HMAC_SECRET at process startup to fail fast with a clear error.

The agreement

Both frontier models flagged this within the same line range. AntFleet's unanimous gate fired — the finding posted on the PR. Closed in a58382a.

The fix

0import { createHmac, timingSafeEqual } from "node:crypto";
1
2// Stateless signed token used by the Onboarder's self-serve
3// public-receipts opt-in link. No DB table — the token *is* the bearer
4// of the (install, owner, repo) tuple, and the HMAC binds it to the
5// server's OPTIN_HMAC_SECRET. Idempotent flip on click means replay
6// inside the validity window is a no-op, not an exploit.
7
8export const OPTIN_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000;
9
10export type OptInPayload = {
11 installationId: number;
12 owner: string;
13 repo: string;
14};
15
16type SignedPayload = OptInPayload & { exp: number };
17
18function getSecret(): string {
19 const secret = process.env["OPTIN_HMAC_SECRET"];
20 if (secret === undefined || secret.length === 0) {
21 throw new Error("OPTIN_HMAC_SECRET is required to sign or verify opt-in tokens");
22 }
23 return secret;
24}
25
26function base64urlEncode(input: Buffer | string): string {
27 const buf = typeof input === "string" ? Buffer.from(input, "utf8") : input;
28 return buf
29 .toString("base64")
30 .replace(/=+$/u, "")
31 .replace(/\+/gu, "-")
32 .replace(/\//gu, "_");
33}
34
35function base64urlDecode(input: string): Buffer {
36 const padded = input.replace(/-/gu, "+").replace(/_/gu, "/");
37 const pad = padded.length % 4 === 0 ? "" : "=".repeat(4 - (padded.length % 4));
38 return Buffer.from(padded + pad, "base64");
39}
40
41function hmacFor(secret: string, payload: string): Buffer {
42 return createHmac("sha256", secret).update(payload).digest();
43}
44
45export function signToken(
46 payload: OptInPayload,
47 now: Date = new Date(),
48): string {
49 const signed: SignedPayload = {
50 installationId: payload.installationId,
51 owner: payload.owner,
52 repo: payload.repo,
53 exp: now.getTime() + OPTIN_TOKEN_TTL_MS,
54 };
55 const encodedPayload = base64urlEncode(JSON.stringify(signed));
56 const mac = hmacFor(getSecret(), encodedPayload);
57 return `${encodedPayload}.${base64urlEncode(mac)}`;
58}
59
60export type VerifyResult =
61 | { kind: "ok"; payload: OptInPayload }
62 | { kind: "expired" }
63 | { kind: "invalid" };
64
65export function verifyTokenDetailed(
66 token: string,
67 now: Date = new Date(),
68): VerifyResult {
69 if (typeof token !== "string" || token.length === 0) return { kind: "invalid" };
70 const dot = token.indexOf(".");
71 if (dot === -1 || dot === token.length - 1) return { kind: "invalid" };
72 const encodedPayload = token.slice(0, dot);
73 const encodedMac = token.slice(dot + 1);
74
75 const expectedMac = hmacFor(getSecret(), encodedPayload);
76 let providedMac: Buffer;
77 try {
78 providedMac = base64urlDecode(encodedMac);
79 } catch {
80 return { kind: "invalid" };
81 }
82 if (providedMac.length !== expectedMac.length) return { kind: "invalid" };
83 if (!timingSafeEqual(expectedMac, providedMac)) return { kind: "invalid" };
84
85 let parsed: unknown;
86 try {
87 parsed = JSON.parse(base64urlDecode(encodedPayload).toString("utf8"));
88 } catch {
89 return { kind: "invalid" };
90 }
91 if (typeof parsed !== "object" || parsed === null) return { kind: "invalid" };
92 const obj = parsed as Record<string, unknown>;
93 const installationId = obj["installationId"];
94 const owner = obj["owner"];
95 const repo = obj["repo"];
96 const exp = obj["exp"];
97 if (
98 typeof installationId !== "number" ||
99 typeof owner !== "string" ||
100 typeof repo !== "string" ||
101 typeof exp !== "number"
102 ) {
103 return { kind: "invalid" };
104 }
105 if (now.getTime() >= exp) return { kind: "expired" };
106 return { kind: "ok", payload: { installationId, owner, repo } };
107}
108
109export function verifyToken(
110 token: string,
111 now: Date = new Date(),
112): OptInPayload | null {
113 const result = verifyTokenDetailed(token, now);
114 return result.kind === "ok" ? result.payload : null;
115}
116
117// Short stable correlation handle for logs. Never log the raw token —
118// it's a bearer credential for 30 days.
119export function tokenLogId(token: string): string {
120 return createHmac("sha256", "antfleet.optin.log").update(token).digest("hex").slice(0, 12);
121}
122
123// Build the absolute URL the Onboarder injects into the first-review
124// summary comment. action defaults to "enable"; pass "disable" for the
125// reversal link.
126export function buildOptInUrl(args: {
127 baseUrl: string;
128 payload: OptInPayload;
129 action?: "enable" | "disable";
130 now?: Date;
131}): string {
132 const token = signToken(args.payload, args.now);
133 const url = new URL("/api/opt-in", args.baseUrl);
134 url.searchParams.set("t", token);
135 if (args.action === "disable") {
136 url.searchParams.set("action", "disable");
137 }
138 return url.toString();
139}
140

Closure

Closed 1 week ago

SHA: a58382a1c8934544d327ad62fd4c9c54b187d8ef

View closure receipt on GitHub →

Tweet thread template

tweet 1 of 8165 / 280

Two frontier models reviewed PR #9 on e24ef98c. Both found this bug: low maintainability: Unhandled missing OPTIN_HMAC_SECRET causes 500 instead of a friendly error

tweet 2 of 8117 / 280

The vulnerable code (apps/web/lib/optin-token.ts:0-0): (full snippet at https://www.antfleet.dev/anatomy/70f6bb2c-0)

tweet 3 of 836 / 280

What Opus saw: "Output unavailable"

tweet 4 of 8280 / 280

What GPT-5 saw: "If OPTIN_HMAC_SECRET is unset in the environment, verifyTokenDetailed() will throw inside getSecret(). The route does not catch this, yielding a 500 without a clear user-facing explanation. While this is a deploy misconfiguration, it’s better to fail with a con…

tweet 5 of 897 / 280

Both flagged the same line range. AntFleet's unanimous gate fired — the finding posted on the PR.

tweet 6 of 893 / 280

The fix landed in commit a58382a: (view diff at https://www.antfleet.dev/anatomy/70f6bb2c-0)

tweet 7 of 881 / 280

AntFleet reviews every PR with two frontier models. Only unanimous findings post.

tweet 8 of 877 / 280

Full anatomy + reasoning + diffs: https://www.antfleet.dev/anatomy/70f6bb2c-0

Paste into X composer one tweet at a time. X has no multi-tweet intent API.