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.`,