Skip to content

Commit f562723

Browse files
committed
fix(tasks): redact webhook query values and the reason phrase
The webhook URL is the credential, and redactWebhookUrlIn is what keeps it out of a diagnostic that quotes an endpoint's reply. Two shapes escaped it. Query values were never candidates. Only `pathname+search`, `search` and the individual path segments were admitted, so a `?token=SUPERSECRET1` endpoint that answers `bad token SUPERSECRET1` matched nothing — the whole `?token=…` pair was a candidate but the echo quotes the value alone. `?token=` auth is an ordinary deployment shape, not a corner case. Query values are now admitted in both raw and decoded form; the raw query is split by hand rather than read from searchParams, whose decoder turns `+` into a space and so would not match the bytes an endpoint echoes. All-lowercase candidates were exempted outright, on the theory that such a run is a word rather than a token. A lowercase token is still a token. The exemption existed to protect `services` and `webhooks`, the routing segments of the two supported providers' paths, which are now named explicitly instead. Accepted cost, stated in the JSDoc and pinned by a test: a generic webhook whose path carries a long lowercase word has that word redacted from echoed diagnostics. Also folds in `response.statusText`, which was interpolated into the message and stored as `httpStatusText` with no redaction at all, and unlike the body was not withheld for a private destination — leaving the SSRF read open through a narrower channel, since a server can put anything in a reason phrase. The length floor is unchanged and now applies to query values too, so a short `?t=abc` still leaks. Same policy as segments, said out loud rather than silently special-cased. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H797qbH356jjznKgUax63o
1 parent 59529b9 commit f562723

2 files changed

Lines changed: 175 additions & 18 deletions

File tree

packages/tasks/src/util/WebhookPost.ts

Lines changed: 65 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,14 @@ export function redactWebhookUrl(url: string): string {
6565
}
6666
}
6767

