[HDX-4173] Redact sensitive fields from internal webhook API responses#2239
[HDX-4173] Redact sensitive fields from internal webhook API responses#2239
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
🦋 Changeset detectedLatest commit: c93e4b7 The changes in this PR will be included in the next version bump. This PR includes changesets to release 3 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
E2E Test Results✅ All tests passed • 167 passed • 3 skipped • 1212s
Tests ran across 4 shards in parallel. |
🟡 Tier 3 — StandardIntroduces new logic, modifies core functionality, or touches areas with non-trivial risk. Why this tier:
Review process: Full human review — logic, architecture, edge cases. Stats
|
PR ReviewThorough fix for the secret-exposure issue with strong defense-in-depth — redaction sentinel, URL/origin masking, exfil guards on URL change, scoped POST /test resolution, and a TOCTOU guard. Tests and changeset are comprehensive. Notes (non-blocking)
No critical bugs or security regressions identified. |
Deep Review✅ No critical issues found. The redaction protocol's primary defenses — origin-only 🟡 P2 -- recommended
🔵 P3 nitpicks (10)
Reviewers (11): correctness, security, testing, maintainability, project-standards, agent-native, learnings-researcher, api-contract, reliability, adversarial, kieran-typescript. Testing gaps:
|
…-4173] The GET /api/webhooks endpoint exposed sensitive credentials (URLs with embedded tokens, auth headers, API keys in query params) to any authenticated team member. This applies the same redaction pattern used by the external API to the internal API. - Mask webhook URLs to origin/**** in all API responses - Redact header and queryParam values (preserve keys, replace values with ****) - Merge logic on PUT preserves stored secrets when redacted markers are sent back, removes keys the user deleted, and accepts new values - Add UX helper text in the edit form for masked fields - Add comprehensive unit tests for redaction and merge behavior - Add E2E test covering the full secret-redaction user flow - Update existing test assertions for sanitized responses
… locator ambiguity - Use $set/$unset in Mongoose findOneAndUpdate so clearing headers/ queryParams actually removes the fields from the document - Remove ambiguous regex assertion in E2E create/delete webhook test that matched multiple masked URLs across parallel test data - Simplify E2E redaction test to assert original URL is hidden rather than matching masked URL text in DOM (avoids strict mode violations) - Add changeset for HDX-4173
…ebhook - isMaskedUrl now compares against the exact masked form of the stored URL (maskUrl(existingUrl) === url) instead of just checking pathname shape, preventing a different-origin URL with /**** from silently resolving to the stored URL - POST /webhooks/test accepts an optional webhookId; when present the handler resolves masked URL and redacted headers from the stored webhook before sending, so Test Webhook works correctly in edit mode - Frontend passes webhookId when testing an existing webhook - Add tests: different-origin /**** URL is stored (not masked), webhookId resolves masked values for test, cross-team webhookId is ignored
…erage Security (P0/P1): - PUT rejects masked header/queryParam values when URL is changing, preventing stored secrets from being redirected to an attacker host - POST /test only resolves stored secrets when the submitted URL matches the stored URL or its masked form; different-origin URLs skip resolution entirely - POST /test returns 404 (not silent fallthrough) when webhookId is provided but not found or belongs to another team - POST /test validates webhookId as ObjectId (400 on malformed) - Duplicate-URL check uses existing service when URL is preserved via masked roundtrip to prevent cross-service existence oracle Type safety / code quality (P1/P2): - sanitizeWebhook uses concrete WebhookApiData type instead of unsound generic <T extends Record<string,unknown>> - Extract toWebhookPlain() helper to deduplicate toJSON cast - mergeRedactedMap uses 'key in existing' instead of truthy check so empty-string stored values are preserved - Add mapHasRedactedValues() helper for exfiltration guard checks Testing (P1/P2): - Mock handleSendGenericWebhook/handleSendSlackWebhook via jest.spyOn in all POST /test tests; assert resolved URL and headers on spy args - Add exfiltration guard tests for PUT (masked headers + new URL → 400) - Add exfiltration guard test for POST /test (different URL skips secret resolution) - Add joint / test (clear headers while preserving queryParams) - Add webhookId 404 and 400 tests Documentation / API contract: - Bump changeset to minor (response shape is breaking for consumers) - Add module-level comment documenting the redaction round-trip protocol - Add JSDoc to WebhookSchema in common-utils noting masked semantics - Guard empty-headers helper text in WebhookForm with length check
When the URL changes, omitted headers/queryParams must not be preserved from the stored webhook (mergeRedactedMap returns existing secrets for undefined input). Now the PUT handler uses submitted values as-is when urlChanged is true: undefined → $unset, new values → $set, no merge. Also: extend changeset breaking note to cover POST/PUT responses, fix JSDoc inaccuracy about external API v2 URL handling.
f7a32ea to
4989340
Compare
- Fix duplicate-check bypass when service changes with masked URL
- Fix mergeRedactedMap wiping stored headers on orphaned redacted keys
- Fix empty-headers storage divergence between urlChanged branches
- Add TOCTOU guard on PUT (condition findOneAndUpdate on stored URL)
- Replace WebhookPlain with Pick<WebhookApiData> for tighter types
- Create serializeWebhook helper replacing unsafe as-casts
- Drop redundant | { message: string } union on POST /test response
- Strengthen queryParams rejection test (message + stored-value assertion)
- Add spy assertions to 404 tests (verify no message sent before check)
- Strengthen leak assertion (check actual token, not substring)
- Fix WebhookForm hint to derive from live form state via useWatch
- Add field descriptions to WebhookSchema documenting sentinel contract
- Reframe changeset as security fix (not breaking change)
Deep Review ResponseAddressed 13 of the 28 items (P0/P1 + P2 scope). Commit: c93e4b7 Fixed (10)
Addressed: Duplicate check now always uses the submitted
Addressed: When all incoming keys are redacted-but-orphaned (key sent as
Addressed: Added
Addressed:
Addressed: Created
Addressed: Replaced with
Addressed: Dropped the union —
Addressed: Added
Addressed: Added
Addressed: Now asserts Fixed differently (1)
Fixed differently: Added Replied (2)
Added
Updated: Removed "Breaking:" prefix. This is a security fix — these values should have always been redacted. Kept as Declined (4)
Declined: Strict comparison is the safer default. URL canonicalization (
Declined: The form doesn't expose
Declined: Server-side Zod validation catches invalid ObjectIds. A shared schema for a single optional field is premature abstraction.
Declined: Team members testing team webhooks is by design — this is team-level access control. Rate limiting is a separate concern. Not in scope — track as follow-up (3)
Remaining P3 items (deferred by scope decision)Items #16–29 (file extraction, REDACTED_VALUE to common-utils, reject literal Validation: |
Summary
Fixes a security vulnerability (CWE-522) where the internal
GET /api/webhooksendpoint exposed sensitive credentials — webhook URLs with embedded tokens, auth headers, and API keys in query params — to any authenticated team member.This applies the same redaction pattern already used by the external API (
GET /api/v2/webhooks) to the internal API:${origin}/****, hiding path segments that contain tokens (e.g., Slack webhook URLs)****, so users can see what's configured without seeing the secrets****) are resolved back to the stored originals; new values replace them; removed keys are deleted from storageHow to test locally or on Vercel
https://example.com/****) and header values show****References