@@ -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 ( ) ;
@@ -1347,6 +1428,33 @@ describe("Webhook notification tasks", () => {
13471428 expect ( error . httpStatus ) . toBe ( 400 ) ;
13481429 } ) ;
13491430
1431+ // The body was withheld for a private destination but the reason phrase was
1432+ // not, and a server is free to put anything in it. That left the SSRF read
1433+ // open through a narrower channel: the internal service names its own index
1434+ // in the phrase, and it reached both the message and `httpStatusText`.
1435+ test ( "Slack does not echo a private endpoint's reason phrase" , async ( ) => {
1436+ mockFetch . mockImplementation ( ( ) =>
1437+ Promise . resolve (
1438+ new Response ( ES_BODY , { status : 400 , statusText : "index=cluster-secrets shard=3" } )
1439+ )
1440+ ) ;
1441+
1442+ const error = ( await slackNotify ( {
1443+ url : PRIVATE_URL ,
1444+ text : "x" ,
1445+ allow_private_destination : true ,
1446+ } ) . catch ( ( e : unknown ) => e ) ) as PermanentJobError & {
1447+ httpStatus ?: number ;
1448+ httpStatusText ?: string ;
1449+ } ;
1450+
1451+ expect ( error . message ) . not . toContain ( "cluster-secrets" ) ;
1452+ // The status still reports; only the caller-controlled text is withheld.
1453+ expect ( error . message ) . toContain ( "400" ) ;
1454+ expect ( error . httpStatus ) . toBe ( 400 ) ;
1455+ expect ( error . httpStatusText ) . toBeUndefined ( ) ;
1456+ } ) ;
1457+
13501458 test ( "Discord does not echo a private endpoint's failure body" , async ( ) => {
13511459 mockFetch . mockImplementation ( ( ) =>
13521460 Promise . resolve ( new Response ( ES_BODY , { status : 400 , statusText : "Bad Request" } ) )
0 commit comments