68+
/**
69+
* Routing segments of the supported providers' webhook paths. Redacting these
70+
* would mangle prose like `invalid webhooks payload` for no gain. Everything
71+
* else long enough to be a token is redacted even when all-lowercase — a
72+
* lowercase secret is still a secret.
73+
*/
74+
const STRUCTURAL_PATH_SEGMENTS: ReadonlySet<string> = new Set(["services", "webhooks"]);
75+
6876
/**
6977
* Strips every trace of `url` out of `text`.
7078
*
@@ -73,17 +81,32 @@ export function redactWebhookUrl(url: string): string {
7381
* `Cannot POST /services/T0.../SECRETTOKEN` — the path, never the origin — and
7482
* a validation error may quote the token on its own. So after the full URL
7583
* collapses to its origin (which stays the useful diagnostic), the path, the
76-
* query and each individual path segment are replaced too.
84+
* query, each individual path segment AND each query VALUE are replaced too.
85+
*
86+
* Query values are a real carrier here, not a hypothetical one: plenty of
87+
* webhook endpoints take their token as `?token=…` rather than as a path
88+
* segment, and an echoing endpoint quotes back the value on its own.
7789
*
7890
* Candidates are applied LONGEST FIRST: replacing a short segment first would
7991
* break a longer candidate that contains it, leaving the rest of that longer
8092
* fragment behind.
8193
*
82-
* A candidate is admitted only when it is long enough to plausibly be a token
83-
* AND is not an all-lowercase word. Both clauses are load-bearing: `services`
84-
* and `webhooks` are real segments of the Slack and Discord webhook paths, and
85-
* redacting them would corrupt ordinary prose like `invalid webhooks payload`
86-
* for no security gain.
94+
* Admission is by LENGTH only. A candidate shorter than
95+
* `SECURITY_LIMITS.webhookMinRedactableSegmentChars` is far likelier to be an
96+
* ordinary word than a token, and replacing it would mangle the diagnostic this
97+
* redaction exists to preserve. That floor applies to query values too, so a
98+
* short `?t=abc` still leaks — the same policy as segments, stated rather than
99+
* silently special-cased.
100+
*
101+
* There is deliberately no "all-lowercase words are safe" exemption: a
102+
* lowercase token is still a token, and the endpoint that echoes it does not
103+
* care about its character class. The two lowercase strings actually worth
104+
* keeping are the providers' own routing segments, which are named in
105+
* {@link STRUCTURAL_PATH_SEGMENTS} instead.
106+
*
107+
* Cost, accepted: a generic webhook whose path carries a long lowercase WORD
108+
* (`/notifications/deploy`) now has that word redacted out of echoed
109+
* diagnostics.
87110
*/
88111
export function redactWebhookUrlIn(text: string, url: string): string {
89112
if (url.length === 0) {
@@ -100,20 +123,40 @@ export function redactWebhookUrlIn(text: string, url: string): string {
100123

101124
const candidates = new Set<string>();
102125
const admit = (candidate: string): void => {
103-
if (
104-
candidate.length >= SECURITY_LIMITS.webhookMinRedactableSegmentChars &&
105-
!/^[a-z]+$/.test(candidate)
106-
) {
126+
if (candidate.length >= SECURITY_LIMITS.webhookMinRedactableSegmentChars) {
107127
candidates.add(candidate);
108128
}
109129
};
130+
const admitVariants = (candidate: string): void => {
131+
admit(candidate);
132+
const encoded = encodeURIComponent(candidate);
133+
if (encoded !== candidate) {
134+
admit(encoded);
135+
}
136+
};
137+
110138
admit(`${parsed.pathname}${parsed.search}`);
111139
admit(parsed.search);
112140
for (const segment of parsed.pathname.split("/")) {
113-
admit(segment);
114-
const encoded = encodeURIComponent(segment);
115-
if (encoded !== segment) {
116-
admit(encoded);
141+
if (STRUCTURAL_PATH_SEGMENTS.has(segment)) {
142+
continue;
143+
}
144+
admitVariants(segment);
145+
}
146+
// Split the raw query rather than reading `searchParams`: the decoder turns
147+
// `+` into a space, so the decoded value would not match the bytes an
148+
// endpoint echoes back. Both forms are admitted.
149+
for (const pair of parsed.search.replace(/^\?/, "").split("&")) {
150+
const equals = pair.indexOf("=");
151+
if (equals < 0) {
152+
continue;
153+
}
154+
const raw = pair.slice(equals + 1);
155+
admit(raw);
156+
try {
157+
admit(decodeURIComponent(raw));
158+
} catch {
159+
// Malformed escape: the raw form is what appears in an echo anyway.
117160
}
118161
}
119162

@@ -582,14 +625,20 @@ export async function postWebhookJson(request: WebhookPostRequest): Promise<Webh
582625
request.includeBodyInError && !isPrivate && failureBody.length > 0
583626
? `: ${truncate(redactWebhookUrlIn(failureBody, url), MAX_ERROR_BODY_CHARS)}`
584627
: "";
628+
// The reason phrase is caller-controlled text like the body, so it gets the
629+
// same two treatments: withheld entirely for a private destination, and
630+
// redacted otherwise. A server is free to put anything in it, and the well
631+
// known phrases ("Not Found", "Bad Request") survive redaction unchanged.
632+
const statusText = isPrivate ? "" : redactWebhookUrlIn(response.statusText, url);
585633

586634
throw createFetchUrlJobError(
587635
httpStatusToFetchUrlErrorCode(response.status),
588-
`Failed to post ${label} to ${redacted}: ${response.status} ${response.statusText}${bodySuffix}`,
636+
`Failed to post ${label} to ${redacted}: ${response.status}` +
637+
`${statusText === "" ? "" : ` ${statusText}`}${bodySuffix}`,
589638
{
590639
url: redacted,
591640
httpStatus: response.status,
592-
httpStatusText: response.statusText,
641+
httpStatusText: statusText === "" ? undefined : statusText,
593642
retryDate,
594643
}
595644
);

packages/test/src/test/task/NotifyTask.test.ts

Lines changed: 110 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -471,8 +471,9 @@ describe("Webhook notification tasks", () => {
471471

472472
// The guard against over-redaction. `webhooks` is a real path segment of
473473
// every Discord webhook URL and is exactly 8 characters, so a length floor
474-
// alone would delete the word from ordinary prose. All-lowercase-alphabetic
475-
// runs are words, not tokens.
474+
// alone would delete the word from ordinary prose. It is exempt because it
475+
// is a named ROUTING segment of a supported provider, not because it is
476+
// lowercase — this pins the replacement for the deleted lowercase rule.
476477
test("an ordinary word that happens to be a path segment survives", async () => {
477478
mockFetch.mockImplementation(() =>
478479
Promise.resolve(
@@ -488,6 +489,86 @@ describe("Webhook notification tasks", () => {
488489
expect(error.message).not.toContain("SECRETTOKEN");
489490
});
490491

492+
// A token in the query string is a real deployment shape — plenty of
493+
// endpoints authenticate with `?token=…` rather than a path segment — and
494+
// nothing admitted a query VALUE as a redaction candidate. The whole
495+
// `?token=…` pair was a candidate, but an endpoint echoes the value alone,
496+
// so the pair never matched and the secret went out verbatim.
497+
test("a token carried in the query string is redacted from an echoed body", async () => {
498+
mockFetch.mockImplementation(() =>
499+
Promise.resolve(
500+
new Response("bad token SUPERSECRET1", { status: 403, statusText: "Forbidden" })
501+
)
502+
);
503+
504+
const error = (await slackNotify({
505+
url: "https://hooks.example.com/notify?token=SUPERSECRET1",
506+
text: "hi",
507+
}).catch((e: unknown) => e)) as PermanentJobError;
508+
509+
expect(error.message).not.toContain("SUPERSECRET1");
510+
expect(String(error.stack)).not.toContain("SUPERSECRET1");
511+
// The surrounding diagnostic survives; only the token is removed.
512+
expect(error.message).toContain("bad token");
513+
});
514+
515+
// An all-lowercase path segment was exempted outright, on the theory that
516+
// such a run is a word rather than a token. A lowercase token is still a
517+
// token, and the endpoint echoing it does not care about its character
518+
// class.
519+
test("an all-lowercase token in the path is redacted from an echoed body", async () => {
520+
mockFetch.mockImplementation(() =>
521+
Promise.resolve(
522+
new Response("rejected: supersecrettoken", { status: 403, statusText: "Forbidden" })
523+
)
524+
);
525+
526+
const error = (await slackNotify({
527+
url: "https://hooks.example.com/hooks/supersecrettoken",
528+
text: "hi",
529+
}).catch((e: unknown) => e)) as PermanentJobError;
530+
531+
expect(error.message).not.toContain("supersecrettoken");
532+
expect(String(error.stack)).not.toContain("supersecrettoken");
533+
expect(error.message).toContain("rejected");
534+
});
535+
536+
// The stated cost of dropping the lowercase exemption, pinned rather than
537+
// discovered later: a long lowercase word in a generic webhook's path is
538+
// now redacted out of echoed diagnostics, because nothing distinguishes it
539+
// from a lowercase token.
540+
test("a long lowercase path word in a generic webhook is redacted, the accepted cost", async () => {
541+
mockFetch.mockImplementation(() =>
542+
Promise.resolve(
543+
new Response("unknown notifications route", { status: 404, statusText: "Not Found" })
544+
)
545+
);
546+
547+
const error = (await slackNotify({
548+
url: "https://hooks.example.com/notifications/deploy",
549+
text: "hi",
550+
}).catch((e: unknown) => e)) as PermanentJobError;
551+
552+
expect(error.message).not.toContain("notifications");
553+
expect(error.message).toContain("unknown");
554+
});
555+
556+
// `statusText` is caller-controlled text just like the body, and it was
557+
// interpolated into the message and stored as `httpStatusText` with no
558+
// redaction pass over it at all.
559+
test("a reason phrase echoing the token is redacted", async () => {
560+
mockFetch.mockImplementation(() =>
561+
Promise.resolve(new Response("nope", { status: 403, statusText: "token SECRETTOKEN bad" }))
562+
);
563+
564+
const error = (await slackNotify({ url: SLACK_URL, text: "hi" }).catch(
565+
(e: unknown) => e
566+
)) as PermanentJobError & { httpStatusText?: string };
567+
568+
expect(error.message).not.toContain("SECRETTOKEN");
569+
expect(String(error.httpStatusText)).not.toContain("SECRETTOKEN");
570+
});
571+
491572
test("the webhook URL is absent from every output schema", () => {
492573
for (const taskClass of [WebhookNotifyTask, SlackNotifyTask, DiscordNotifyTask]) {
493574
const schema = taskClass.outputSchema();
@@ -1252,6 +1333,33 @@ describe("Webhook notification tasks", () => {
12521333
expect(error.httpStatus).toBe(400);
12531334
});
12541335

1336+
// The body was withheld for a private destination but the reason phrase was
1337+
// not, and a server is free to put anything in it. That left the SSRF read
1338+
// open through a narrower channel: the internal service names its own index
1339+
// in the phrase, and it reached both the message and `httpStatusText`.
1340+
test("Slack does not echo a private endpoint's reason phrase", async () => {
1341+
mockFetch.mockImplementation(() =>
1342+
Promise.resolve(
1343+
new Response(ES_BODY, { status: 400, statusText: "index=cluster-secrets shard=3" })
1344+
)
1345+
);
1346+
1347+
const error = (await slackNotify({
1348+
url: PRIVATE_URL,
1349+
text: "x",
1350+
allow_private_destination: true,
1351+
}).catch((e: unknown) => e)) as PermanentJobError & {
1352+
httpStatus?: number;
1353+
httpStatusText?: string;
1354+
};
1355+
1356+
expect(error.message).not.toContain("cluster-secrets");
1357+
// The status still reports; only the caller-controlled text is withheld.
1358+
expect(error.message).toContain("400");
1359+
expect(error.httpStatus).toBe(400);
1360+
expect(error.httpStatusText).toBeUndefined();
1361+
});
1362+
12551363
test("Discord does not echo a private endpoint's failure body", async () => {
12561364
mockFetch.mockImplementation(() =>
12571365
Promise.resolve(new Response(ES_BODY, { status: 400, statusText: "Bad Request" }))

0 commit comments

Comments
 (0)