From 6c871821b01aed603efec3dff18dc2db69e5000e Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Wed, 21 Oct 2026 10:53:43 -0400 Subject: [PATCH] feat(apps): report the app CSP from the dev server HTML pages and worker scripts from the apps dev server now carry, as Content-Security-Policy-Report-Only, the policy the sandbox shim applies to uploaded apps, minus frame-ancestors. Inline scripts, eval, http: requests, https: images, blob: workers, frames and form posts that production would block are reported locally before upload, without being blocked. A policy already on the response is kept. The policy is off under Vitest and for appType 'custom' hosts such as Storybook, which serve their own HTML, and server.proxy paths, matched as Vite's proxy matches them, are left alone. script-src also allows the inline React Refresh preamble by the hash of the served text, and connect-src allows the HMR WebSocket. A dev-only script logs each violation with the rule involved. A check-app-csp-drift script compares the copied directives with their source in a web-ui checkout. Refs: APPS-2962 Co-Authored-By: Claude Opus 5.5 --- packages/plugins/apps/README.md | 24 + packages/plugins/apps/package.json | 1 + .../apps/scripts/check-app-csp-drift.js | 114 ++++ packages/plugins/apps/src/vite/app-csp.ts | 33 ++ .../apps/src/vite/check-app-csp-drift.test.ts | 194 +++++++ .../vite/dev-server-csp-violations.test.ts | 95 +++ .../src/vite/dev-server-csp-violations.ts | 44 ++ .../apps/src/vite/dev-server-csp.fixtures.ts | 65 +++ .../vite/dev-server-csp.integration.test.ts | 284 +++++++++ .../apps/src/vite/dev-server-csp.test.ts | 539 ++++++++++++++++++ .../plugins/apps/src/vite/dev-server-csp.ts | 276 +++++++++ .../src/vite/dev-server-request-guard.test.ts | 26 + .../apps/src/vite/dev-server-request-guard.ts | 10 +- packages/plugins/apps/src/vite/index.test.ts | 65 ++- packages/plugins/apps/src/vite/index.ts | 36 +- 15 files changed, 1797 insertions(+), 9 deletions(-) create mode 100644 packages/plugins/apps/scripts/check-app-csp-drift.js create mode 100644 packages/plugins/apps/src/vite/app-csp.ts create mode 100644 packages/plugins/apps/src/vite/check-app-csp-drift.test.ts create mode 100644 packages/plugins/apps/src/vite/dev-server-csp-violations.test.ts create mode 100644 packages/plugins/apps/src/vite/dev-server-csp-violations.ts create mode 100644 packages/plugins/apps/src/vite/dev-server-csp.fixtures.ts create mode 100644 packages/plugins/apps/src/vite/dev-server-csp.integration.test.ts create mode 100644 packages/plugins/apps/src/vite/dev-server-csp.test.ts create mode 100644 packages/plugins/apps/src/vite/dev-server-csp.ts diff --git a/packages/plugins/apps/README.md b/packages/plugins/apps/README.md index f7e98d130..9e1712a93 100644 --- a/packages/plugins/apps/README.md +++ b/packages/plugins/apps/README.md @@ -18,6 +18,7 @@ It needs Vite 6.4 or later: on an older Vite, it stops every Vite command with a - [Cloud execution](#cloud-execution) - [Local execution](#local-execution) - [Backend function runtime](#backend-function-runtime) +- [Development server Content Security Policy](#development-server-content-security-policy) - [Package output](#package-output) - [apps.enable](#appsenable) - [apps.include](#appsinclude) @@ -117,6 +118,29 @@ Backend functions run on the Node backend runtime, and the plugin's build-time a The checks also warn, without failing the build, about `process.env` reads: in production, the Node backend runtime doesn't pass your own environment variables or Custom Credentials to backend functions, so those reads don't see your values there. They catch direct reads: `process.env.X`, `process.env[key]`, a destructure of `process.env`, an `env`, default or namespace import from `process` or `node:process`, and a `const` bound to `process.env` or to an `env` destructured from `process`. Like the other checks, they cover only the app's own code and opted-in packages, so an SDK that reads `process.env` (for example, for a default API key) doesn't warn. +## Development server Content Security Policy + +The dev server sends its HTML pages and worker scripts with the policy the sandbox shim applies to uploaded apps, as `Content-Security-Policy-Report-Only`: until the shim covers production, it reports what the policy would block and doesn't block it. A policy already on the response, for example from `server.headers`, is kept. The policy would block, among others: + +- classic inline ``, + ``, + '', + ].join('\n'); + const files: Array<[string, string]> = [ + ['index.html', html], + ['main.js', 'export {};\n'], + ['public/static.html', '

static

