-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
fix(core): Filter collected HTTP bodies and redact browser GraphQL document literals #24178
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| import { isPlainObject } from '../is'; | ||
| import { FILTERED_VALUE } from './filtering-snippets'; | ||
| import { shouldFilterDataKey } from './filterKeyValueData'; | ||
| import { filterQueryParams } from './filterQueryParams'; | ||
|
|
||
| /** Matches `key=value&key2=value2` bodies, the only non-JSON shape whose keys the denylist can check. */ | ||
| const FORM_BODY_RE = /^[^=&]+=[^&]*(?:&[^=&]+=[^&]*)*$/; | ||
|
|
||
| /** | ||
| * Scrubs an HTTP body the SDK collected itself, before it becomes `request.data` or | ||
| * `http.request.body.data`. A parseable body keeps its shape, and only the values of sensitive keys | ||
| * are replaced. An unparseable body has no keys to match, so the whole value becomes `[Filtered]`. | ||
| */ | ||
| export function filterCollectedHttpBody(body: unknown): unknown { | ||
| if (body == null) { | ||
| return body; | ||
| } | ||
|
|
||
| if (typeof body === 'string') { | ||
| return filterCollectedHttpBodyString(body); | ||
| } | ||
|
|
||
| // A `Buffer`, a stream, or a number has no keys to match, so the whole value is filtered. | ||
| return isPlainObject(body) || Array.isArray(body) ? filterBodyValue(body) : FILTERED_VALUE; | ||
| } | ||
|
|
||
| /** | ||
| * String-only variant of {@link filterCollectedHttpBody}. Capture sites call this before they | ||
| * truncate, because a truncated JSON body no longer parses and would be dropped wholesale. | ||
| */ | ||
| export function filterCollectedHttpBodyString(body: string): string { | ||
| if (!body) { | ||
| return body; | ||
| } | ||
|
|
||
| try { | ||
| const json: unknown = JSON.parse(body); | ||
| // A bare JSON scalar (`"hi"`, `42`) has no keys to match against, so it counts as unparseable. | ||
| if (typeof json === 'object' && json !== null) { | ||
| return JSON.stringify(filterBodyValue(json)); | ||
| } | ||
| } catch { | ||
| // Not JSON. The form-encoded attempt below runs instead. | ||
| } | ||
|
|
||
| // The query-param filter keeps the body's original encoding byte-for-byte. | ||
| return (FORM_BODY_RE.test(body) && filterQueryParams(body, true)) || FILTERED_VALUE; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Form heuristic leaks unstructured bodiesMedium Severity After Additional Locations (1)Triggered by project rule: PR Review Guidelines for Cursor Bot Reviewed by Cursor Bugbot for commit c943d35. Configure here. |
||
| } | ||
|
|
||
| function filterBodyValue(value: unknown): unknown { | ||
| if (Array.isArray(value)) { | ||
| return value.map(filterBodyValue); | ||
| } | ||
|
|
||
| if (!isPlainObject(value)) { | ||
| return value; | ||
| } | ||
|
|
||
| const result: Record<string, unknown> = {}; | ||
| for (const [key, nested] of Object.entries(value)) { | ||
| result[key] = shouldFilterDataKey(key, true) ? FILTERED_VALUE : filterBodyValue(nested); | ||
|
|
||
| } | ||
| return result; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| import { describe, expect, it, vi } from 'vitest'; | ||
| import { patchRequestToCaptureBody } from '../../../../src/integrations/http/patch-request-to-capture-body'; | ||
| import type { HttpIncomingMessage } from '../../../../src/integrations/http/types'; | ||
| import type { Scope } from '../../../../src/scope'; | ||
|
|
||
| function makeFakeRequest(): { req: HttpIncomingMessage; emit: (event: string, ...args: unknown[]) => void } { | ||
| const listeners: Record<string, ((...args: unknown[]) => void)[]> = {}; | ||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| const req: any = { | ||
| on(event: string, cb: (...args: unknown[]) => void) { | ||
| (listeners[event] ??= []).push(cb); | ||
| return req; | ||
| }, | ||
| off(event: string, cb: (...args: unknown[]) => void) { | ||
| listeners[event] = (listeners[event] ?? []).filter(listener => listener !== cb); | ||
| return req; | ||
| }, | ||
| }; | ||
| req.addListener = req.on; | ||
| req.removeListener = req.off; | ||
|
|
||
| return { | ||
| req: req as HttpIncomingMessage, | ||
| emit: (event, ...args) => (listeners[event] ?? []).slice().forEach(cb => cb(...args)), | ||
| }; | ||
| } | ||
|
|
||
| function capture(chunks: string[]): ReturnType<typeof vi.fn> { | ||
| const setSDKProcessingMetadata = vi.fn(); | ||
| const scope = { setSDKProcessingMetadata } as unknown as Scope; | ||
| const { req, emit } = makeFakeRequest(); | ||
|
|
||
| patchRequestToCaptureBody(req, scope, 'small', 'test'); | ||
| // The patch only records chunks when the app itself consumes the body. | ||
| req.on('data', () => {}); | ||
| chunks.forEach(chunk => emit('data', Buffer.from(chunk))); | ||
| emit('end'); | ||
|
|
||
| return setSDKProcessingMetadata; | ||
| } | ||
|
|
||
| function expectCapturedBody(spy: ReturnType<typeof vi.fn>, data: unknown): void { | ||
| expect(spy).toHaveBeenCalledWith({ normalizedRequest: { data } }); | ||
| } | ||
|
|
||
| describe('patchRequestToCaptureBody', () => { | ||
| it('filters sensitive keys in a complete JSON body', () => { | ||
| expectCapturedBody(capture(['{"colour":"blue",', '"token":"abc"}']), '{"colour":"blue","token":"[Filtered]"}'); | ||
| }); | ||
|
|
||
| it('keeps the filter-then-truncate order for a body that overshoots the limit in its final chunk', () => { | ||
| // 9 bytes of prefix + 988 kept characters + `...` = the 1000-byte `small` limit. | ||
| expectCapturedBody(capture([`{"note":"${'x'.repeat(1200)}"}`]), expect.stringMatching(/^\{"note":"x{988}\.\.\.$/)); | ||
| }); | ||
|
|
||
| it('filters a capped stream wholesale, since the dropped chunks make it unparseable', () => { | ||
| expectCapturedBody(capture([`{"note":"${'x'.repeat(1200)}"}`, '{"more":"data"}']), '[Filtered]'); | ||
| }); | ||
|
|
||
| it('filters a body that cannot be parsed into key-value pairs', () => { | ||
| expectCapturedBody(capture(['plain text body']), '[Filtered]'); | ||
| }); | ||
|
|
||
| it('attaches nothing for an empty body', () => { | ||
| expect(capture([])).not.toHaveBeenCalled(); | ||
| }); | ||
| }); |


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The current spec says raw bodies should be filtered: https://develop.sentry.dev/sdk/foundations/client/data-collection/#request-and-response-bodies