diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/sri-test/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16/app/sri-test/page.tsx new file mode 100644 index 000000000000..94e17a8bb4ef --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/sri-test/page.tsx @@ -0,0 +1,20 @@ +'use client'; + +import Link from 'next/link'; +import { useState } from 'react'; + +export default function SriTestPage() { + const [count, setCount] = useState(0); + + return ( +
+

SRI Test Page

+ + + Go to target + +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/sri-test/target/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16/app/sri-test/target/page.tsx new file mode 100644 index 000000000000..80ea89c506d5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/sri-test/target/page.tsx @@ -0,0 +1,20 @@ +'use client'; + +import Link from 'next/link'; +import { useState } from 'react'; + +export default function SriTestTargetPage() { + const [clicked, setClicked] = useState(false); + + return ( +
+

SRI Target Page

+ + + Go back + +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/next.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/next.config.ts index 41814b8152d0..ee93730e8d1d 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/next.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/next.config.ts @@ -4,7 +4,13 @@ import type { NextConfig } from 'next'; // Simulate Vercel environment for cron monitoring tests process.env.VERCEL = '1'; -const nextConfig: NextConfig = {}; +const nextConfig: NextConfig = { + experimental: { + sri: { + algorithm: 'sha256', + }, + }, +}; export default withSentryConfig(nextConfig, { silent: true, diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/sri.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/sri.test.ts new file mode 100644 index 000000000000..c68d23c21de6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/sri.test.ts @@ -0,0 +1,60 @@ +import { expect, test } from '@playwright/test'; + +const isDevMode = !!process.env.TEST_ENV && process.env.TEST_ENV.includes('development'); + +test.describe('Subresource Integrity (SRI)', () => { + test('page with client components loads correctly with SRI enabled', async ({ page }) => { + // SRI is only relevant for production builds + test.skip(isDevMode, 'SRI only applies to production builds'); + + const consoleErrors: string[] = []; + page.on('console', msg => { + if (msg.type() === 'error') { + consoleErrors.push(msg.text()); + } + }); + + await page.goto('/sri-test'); + + const heading = page.locator('#sri-test-heading'); + await expect(heading).toBeVisible(); + + // Verify client-side interactivity works (scripts loaded correctly) + const button = page.locator('#counter-button'); + await expect(button).toContainText('Count: 0'); + await button.click(); + await expect(button).toContainText('Count: 1'); + + expect(consoleErrors.filter(e => e.includes('integrity'))).toHaveLength(0); + }); + + test('client-side navigation works with SRI enabled', async ({ page }) => { + test.skip(isDevMode, 'SRI only applies to production builds'); + + const consoleErrors: string[] = []; + page.on('console', msg => { + if (msg.type() === 'error') { + consoleErrors.push(msg.text()); + } + }); + + await page.goto('/sri-test'); + await expect(page.locator('#sri-test-heading')).toBeVisible(); + + // Navigate to target page via client-side link + await page.locator('#navigate-link').click(); + await expect(page.locator('#sri-target-heading')).toBeVisible(); + + // Verify client-side interactivity on the target page + const targetButton = page.locator('#target-button'); + await expect(targetButton).toContainText('Click me'); + await targetButton.click(); + await expect(targetButton).toContainText('Clicked!'); + + // Navigate back + await page.locator('#back-link').click(); + await expect(page.locator('#sri-test-heading')).toBeVisible(); + + expect(consoleErrors.filter(e => e.includes('integrity'))).toHaveLength(0); + }); +}); diff --git a/packages/nextjs/src/config/handleRunAfterProductionCompile.ts b/packages/nextjs/src/config/handleRunAfterProductionCompile.ts index f6e5a3b21617..25e4d1a6a485 100644 --- a/packages/nextjs/src/config/handleRunAfterProductionCompile.ts +++ b/packages/nextjs/src/config/handleRunAfterProductionCompile.ts @@ -15,7 +15,14 @@ export async function handleRunAfterProductionCompile( distDir, buildTool, usesNativeDebugIds, - }: { releaseName?: string; distDir: string; buildTool: 'webpack' | 'turbopack'; usesNativeDebugIds?: boolean }, + sriEnabled, + }: { + releaseName?: string; + distDir: string; + buildTool: 'webpack' | 'turbopack'; + usesNativeDebugIds?: boolean; + sriEnabled?: boolean; + }, sentryBuildOptions: SentryBuildOptions, ): Promise { if (sentryBuildOptions.debug) { @@ -68,10 +75,19 @@ export async function handleRunAfterProductionCompile( // the deleted .map files, and in Next.js 16 (turbopack) those requests fall through // to the app router instead of returning 404, which can break middleware-dependent // features like Clerk auth. + // When SRI is enabled, we must skip this step because Next.js computes integrity + // hashes during the build — modifying files afterward invalidates those hashes. const deleteSourcemapsAfterUpload = sentryBuildOptions.sourcemaps?.deleteSourcemapsAfterUpload ?? false; - if (deleteSourcemapsAfterUpload && buildTool === 'turbopack') { + if (deleteSourcemapsAfterUpload && buildTool === 'turbopack' && !sriEnabled) { await stripSourceMappingURLComments(path.join(distDir, 'static'), sentryBuildOptions.debug); } + + if (deleteSourcemapsAfterUpload && buildTool === 'turbopack' && sriEnabled && sentryBuildOptions.debug) { + // eslint-disable-next-line no-console + console.debug( + '[@sentry/nextjs] Skipping sourceMappingURL comment stripping because Subresource Integrity (SRI) is enabled.', + ); + } } const SOURCEMAPPING_URL_COMMENT_REGEX = /\n?\/\/[#@] sourceMappingURL=[^\n]+$/; diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index 9aa31f79e535..86068841e773 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -46,6 +46,7 @@ export type NextConfigObject = { instrumentationHook?: boolean; clientTraceMetadata?: string[]; serverComponentsExternalPackages?: string[]; // next < v15.0.0 + sri?: { algorithm?: string }; }; productionBrowserSourceMaps?: boolean; // https://nextjs.org/docs/pages/api-reference/next-config-js/env diff --git a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectBundlerUtils.ts b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectBundlerUtils.ts index 76187ca319f9..edd62b8ba8c3 100644 --- a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectBundlerUtils.ts +++ b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectBundlerUtils.ts @@ -140,6 +140,7 @@ export function maybeSetUpRunAfterProductionCompileHook({ distDir, buildTool: bundlerInfo.isTurbopack ? 'turbopack' : 'webpack', usesNativeDebugIds: bundlerInfo.isTurbopack ? turboPackConfig?.debugIds : undefined, + sriEnabled: !!incomingUserNextConfigObject.experimental?.sri, }, userSentryOptions, ); @@ -160,6 +161,7 @@ export function maybeSetUpRunAfterProductionCompileHook({ distDir, buildTool: bundlerInfo.isTurbopack ? 'turbopack' : 'webpack', usesNativeDebugIds: bundlerInfo.isTurbopack ? turboPackConfig?.debugIds : undefined, + sriEnabled: !!incomingUserNextConfigObject.experimental?.sri, }, userSentryOptions, ); diff --git a/packages/nextjs/test/config/handleRunAfterProductionCompile.test.ts b/packages/nextjs/test/config/handleRunAfterProductionCompile.test.ts index 3d551dfb6c40..7c21c26d2ef6 100644 --- a/packages/nextjs/test/config/handleRunAfterProductionCompile.test.ts +++ b/packages/nextjs/test/config/handleRunAfterProductionCompile.test.ts @@ -388,6 +388,43 @@ describe('handleRunAfterProductionCompile', () => { expect(readdirSpy).not.toHaveBeenCalled(); }); + + it('does NOT strip sourceMappingURL comments when SRI is enabled', async () => { + await handleRunAfterProductionCompile( + { + releaseName: 'test-release', + distDir: '/path/to/.next', + buildTool: 'turbopack', + sriEnabled: true, + }, + { + ...mockSentryBuildOptions, + sourcemaps: { deleteSourcemapsAfterUpload: true }, + }, + ); + + expect(readdirSpy).not.toHaveBeenCalled(); + }); + + it('strips sourceMappingURL comments when SRI is not enabled', async () => { + await handleRunAfterProductionCompile( + { + releaseName: 'test-release', + distDir: '/path/to/.next', + buildTool: 'turbopack', + sriEnabled: false, + }, + { + ...mockSentryBuildOptions, + sourcemaps: { deleteSourcemapsAfterUpload: true }, + }, + ); + + expect(readdirSpy).toHaveBeenCalledWith( + path.join('/path/to/.next', 'static'), + expect.objectContaining({ recursive: true }), + ); + }); }); describe('path handling', () => {