Skip to content

Commit 87bdc84

Browse files
msonnbcodex
andauthored
fix(bundler-plugins): Escape JSON in generated injection code (#25265)
`JSON.stringify` leaves HTML script delimiters intact, allowing attacker-controlled injected values to break out if a consumer inlines the generated bundle into HTML. Escape `<` in metadata, release names, and build information so HTML parsing cannot reinterpret those values, while preserving their exact JavaScript runtime values. Fixes [code-scanning alert #467](https://github.com/getsentry/sentry-javascript/security/code-scanning/467). Co-authored-by: GPT-6 <codex@openai.com>
1 parent b80300b commit 87bdc84

2 files changed

Lines changed: 54 additions & 4 deletions

File tree

‎packages/bundler-plugins/src/core/utils.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,11 @@ export function determineReleaseName(): string | undefined {
308308
);
309309
}
310310

311+
function stringifyForCodeInjection(value: unknown): string | undefined {
312+
// Bundles may be inlined in HTML, where JSON quoting does not escape script tags or HTML comments.
313+
return JSON.stringify(value)?.replace(/</g, '\\u003c');
314+
}
315+
311316
/**
312317
* Generates code for the global injector which is responsible for setting the global
313318
* `SENTRY_RELEASE` & `SENTRY_BUILD_INFO` variables.
@@ -319,12 +324,12 @@ export function generateReleaseInjectorCode({
319324
release: string;
320325
injectBuildInformation: boolean;
321326
}): CodeInjection {
322-
let code = `e.SENTRY_RELEASE={id:${JSON.stringify(release)}};`;
327+
let code = `e.SENTRY_RELEASE={id:${stringifyForCodeInjection(release)}};`;
323328

324329
if (injectBuildInformation) {
325330
const buildInfo = getBuildInformation();
326331

327-
code += `e.SENTRY_BUILD_INFO=${JSON.stringify(buildInfo)};`;
332+
code += `e.SENTRY_BUILD_INFO=${stringifyForCodeInjection(buildInfo)};`;
328333
}
329334

330335
return new CodeInjection(code);
@@ -335,7 +340,7 @@ export function generateModuleMetadataInjectorCode(metadata: any): CodeInjection
335340
// We are merging the metadata objects in case modules are bundled twice with the plugin
336341
// Use try-catch to avoid issues when bundlers rename global variables like 'window' to 'k'
337342
return new CodeInjection(
338-
`e._sentryModuleMetadata=e._sentryModuleMetadata||{},e._sentryModuleMetadata[(new e.Error).stack]=function(e){for(var n=1;n<arguments.length;n++){var a=arguments[n];if(null!=a)for(var t in a)a.hasOwnProperty(t)&&(e[t]=a[t])}return e}({},e._sentryModuleMetadata[(new e.Error).stack],${JSON.stringify(
343+
`e._sentryModuleMetadata=e._sentryModuleMetadata||{},e._sentryModuleMetadata[(new e.Error).stack]=function(e){for(var n=1;n<arguments.length;n++){var a=arguments[n];if(null!=a)for(var t in a)a.hasOwnProperty(t)&&(e[t]=a[t])}return e}({},e._sentryModuleMetadata[(new e.Error).stack],${stringifyForCodeInjection(
339344
metadata,
340345
)});`,
341346
);

‎packages/bundler-plugins/test/core/utils.test.ts‎

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ import {
1111

1212
import childProcess from 'child_process';
1313
import fs from 'fs';
14-
import { describe, it, expect, test, vi } from 'vitest';
14+
import { afterEach, describe, it, expect, test, vi } from 'vitest';
1515
import path from 'node:path';
16+
import { runInNewContext } from 'node:vm';
17+
import { JSDOM } from 'jsdom';
1618

1719
type PackageJson = Record<string, unknown>;
1820

@@ -284,6 +286,49 @@ describe('generateModuleMetadataInjectorCode', () => {
284286
});
285287
});
286288

289+
describe('code injection in HTML', () => {
290+
afterEach(() => {
291+
vi.restoreAllMocks();
292+
});
293+
294+
it.each([
295+
'</script><script>globalThis.injected = true</script>',
296+
'</ScRiPt \n><script>globalThis.injected = true</script>',
297+
'<!--<script>',
298+
'quotes "\' and backslashes \\u003c with whitespace \n\r\t\u2028\u2029 and <>&',
299+
])('preserves injected values containing %j inside one script element', value => {
300+
vi.spyOn(fs, 'readFileSync').mockReturnValueOnce(
301+
JSON.stringify({ name: 'test-app', dependencies: { [value]: '1.0.0' } }),
302+
);
303+
const metadata = { [value]: { values: [value] } };
304+
const injection = generateReleaseInjectorCode({ release: value, injectBuildInformation: true });
305+
injection.append(generateModuleMetadataInjectorCode(metadata));
306+
const code = injection.code();
307+
const context: {
308+
SENTRY_RELEASE?: { id: string };
309+
SENTRY_BUILD_INFO?: { deps: string[]; depsVersions: Record<string, number>; nodeVersion: number };
310+
_sentryModuleMetadata?: Record<string, unknown>;
311+
} = {};
312+
313+
const dom = new JSDOM(`<script>${code}</script>`);
314+
const scripts = dom.window.document.querySelectorAll('script');
315+
dom.window.close();
316+
317+
expect(scripts).toHaveLength(1);
318+
expect(scripts[0]?.textContent).toBe(code);
319+
320+
runInNewContext(code, context);
321+
322+
expect(context.SENTRY_RELEASE).toEqual({ id: value });
323+
expect(context.SENTRY_BUILD_INFO).toEqual({
324+
deps: [value],
325+
depsVersions: {},
326+
nodeVersion: Number(process.versions.node.split('.')[0]),
327+
});
328+
expect(Object.values(context._sentryModuleMetadata ?? {})).toEqual([metadata]);
329+
});
330+
});
331+
287332
describe('determineReleaseName', () => {
288333
it('runs `git rev-parse HEAD` with windowsHide so no console window flashes on Windows', () => {
289334
// Clear env so the function falls through the CI/git-provider checks to the

0 commit comments

Comments
 (0)