\n'], + ]; + for (const [file, content] of files) { + const filePath = path.join(root, file); + outputFileSync(filePath, content); + } +}; + +const createAppsPlugin = (root: string): Plugin => ({ + name: 'dd-apps-test', + enforce: 'post', + ...getVitePlugin({ + bundler: { build, defaultServerConditions }, + context: getContextMock({ buildRoot: root }), + options: { + include: [], + tags: [], + longPolling: { + maxRetries: 1, + timeoutMs: 1000, + jitter: false, + exponentialBackoff: false, + }, + }, + }), +}); + +const startDevServer = async (root: string) => { + const appsPlugin = createAppsPlugin(root); + const server = await createServer({ + configFile: false, + root, + base: BASE, + logLevel: 'silent', + server: { host: '127.0.0.1', port: 0 }, + plugins: [reactPreamblePlugin(), appsPlugin], + optimizeDeps: { noDiscovery: true }, + }); + await server.listen(); + const address = server.httpServer?.address(); + if (!address || typeof address === 'string') { + throw new Error('Expected the dev server to listen on a TCP port'); + } + return { server, port: address.port }; +}; + +const getFrom = (port: number, urlPath: string) => + httpGet(port, urlPath, { host: `localhost:${port}` }); + +beforeAll(() => { + nock.enableNetConnect('127.0.0.1'); +}); + +afterAll(() => { + nock.disableNetConnect(); +}); + +describe('Dev server app CSP — real Vite dev server', () => { + let root: string; + let server: ViteDevServer; + let port: number; + let restoreEnv: () => void; + + const get = (urlPath: string) => getFrom(port, urlPath); + + beforeAll(async () => { + // Without credentials, configureServer skips the backend function sandbox setup. + restoreEnv = clearDatadogEnv(); + root = getTempWorkingDir(`apps-csp-${Date.now()}`); + writeApp(root); + ({ server, port } = await startDevServer(root)); + }, 30000); + + afterAll(async () => { + await server?.close(); + rmSync(root); + restoreEnv(); + }); + + test('Should allow the injected preamble by the hash of its served text, and nothing else inline', async () => { + const { headers, body } = await get(BASE); + const policy = String(headers[APP_CSP_HEADER.toLowerCase()]); + const preamble = getPreamble(BASE); + + expect(body).toContain(preamble); + expect(body).toContain(CLASSIC_INLINE_SCRIPT); + // Vite serves an authored inline module from a URL, which 'self' allows. + expect(body).not.toContain(AUTHORED_INLINE_MODULE); + const preambleHash = sha256Source(preamble); + expect(getDirective(policy, 'script-src')).toEqual([ + ...getAppCspDirective('script-src'), + preambleHash, + ]); + }); + + test('Should allow the HMR WebSocket and leave out frame-ancestors', async () => { + const { headers } = await get(BASE); + const policy = String(headers[APP_CSP_HEADER.toLowerCase()]); + + const appSources = getAppCspDirective('connect-src'); + const connectSources = getDirective(policy, 'connect-src'); + + expect(connectSources.slice(0, appSources.length)).toEqual(appSources); + expect(connectSources).toContain(`ws://localhost:${port}`); + expect(getDirective(policy, 'frame-ancestors')).toEqual([]); + }); + + test('Should apply the CSP to HTML served from the public directory', async () => { + const { headers } = await get(`${BASE}static.html`); + const policy = String(headers[APP_CSP_HEADER.toLowerCase()]); + + expect(getDirective(policy, 'script-src')).toEqual(getAppCspDirective('script-src')); + }); + + test('Should load the violation logger first, from a URL the policy allows', async () => { + const { body } = await get(BASE); + const loggerTag = new RegExp(``, + expected: [sha256Source(APP_PREAMBLE)], + }, + { + description: 'hash the legacy preamble', + html: ``, + expected: [sha256Source(LEGACY_PREAMBLE)], + }, + { + description: 'hash the text the browser sees after CRLF normalization', + html: ``, + expected: [ROOT_PREAMBLE_HASH], + }, + { + description: 'skip an inline classic script', + html: ``, + expected: [ROOT_PREAMBLE_HASH], + }, + { + description: 'skip the preamble text in a classic script', + html: ``, + expected: [], + }, + { + description: 'skip a module that adds code to the preamble', + html: ``, + expected: [], + }, + { + description: 'skip a script with a src, even if it has content', + html: ``, + expected: [], + }, + { + description: + 'skip an inline module that imports the refresh runtime without installing it', + html: '', + expected: [], + }, + { + description: 'return nothing for a page without scripts', + html: 'Hello', + expected: [], + }, + ]; + + test.each(cases)('Should $description', ({ html, expected }) => { + expect(getReactRefreshPreambleHashes(html)).toEqual(expected); + }); +}); + +describe('dev-server-csp — getHmrWebSocketSources', () => { + const cases: { + description: string; + config: CspServerConfig; + host: string | undefined; + encrypted?: boolean; + expected: string[]; + }[] = [ + { + description: "allow the page's own host and the direct fallback", + config: baseConfig, + host: `127.0.0.1:${SERVER_PORT}`, + expected: [`ws://127.0.0.1:${SERVER_PORT}`, `ws://localhost:${SERVER_PORT}`], + }, + { + description: 'use wss for an https page', + config: { server: { ...baseConfig.server, https: {} } }, + host: `localhost:${SERVER_PORT}`, + expected: [`wss://localhost:${SERVER_PORT}`], + }, + { + description: 'use wss for a page served over TLS', + config: baseConfig, + host: `localhost:${SERVER_PORT}`, + encrypted: true, + expected: [`wss://localhost:${SERVER_PORT}`], + }, + { + description: 'use the HMR host, port and protocol from the config', + config: { + server: { + ...baseConfig.server, + hmr: { host: 'hmr.localhost', port: 24680, protocol: 'wss' }, + }, + }, + host: `localhost:${SERVER_PORT}`, + expected: ['wss://hmr.localhost:24680'], + }, + { + description: 'prefer the HMR client port for the page connection', + config: { server: { ...baseConfig.server, hmr: { clientPort: 443, port: 24680 } } }, + host: `app.localhost:${SERVER_PORT}`, + expected: ['ws://app.localhost:443'], + }, + { + description: 'read server.ws before server.hmr', + config: { + server: { ...baseConfig.server, hmr: { port: 1111 }, ws: { port: 2222 } }, + }, + host: `localhost:${SERVER_PORT}`, + expected: ['ws://localhost:2222'], + }, + { + description: 'use the default HMR port in middleware mode', + config: { server: { ...baseConfig.server, middlewareMode: true } }, + host: 'localhost:3000', + expected: [`ws://localhost:${MIDDLEWARE_MODE_HMR_PORT}`], + }, + { + description: 'use the configured server host for the direct fallback', + config: { server: { ...baseConfig.server, host: '127.0.0.1' } }, + host: `localhost:${SERVER_PORT}`, + expected: [`ws://localhost:${SERVER_PORT}`, `ws://127.0.0.1:${SERVER_PORT}`], + }, + { + description: 'leave an IPv6 page host to self, since a CSP source cannot name it', + config: baseConfig, + host: `[::1]:${SERVER_PORT}`, + expected: [`ws://localhost:${SERVER_PORT}`], + }, + { + description: + 'leave a host name with an underscore to self, since a CSP source cannot name it', + config: baseConfig, + host: `web_app:${SERVER_PORT}`, + expected: [`ws://localhost:${SERVER_PORT}`], + }, + { + description: 'skip a configured server host that a CSP source cannot name', + config: { server: { ...baseConfig.server, host: '::1' } }, + host: `localhost:${SERVER_PORT}`, + expected: [`ws://localhost:${SERVER_PORT}`], + }, + { + description: 'skip a Host header that is not a plain host and port', + config: baseConfig, + host: `localhost:${SERVER_PORT}; script-src 'unsafe-inline'`, + expected: [`ws://localhost:${SERVER_PORT}`], + }, + { + description: 'skip a Host header that would add a policy', + config: baseConfig, + host: `localhost,evil.example:${SERVER_PORT}`, + expected: [`ws://localhost:${SERVER_PORT}`], + }, + { + description: 'skip a Host header with an out-of-range port', + config: baseConfig, + host: 'localhost:99999', + expected: [`ws://localhost:${SERVER_PORT}`], + }, + { + description: 'skip a missing Host header', + config: baseConfig, + host: undefined, + expected: [`ws://localhost:${SERVER_PORT}`], + }, + { + description: 'keep the socket when only HMR is off, as the client still connects', + config: { server: { ...baseConfig.server, hmr: false } }, + host: `localhost:${SERVER_PORT}`, + expected: [`ws://localhost:${SERVER_PORT}`], + }, + { + description: 'use the default port in middleware mode with HMR off', + config: { server: { ...baseConfig.server, hmr: false, middlewareMode: true } }, + host: 'localhost:3000', + expected: [`ws://localhost:${MIDDLEWARE_MODE_HMR_PORT}`], + }, + { + description: 'allow nothing when the WebSocket server is off', + config: { server: { ...baseConfig.server, ws: false } }, + host: `localhost:${SERVER_PORT}`, + expected: [], + }, + ]; + + test.each(cases)('Should $description', ({ config, host, encrypted, expected }) => { + expect(getHmrWebSocketSources(config, host, encrypted ?? false)).toEqual(expected); + }); +}); + +describe('dev-server-csp — createAppCspMiddleware', () => { + test('Should pass a response without the Node header API through untouched', () => { + const req = new http.IncomingMessage(new net.Socket()); + const res = new http.ServerResponse(req); + Reflect.set(res, 'getHeader', undefined); + const { end } = res; + const next = jest.fn(); + + createAppCspMiddleware(baseConfig)(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(res.end).toBe(end); + }); +}); + +describe('dev-server-csp — createAppCspMiddleware over real HTTP', () => { + type Responder = (req: http.IncomingMessage, res: http.ServerResponse) => void; + let server: http.Server; + let port: number; + let respond: Responder; + + const preamble = getPreamble('/'); + const html = ``; + + const get = (headers: Record = {}, urlPath = '/') => + httpGet(port, urlPath, headers); + + const getPolicies = (res: HttpGetResponse): string[] => { + const header = res.headers[APP_CSP_HEADER.toLowerCase()]; + if (header === undefined) { + return []; + } + return [header].flat(); + }; + + beforeAll(async () => { + const middleware = createAppCspMiddleware({ + server: { + ...baseConfig.server, + proxy: { + '/api': 'http://127.0.0.1:1', + '^/re/.*': {}, + '/unset': undefined, + }, + }, + }); + server = http.createServer((req, res) => { + middleware(req, res, () => respond(req, res)); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Expected the test server to listen on a TCP port'); + } + port = address.port; + nock.enableNetConnect(`127.0.0.1:${port}`); + }); + + afterAll(async () => { + nock.disableNetConnect(); + await new Promise((resolve) => server.close(resolve)); + }); + + test('Should allow only the preamble by hash on a buffered HTML response', async () => { + respond = (req, res) => { + res.setHeader('Content-Type', 'text/html'); + res.end(html); + }; + const served = await get(); + const [policy, ...others] = getPolicies(served); + + expect(others).toEqual([]); + expect(getDirective(policy, 'script-src')).toEqual([ + ...getAppCspDirective('script-src'), + sha256Source(preamble), + ]); + expect(policy).not.toContain(sha256Source(CLASSIC_INLINE_SCRIPT)); + expect(getDirective(policy, 'frame-ancestors')).toEqual([]); + }); + + test('Should allow the HMR WebSocket in connect-src', async () => { + respond = (req, res) => { + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.end(Buffer.from(html)); + }; + const served = await get({ host: `localhost:${port}` }); + const [policy] = getPolicies(served); + + expect(getDirective(policy, 'connect-src')).toEqual([ + ...getAppCspDirective('connect-src'), + ...getHmrWebSocketSources(baseConfig, `localhost:${port}`, false), + ]); + }); + + test('Should keep every other directive of the app CSP unchanged', async () => { + respond = (req, res) => { + res.setHeader('Content-Type', 'text/html'); + res.end(html); + }; + const served = await get(); + const [policy] = getPolicies(served); + + for (const [name, sources] of APP_CSP_DIRECTIVES) { + if (name === 'script-src' || name === 'connect-src') { + continue; + } + expect(getDirective(policy, name)).toEqual(sources); + } + }); + + test('Should apply the policy without hashes to a streamed HTML response', async () => { + respond = (req, res) => { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.write(html); + res.end(); + }; + const served = await get(); + const [policy] = getPolicies(served); + + expect(getDirective(policy, 'script-src')).toEqual(getAppCspDirective('script-src')); + }); + + test('Should not let an existing policy replace the app CSP', async () => { + respond = (req, res) => { + res.setHeader('Content-Type', 'text/html'); + res.setHeader(APP_CSP_HEADER, "script-src 'unsafe-inline' 'unsafe-eval'"); + res.end(html); + }; + const served = await get(); + const policies = getPolicies(served); + + expect(policies).toHaveLength(2); + expect(policies[0]).toBe("script-src 'unsafe-inline' 'unsafe-eval'"); + expect(getDirective(policies[1], 'script-src')).toContain(sha256Source(preamble)); + }); + + test('Should report the app CSP without enforcing it, leaving an enforced policy alone', async () => { + respond = (req, res) => { + res.setHeader('Content-Type', 'text/html'); + res.setHeader(ENFORCED_CSP_HEADER, "img-src 'self'"); + res.end(html); + }; + const served = await get(); + const [policy, ...others] = getPolicies(served); + + expect(served.headers[ENFORCED_CSP_HEADER.toLowerCase()]).toBe("img-src 'self'"); + expect(others).toEqual([]); + expect(getDirective(policy, 'script-src')).toContain(sha256Source(preamble)); + }); + + test('Should add the policy beside one passed to writeHead, before any body is sent', async () => { + respond = (req, res) => { + res.writeHead(200, 'OK', { + 'content-type': 'text/html', + [APP_CSP_HEADER]: "script-src 'unsafe-inline'", + }); + res.end(html); + }; + const served = await get(); + const policies = getPolicies(served); + + expect(policies).toHaveLength(2); + expect(policies[0]).toBe("script-src 'unsafe-inline'"); + expect(getDirective(policies[1], 'script-src')).toEqual(getAppCspDirective('script-src')); + }); + + test('Should apply the policy under a proxy key Vite skips for having no target', async () => { + respond = (req, res) => { + res.setHeader('Content-Type', 'text/html'); + res.end(html); + }; + const served = await get({}, '/unset/page'); + const [policy] = getPolicies(served); + + expect(getDirective(policy, 'script-src')).toContain(sha256Source(preamble)); + }); + + test('Should keep a policy writeHead passes on a response it leaves alone', async () => { + respond = (req, res) => { + res.setHeader(APP_CSP_HEADER, "default-src 'none'"); + res.writeHead(200, { + 'content-type': 'application/json', + [APP_CSP_HEADER]: "img-src 'self'", + }); + res.end('{}'); + }; + + const served = await get(); + expect(getPolicies(served)).toEqual(["img-src 'self'"]); + }); + + test('Should apply the policy to a worker script', async () => { + respond = (req, res) => { + res.setHeader('Content-Type', 'text/javascript'); + res.end('postMessage(1);'); + }; + const served = await get({ 'sec-fetch-dest': 'worker' }); + const [policy] = getPolicies(served); + + expect(getDirective(policy, 'connect-src')).toEqual( + expect.arrayContaining(getAppCspDirective('connect-src')), + ); + expect(getDirective(policy, 'worker-src')).toEqual(getAppCspDirective('worker-src')); + }); + + test('Should drop conditional headers from a page request, so the page is never a 304', async () => { + let seenConditionals: unknown[] = []; + respond = (req, res) => { + seenConditionals = [req.headers['if-none-match'], req.headers['if-modified-since']]; + res.setHeader('Content-Type', 'text/html'); + res.end(html); + }; + await get({ + accept: 'text/html', + 'if-none-match': 'W/"1"', + 'if-modified-since': 'Wed, 21 Oct 2026 07:28:00 GMT', + }); + + expect(seenConditionals).toEqual([undefined, undefined]); + }); + + test("Should leave Vite's own bundling-in-progress page alone, so it can reload itself", async () => { + respond = (req, res) => { + res.setHeader('Content-Type', 'text/html'); + res.end( + '', + ); + }; + + const served = await get(); + expect(getPolicies(served)).toEqual([]); + }); + + test('Should not hash a compressed body it cannot read', async () => { + respond = (req, res) => { + res.setHeader('Content-Type', 'text/html'); + res.setHeader('Content-Encoding', 'gzip'); + res.end(html); + }; + const served = await get(); + const [policy] = getPolicies(served); + + expect(getDirective(policy, 'script-src')).toEqual(getAppCspDirective('script-src')); + }); + + const untouchedCases: { description: string; responder: Responder; path?: string }[] = [ + { + description: 'leave a JavaScript response alone', + responder: (req, res) => { + res.setHeader('Content-Type', 'text/javascript'); + res.end('export {}'); + }, + }, + { + description: 'leave HTML under a proxied path alone', + responder: (req, res) => { + res.setHeader('Content-Type', 'text/html'); + res.end(html); + }, + path: '/api/login', + }, + { + description: 'leave HTML under a proxied pattern alone', + responder: (req, res) => { + res.setHeader('Content-Type', 'text/html'); + res.end(html); + }, + path: '/re/page', + }, + { + description: 'leave a 304 without a content type alone', + responder: (req, res) => { + res.statusCode = 304; + res.end(); + }, + }, + ]; + + test.each(untouchedCases)('Should $description', async ({ responder, path }) => { + respond = responder; + const response = await get({}, path); + expect(getPolicies(response)).toEqual([]); + }); +}); diff --git a/packages/plugins/apps/src/vite/dev-server-csp.ts b/packages/plugins/apps/src/vite/dev-server-csp.ts new file mode 100644 index 000000000..0d2510b11 --- /dev/null +++ b/packages/plugins/apps/src/vite/dev-server-csp.ts @@ -0,0 +1,276 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import { createHash } from 'crypto'; +import type { IncomingMessage, ServerResponse } from 'http'; +import type { Socket } from 'net'; + +import { formatAppCsp } from './app-csp'; +import { getRequestHost } from './dev-server-request-guard'; + +export const APP_CSP_HEADER = 'Content-Security-Policy-Report-Only'; +export const MIDDLEWARE_MODE_HMR_PORT = 24678; +// A host that serves its own HTML through Vite, such as Storybook, never runs under the sandbox shim. +export const SELF_SERVED_APP_TYPE = 'custom'; + +interface SocketOptions { + protocol?: string; + host?: string; + port?: number; + clientPort?: number; + server?: unknown; +} + +export interface CspServerConfig { + server: { + hmr?: boolean | SocketOptions; + // Vite 8 reads its HMR socket options from `server.ws`, and `server.hmr` aliases them. + ws?: false | SocketOptions; + middlewareMode?: unknown; + https?: unknown; + host?: string | boolean; + port?: number; + proxy?: Record; + }; +} + +type HeaderValue = number | string | string[]; + +const SCRIPT_RE = /]*)>([\s\S]*?)<\/script\s*>/gi; +const SRC_ATTRIBUTE_RE = /(?:^|\s)src\s*=/i; +const MODULE_TYPE_RE = /(?:^|\s)type\s*=\s*["']?module["']?(?:\s|$)/i; +const PREAMBLE_IMPORT_RE = + /import\s*(?:\{\s*injectIntoGlobalHook\s*\}|RefreshRuntime)\s*from\s*["'][^"']*\/@react-refresh["'];?/; +const PREAMBLE_INSTALL_RE = /(?:RefreshRuntime\.)?injectIntoGlobalHook\(\s*window\s*\);?/; +const PREAMBLE_STATEMENT_RES = [ + PREAMBLE_IMPORT_RE, + PREAMBLE_INSTALL_RE, + /window\.\$RefreshReg\$\s*=\s*\(\)\s*=>\s*\{\s*\};?/, + /window\.\$RefreshSig\$\s*=\s*\(\)\s*=>\s*\(type\)\s*=>\s*type;?/, + /window\.__vite_plugin_react_preamble_installed__\s*=\s*true;?/, +]; +const HOST_HEADER_RE = /^(?:[a-z0-9_-]+(?:\.[a-z0-9_-]+)*\.?|\[[0-9a-f:.]+\])(?::\d{1,5})?$/i; +// A CSP host-source has no IPv6 literal or underscore; 'self' still covers the page's own port. +const CSP_HOST_RE = /^[a-z0-9-]+(?:\.[a-z0-9-]+)*$/i; +const WILDCARD_HOSTS = new Set(['0.0.0.0', '::', '0000:0000:0000:0000:0000:0000:0000:0000']); +const PAGE_DESTINATIONS = new Set(['document', 'iframe', 'frame']); +// Vite 8's bundled dev mode serves this page, which reloads itself from an inline script, while +// it bundles; it's never part of the app. +const VITE_FALLBACK_PAGE_MARKER = 'globalThis.__vite_is_fallback_page__ = true'; +// A worker takes its CSP from its own script response, not from the page. +const WORKER_DESTINATIONS = new Set(['worker', 'sharedworker', 'serviceworker']); + +export const sha256Source = (content: string) => { + const digest = createHash('sha256').update(content, 'utf8').digest('base64'); + return `'sha256-${digest}'`; +}; + +const isReactRefreshPreamble = (attributes: string, content: string): boolean => { + if ( + SRC_ATTRIBUTE_RE.test(attributes) || + !MODULE_TYPE_RE.test(attributes) || + !PREAMBLE_IMPORT_RE.test(content) || + !PREAMBLE_INSTALL_RE.test(content) + ) { + return false; + } + const otherCode = PREAMBLE_STATEMENT_RES.reduce( + (remaining, statement) => remaining.replace(statement, ''), + content, + ); + return otherCode.trim() === ''; +}; + +/** The React Refresh preamble is the one inline script dev adds that production doesn't serve. */ +export const getReactRefreshPreambleHashes = (html: string): string[] => { + const hashes: string[] = []; + for (const [, attributes, content] of html.matchAll(SCRIPT_RE)) { + if (isReactRefreshPreamble(attributes, content)) { + // Browsers hash the text after HTML input normalization turns CRLF into LF. + const parsedContent = content.replace(/\r\n?/g, '\n'); + const hash = sha256Source(parsedContent); + hashes.push(hash); + } + } + return hashes; +}; + +const getSocketOptions = ({ server }: CspServerConfig): SocketOptions => { + if (typeof server.ws === 'object') { + return server.ws; + } + return typeof server.hmr === 'object' ? server.hmr : {}; +}; + +// The Host header lands in the policy, so only a plain host and port is used. +const parsePageHost = (protocol: string, hostHeader: string | undefined): URL | undefined => { + const pageHost = hostHeader?.trim(); + if (!pageHost || !HOST_HEADER_RE.test(pageHost)) { + return undefined; + } + try { + return new URL(`${protocol}://${pageHost}`); + } catch { + return undefined; + } +}; + +/** Mirrors how `@vite/client` picks its HMR WebSocket URL and its direct fallback. */ +export const getHmrWebSocketSources = ( + config: CspServerConfig, + hostHeader: string | undefined, + isEncrypted: boolean, +): string[] => { + const { server } = config; + // The client connects even with HMR off, for reloads and the error overlay. + if (server.ws === false) { + return []; + } + const socketOptions = getSocketOptions(config); + const isHttps = isEncrypted || Boolean(server.https); + const protocol = socketOptions.protocol || (isHttps ? 'wss' : 'ws'); + let clientPort = socketOptions.clientPort || socketOptions.port; + if (server.middlewareMode && !socketOptions.server) { + clientPort ||= MIDDLEWARE_MODE_HMR_PORT; + } + + const sources = new Set(); + const addSource = (hostname: string, port: number | string | undefined) => { + if (CSP_HOST_RE.test(hostname)) { + sources.add(`${protocol}://${hostname}${port ? `:${port}` : ''}`); + } + }; + const pageUrl = parsePageHost(protocol, hostHeader); + if (pageUrl) { + addSource(socketOptions.host || pageUrl.hostname, clientPort || pageUrl.port); + } + // Without a client port, the client retries against the server's own address. + if (!clientPort) { + const serverHost = + typeof server.host === 'string' && server.host && !WILDCARD_HOSTS.has(server.host) + ? server.host + : 'localhost'; + addSource(socketOptions.host || serverHost, server.port); + } + return [...sources]; +}; + +// Proxied responses aren't app files; keys match the way Vite's proxy matches them. +const isProxiedUrl = (proxy: CspServerConfig['server']['proxy'], url: string) => + Object.entries(proxy ?? {}).some( + ([context, options]) => + !!options && + ((context.startsWith('^') && new RegExp(context).test(url)) || url.startsWith(context)), + ); + +const isHtmlContentType = (contentType: unknown): boolean => + String(contentType ?? '') + .trim() + .toLowerCase() + .startsWith('text/html'); + +const isHeaderObject = (headers: unknown): headers is Record => + !!headers && typeof headers === 'object' && !Array.isArray(headers); + +const readBody = (chunk: unknown): string | undefined => { + if (typeof chunk === 'string') { + return chunk; + } + if (chunk instanceof Uint8Array) { + const buffer = Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength); + return buffer.toString('utf8'); + } + return undefined; +}; + +const isTlsSocket = (socket: Socket | undefined): boolean => + !!socket && 'encrypted' in socket && socket.encrypted === true; + +/** + * Reports the app CSP on HTML and worker script responses, without enforcing it, so code + * production would block shows up in dev before upload. A policy already on the response stays. + */ +export const createAppCspMiddleware = (config: CspServerConfig) => { + return (req: IncomingMessage, res: ServerResponse, next: () => void): void => { + const hasHeaderApi = + typeof res.getHeader === 'function' && typeof res.setHeader === 'function'; + if (!hasHeaderApi || isProxiedUrl(config.server.proxy, req.url ?? '')) { + next(); + return; + } + const destination = String(req.headers['sec-fetch-dest'] ?? ''); + const isWorkerScript = WORKER_DESTINATIONS.has(destination); + const isPageRequest = + PAGE_DESTINATIONS.has(destination) || + String(req.headers.accept ?? '').includes('text/html'); + if (isPageRequest || isWorkerScript) { + // A 304 keeps the policy the browser cached, which may predate this one. + delete req.headers['if-none-match']; + delete req.headers['if-modified-since']; + } + + let isApplied = false; + const isCovered = () => { + const contentType = res.getHeader('Content-Type'); + return isWorkerScript || isHtmlContentType(contentType); + }; + const applyPolicy = (body: string | undefined) => { + if (isApplied || res.headersSent || !isCovered()) { + return; + } + isApplied = true; + if (body?.includes(VITE_FALLBACK_PAGE_MARKER)) { + return; + } + const isHashable = + body !== undefined && !isWorkerScript && !res.getHeader('Content-Encoding'); + const scriptHashes = isHashable ? getReactRefreshPreambleHashes(body) : []; + const pageHost = getRequestHost(req.headers); + const isEncrypted = isTlsSocket(req.socket); + const webSocketSources = getHmrWebSocketSources(config, pageHost, isEncrypted); + const policy = formatAppCsp({ + 'script-src': scriptHashes, + 'connect-src': webSocketSources, + }); + const existingPolicies = [res.getHeader(APP_CSP_HEADER) ?? []].flat().map(String); + res.setHeader(APP_CSP_HEADER, [...existingPolicies, policy]); + }; + + const originalWriteHead = res.writeHead; + const originalWrite = res.write; + const originalEnd = res.end; + const originalFlushHeaders = res.flushHeaders; + + // sirv serves public files with writeHead(status, headers); setting those first lets the + // policy see their Content-Type and sit beside any policy among them. + Reflect.set(res, 'writeHead', (statusCode: number, ...rest: unknown[]) => { + const statusMessage = typeof rest[0] === 'string' ? [rest[0]] : []; + const headers = rest[statusMessage.length]; + const hasHeaderObject = isHeaderObject(headers); + if (hasHeaderObject) { + for (const [name, value] of Object.entries(headers)) { + res.setHeader(name, value); + } + } + applyPolicy(undefined); + const args = hasHeaderObject ? [statusCode, ...statusMessage] : [statusCode, ...rest]; + return Reflect.apply(originalWriteHead, res, args); + }); + Reflect.set(res, 'write', (...args: unknown[]) => { + applyPolicy(undefined); + return Reflect.apply(originalWrite, res, args); + }); + Reflect.set(res, 'end', (...args: unknown[]) => { + const body = readBody(args[0]); + applyPolicy(body); + return Reflect.apply(originalEnd, res, args); + }); + Reflect.set(res, 'flushHeaders', () => { + applyPolicy(undefined); + return Reflect.apply(originalFlushHeaders, res, []); + }); + + next(); + }; +}; diff --git a/packages/plugins/apps/src/vite/dev-server-request-guard.test.ts b/packages/plugins/apps/src/vite/dev-server-request-guard.test.ts index 9146985f3..286af385c 100644 --- a/packages/plugins/apps/src/vite/dev-server-request-guard.test.ts +++ b/packages/plugins/apps/src/vite/dev-server-request-guard.test.ts @@ -6,6 +6,7 @@ import type { HostCheckConfig } from '@dd/apps-plugin/vite/dev-server-request-gu import { checkDevServerRequest, createViteHostCheck, + getRequestHost, MISSING_HOST_MESSAGE, } from '@dd/apps-plugin/vite/dev-server-request-guard'; import type { IncomingHttpHeaders } from 'http'; @@ -392,3 +393,28 @@ describe('dev-server-request-guard — checkDevServerRequest', () => { expect(rejection?.statusCode).toBe(403); }); }); + +describe('dev-server-request-guard — getRequestHost', () => { + const cases: { description: string; headers: IncomingHttpHeaders; expected?: string }[] = [ + { + description: 'read Host', + headers: { host: 'localhost:5173' }, + expected: 'localhost:5173', + }, + { + description: 'read :authority from an HTTP/2 request', + headers: { ':authority': 'localhost:5173' }, + expected: 'localhost:5173', + }, + { + description: 'prefer Host over :authority', + headers: { host: 'a.localhost', ':authority': 'b.localhost' }, + expected: 'a.localhost', + }, + { description: 'return nothing without either', headers: {}, expected: undefined }, + ]; + + test.each(cases)('Should $description', ({ headers, expected }) => { + expect(getRequestHost(headers)).toBe(expected); + }); +}); diff --git a/packages/plugins/apps/src/vite/dev-server-request-guard.ts b/packages/plugins/apps/src/vite/dev-server-request-guard.ts index 2d8975fe7..59c043409 100644 --- a/packages/plugins/apps/src/vite/dev-server-request-guard.ts +++ b/packages/plugins/apps/src/vite/dev-server-request-guard.ts @@ -116,14 +116,18 @@ function isJsonContentType(contentType: string | undefined): boolean { return contentType?.split(';')[0].trim().toLowerCase() === 'application/json'; } +// HTTP/2 requests carry :authority and no Host. +export function getRequestHost(headers: IncomingHttpHeaders): string | undefined { + const authority = headers[':authority']; + return headers.host ?? (typeof authority === 'string' ? authority : undefined); +} + export function checkDevServerRequest( headers: IncomingHttpHeaders, isHostAllowed: HostCheck, ): RequestRejection | undefined { const { origin } = headers; - // HTTP/2 requests carry :authority and no Host. - const authority = headers[':authority']; - const host = headers.host ?? (typeof authority === 'string' ? authority : undefined); + const host = getRequestHost(headers); if (!host) { return { statusCode: 403, message: MISSING_HOST_MESSAGE }; } diff --git a/packages/plugins/apps/src/vite/index.test.ts b/packages/plugins/apps/src/vite/index.test.ts index 2e5878675..09bf0bec2 100644 --- a/packages/plugins/apps/src/vite/index.test.ts +++ b/packages/plugins/apps/src/vite/index.test.ts @@ -2,6 +2,7 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. +import { SELF_SERVED_APP_TYPE } from '@dd/apps-plugin/vite/dev-server-csp'; import type { HostCheckConfig } from '@dd/apps-plugin/vite/dev-server-request-guard'; import { APPS_DEV_CLIENT_PATH, @@ -110,7 +111,7 @@ type FakeViteDevServer = { middlewares: { use: (fn: DevServerMiddleware) => void }; ssrLoadModule: (id: string) => Promise; close?: () => Promise; - config: { mode: string } & HostCheckConfig; + config: { mode: string; appType?: string } & HostCheckConfig; environments?: { ssr: { hot: { handleInvoke: () => Promise }; config: { dev: object } }; }; @@ -1574,6 +1575,64 @@ describe('Backend Functions - getVitePlugin', () => { }, ); + const appTypeCases: { + description: string; + appType: string | undefined; + middlewareCount: number; + }[] = [ + { + description: + 'register only the backend function routes for a host that serves its own HTML', + appType: SELF_SERVED_APP_TYPE, + middlewareCount: 1, + }, + { + description: 'register the app CSP for the default app type', + appType: undefined, + middlewareCount: 2, + }, + ]; + + test.each(appTypeCases)('Should $description', ({ appType, middlewareCount }) => { + const plugin = getVitePlugin(defaultOptions); + const use = jest.fn(); + const configureServer = getConfigureServer(plugin); + const restoreEnv = clearDatadogEnv(); + + try { + configureServer({ + middlewares: { use }, + ssrLoadModule: jest.fn(), + config: { ...fakeServerConfig('development'), appType }, + }); + } finally { + restoreEnv(); + } + + expect(use).toHaveBeenCalledTimes(middlewareCount); + }); + + test('Should register only the backend function routes under Vitest', () => { + const plugin = getVitePlugin(defaultOptions); + const use = jest.fn(); + const configureServer = getConfigureServer(plugin); + const restoreEnv = clearDatadogEnv(); + process.env.VITEST = 'true'; + + try { + configureServer({ + middlewares: { use }, + ssrLoadModule: jest.fn(), + config: fakeServerConfig('development'), + }); + } finally { + delete process.env.VITEST; + restoreEnv(); + } + + expect(use).toHaveBeenCalledTimes(1); + }); + test("Should hand the middleware Vite's default server conditions", () => { const middlewareSpy = jest.spyOn(devServer, 'createDevServerMiddleware'); const defaultServerConditions = ['module', 'node']; @@ -1636,8 +1695,8 @@ describe('Backend Functions - getVitePlugin', () => { }); restoreEnv(); - expect(use).toHaveBeenCalledTimes(1); - const [registeredMiddleware] = use.mock.calls[0]; + expect(use).toHaveBeenCalledTimes(2); + const [registeredMiddleware] = use.mock.calls[1]; if (!isDevServerMiddleware(registeredMiddleware)) { throw new Error( 'Expected middlewares.use to have been called with a middleware function', diff --git a/packages/plugins/apps/src/vite/index.ts b/packages/plugins/apps/src/vite/index.ts index 6d91ee21b..1950e865d 100644 --- a/packages/plugins/apps/src/vite/index.ts +++ b/packages/plugins/apps/src/vite/index.ts @@ -52,6 +52,14 @@ import type { AppsOptionsWithDefaults } from '../types'; import { normalizeViteModuleId } from './backend-module-graph-collector'; import { buildBackendFunctions } from './build-backend-functions'; import { buildAppPackage } from './build-package'; +import { + CSP_VIOLATION_LOGGER_ID, + CSP_VIOLATION_LOGGER_ID_RE, + CSP_VIOLATION_LOGGER_SOURCE, + getCspViolationLoggerTags, + RESOLVED_CSP_VIOLATION_LOGGER_ID, +} from './dev-server-csp-violations'; +import { createAppCspMiddleware, SELF_SERVED_APP_TYPE } from './dev-server-csp'; import { collectModuleGraphFromServer } from './dev-server-module-graph'; import { createViteHostCheck } from './dev-server-request-guard'; import { createDevServerMiddleware } from './dev-server'; @@ -225,8 +233,8 @@ function allowPackageRoots(allow: string[], packages: BackendFunctionPackage[]): * Production (closeBundle): builds backend functions (if any) then writes the * deployable package without resolving authentication or performing requests. - * Dev (configureServer): registers middleware for local backend function - * testing when auth credentials are available. + * Dev (configureServer): reports the app CSP on HTML and worker responses, and registers + * middleware for local backend function testing when auth credentials are available. */ export const getVitePlugin = ({ bundler, @@ -255,6 +263,7 @@ export const getVitePlugin = ({ // Vite also invokes closeBundle when a dev server closes; configureServer marks the dev session // so its exit doesn't rebuild backend functions or replace the production package. let devServerActive = false; + let isAppCspActive = false; let hasNoticedSsrWarmupOverride = false; let serverPreTransformRequests: boolean | undefined; let isBuild = false; @@ -372,6 +381,9 @@ export const getVitePlugin = ({ ? DEV_CLIENT_RESOLVED_ID : null; } + if (source === CSP_VIOLATION_LOGGER_ID) { + return RESOLVED_CSP_VIOLATION_LOGGER_ID; + } // In a frontend build, each client-side import of a backend function file resolves // to a proxy module of its own (see load below). Under the backend file's id, the @@ -459,7 +471,15 @@ export const getVitePlugin = ({ load: { // Ahead of other plugins' load hooks, which may claim any id. order: 'pre', - filter: { id: { include: [DEV_CLIENT_RESOLVED_RE, FRONTEND_PROXY_ID_RE] } }, + filter: { + id: { + include: [ + DEV_CLIENT_RESOLVED_RE, + CSP_VIOLATION_LOGGER_ID_RE, + FRONTEND_PROXY_ID_RE, + ], + }, + }, async handler(id) { if (id === DEV_CLIENT_RESOLVED_ID) { try { @@ -471,6 +491,9 @@ export const getVitePlugin = ({ ); } } + if (id === RESOLVED_CSP_VIOLATION_LOGGER_ID) { + return CSP_VIOLATION_LOGGER_SOURCE; + } const sourceId = frontendProxySources.get( id.slice(FRONTEND_PROXY_ID_PREFIX.length, -FRONTEND_PROXY_ID_SUFFIX.length), ); @@ -664,6 +687,7 @@ export const getVitePlugin = ({ return []; } return [ + ...getCspViolationLoggerTags(isAppCspActive), { tag: 'script', attrs: { type: 'module', src: DEV_CLIENT_SRC }, @@ -674,6 +698,12 @@ export const getVitePlugin = ({ }, configureServer(server) { devServerActive = true; + // Vitest serves its own UI and browser-mode pages, with inline scripts, from this server. + isAppCspActive = !process.env.VITEST && server.config.appType !== SELF_SERVED_APP_TYPE; + if (isAppCspActive) { + const appCspMiddleware = createAppCspMiddleware(server.config); + server.middlewares.use(appCspMiddleware); + } if (server.environments?.ssr?.config.dev.preTransformRequests) { log.warn( `SSR import warmup (${SSR_WARMUP_SETTING}) is on, so edited backend code may not run until the dev server restarts.`,