Skip to content

[APPS-2962] Add: report the app Content Security Policy from the apps dev server - #611

Open
tyffical wants to merge 1 commit into
masterfrom
tiffany.trinh/apps-2962-local-app-csp
Open

tyffical wants to merge 1 commit into
masterfrom
tiffany.trinh/apps-2962-local-app-csp

Conversation

@tyffical

@tyffical tyffical commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Motivation

Architecture

flowchart TD
    browser["Browser: GET page or worker script"] --> csp["createAppCspMiddleware, registered in configureServer"]
    csp -->|"server.proxy path: passed on unwrapped"| vite["Vite's own middlewares: index HTML, public dir, transforms"]
    csp -->|"other paths: drop If-None-Match, wrap writeHead, write, end and flushHeaders"| vite
    vite -->|"text/html, or a Sec-Fetch-Dest worker"| policy["Content-Security-Policy-Report-Only beside any policy already set: APP_CSP_DIRECTIVES, plus the React Refresh preamble hash and the HMR WebSocket origins"]
    vite -->|"anything else"| unchanged["Sent unchanged"]
    html["transformIndexHtml, dev only"] --> logger["Logger script: logs each securitypolicyviolation once"]
Loading
  • APP_CSP_DIRECTIVES copies web-ui's getAppCsp at 73db81f; check-app-csp-drift compares them locally, since CI can't read web-ui.

Changes

11 changes across the dev server CSP files, index.ts, the drift script, README.md and tests
What changed File
Adds the CSP middleware. dev-server-csp.ts
Adds the dev-only violation logger. dev-server-csp-violations.ts
Holds the copied production directives. app-csp.ts
Registers both, off under Vitest. index.ts
Skips both for appType: 'custom' hosts such as Storybook. index.ts
Exports getRequestHost. dev-server-request-guard.ts
Adds the check-app-csp-drift script. check-app-csp-drift.js, package.json
Documents the policy and its known gaps. README.md
Tests the middleware, logger and drift script. dev-server-csp.test.ts, dev-server-csp.integration.test.ts, dev-server-csp-violations.test.ts, check-app-csp-drift.test.ts
Adds test fixtures, including a raw-socket HTTP client. dev-server-csp.fixtures.ts, raw-http.fixtures.ts
Updates the guard and configureServer tests. dev-server-request-guard.test.ts, index.test.ts

QA Instructions

yarn install
yarn build:all
# Expected: exit 0 ✅ VERIFIED
yarn typecheck:all
# Expected: exit 0 ✅ VERIFIED
yarn lint
# Expected: exit 0 ✅ VERIFIED
yarn cli integrity
# Expected: exit 0, no changes to commit ✅ VERIFIED
# macOS only: resolve the /var -> /private/var symlink, or two Vite build tests fail for an unrelated reason.
export TMPDIR=$(cd $TMPDIR && pwd -P)/
yarn test:unit packages/plugins/apps
# Expected: Test Suites: 43 passed, 43 total. Tests: 987 passed, 987 total ✅ VERIFIED
yarn test:unit
# Expected: Test Suites: 135 passed, 135 total. Tests: 1 skipped, 3582 passed, 3583 total ✅ VERIFIED
yarn workspace @dd/apps-plugin check-app-csp-drift <web-ui checkout>
# Expected: The app CSP copy matches packages/apps/app-builder/lib/sandbox-shim/csp.ts at 73db81f98e868727008237b5365fb54ffd1a49df. ✅ VERIFIED
yarn workspace @dd/apps-plugin check-app-csp-drift <web-ui checkout> 1ba1c67d708cb9e8cae5681e0b48f4ac1b17a0dd
# Expected: exit 1, "production: connect-src 'self'" / "copy: connect-src 'self' https:" ✅ VERIFIED
  • Manual QA (re-run 2026-10-23 on 298fe58b): a template app on Vite 8, datadog-apps dev, a Datadog staging org, Chrome:
# Output (main run): every probe ran; nothing was blocked
header: Content-Security-Policy-Report-Only, with the React Refresh preamble hash; no enforcing header ✅ VERIFIED
console: [Datadog Apps] The app Content Security Policy would block inline (script-src-elem). Production applies this policy to uploaded apps under the sandbox shim.
console: … would block eval (script-src) / http://example.com/ (connect-src) / https://www.datadoghq.com/favicon.ico (img-src) / blob (worker-src) / https://example.com (frame-src) ✅ VERIFIED
greeting: "Hello, Qa App! This greeting ran as a backend function." ✅ VERIFIED
HMR edit "Count v1" -> "Count v2": label "Count v2: 1", no reload ✅ VERIFIED
# @vitejs/plugin-react-swc with base '/qa-base/': the same six warnings and HMR ✅ VERIFIED
# Vite 8 bundled dev mode: report-only header and HMR, but no "[Datadog Apps]" warnings (README known gap) ✅ VERIFIED
# appType 'custom' (middleware-mode server): no CSP header, no violations ✅ VERIFIED
# Control (master plugin): no CSP header, every probe ran, no violations ✅ VERIFIED
  • A violation inside a URL worker isn't visible to the page, so it gets no warning; the worker script still gets the report-only header.
Manual QA reproduction
  • In a template app's vite.config.ts, import datadogVitePlugin from <build-plugins checkout>/packages/published/vite-plugin/dist/src/index.mjs.

  • index.html, in <head>:

<script>window.__qaInlineRan = true;</script>
  • src/main.tsx, after the imports:
import { runCspProbes } from './cspProbes';

void runCspProbes();
  • src/App.tsx: add const [count, setCount] = useState(0); and render:
<span id="greeting">{greetingText}</span>
<button id="counter" onClick={() => setCount(count + 1)}>
    <span id="hmr-label">Count v1</span>: {count}
</button>
  • src/cspProbes.ts:
type ProbeResult = { probe: string; outcome: string };

const violations: string[] = [];
document.addEventListener('securitypolicyviolation', (event) => {
    violations.push(`${event.effectiveDirective} ${event.blockedURI}`);
});

const withTimeout = <T>(promise: Promise<T>, fallback: T, ms = 3000) =>
    Promise.race([promise, new Promise<T>((resolve) => setTimeout(() => resolve(fallback), ms))]);

const probeImage = () =>
    withTimeout(
        new Promise<string>((resolve) => {
            const image = new Image();
            image.onload = () => resolve('ran');
            image.onerror = () => resolve('blocked');
            image.src = 'https://www.datadoghq.com/favicon.ico';
        }),
        'timeout',
    );

const probeBlobWorker = () =>
    withTimeout(
        new Promise<string>((resolve) => {
            try {
                const url = URL.createObjectURL(
                    new Blob(['postMessage("hi")'], { type: 'text/javascript' }),
                );
                const worker = new Worker(url);
                worker.onmessage = () => resolve('ran');
                worker.onerror = () => resolve('blocked');
            } catch (error) {
                resolve(`blocked: ${(error as Error).name}`);
            }
        }),
        'blocked (no message)',
    );

const probeIframe = () =>
    withTimeout(
        new Promise<string>((resolve) => {
            const before = violations.length;
            const frame = document.createElement('iframe');
            frame.src = 'https://example.com/';
            frame.onload = () => {
                const blocked = violations.slice(before).some((v) => v.startsWith('frame-src'));
                resolve(blocked ? 'blocked' : 'loaded');
            };
            document.body.appendChild(frame);
        }),
        'timeout',
    );

const probeUrlWorkerFetch = () =>
    withTimeout(
        new Promise<string>((resolve) => {
            const worker = new Worker(new URL('./probeWorker.ts', import.meta.url), {
                type: 'module',
            });
            worker.onmessage = (event) => resolve(`worker http: fetch ${event.data}`);
            worker.onerror = () => resolve('worker failed to start');
        }),
        'timeout',
    );

export async function runCspProbes() {
    const results: ProbeResult[] = [];
    results.push({
        probe: 'inline classic script',
        outcome: (window as { __qaInlineRan?: boolean }).__qaInlineRan ? 'ran' : 'blocked',
    });
    try {
        // eslint-disable-next-line no-eval
        eval('1 + 1');
        results.push({ probe: 'eval', outcome: 'ran' });
    } catch (error) {
        results.push({ probe: 'eval', outcome: `blocked: ${(error as Error).name}` });
    }
    try {
        await fetch('http://example.com/', { mode: 'no-cors' });
        results.push({ probe: 'http: fetch', outcome: 'ran' });
    } catch (error) {
        results.push({ probe: 'http: fetch', outcome: `blocked: ${(error as Error).name}` });
    }
    results.push({ probe: 'https: image', outcome: await probeImage() });
    results.push({ probe: 'blob: worker', outcome: await probeBlobWorker() });
    results.push({ probe: 'iframe', outcome: await probeIframe() });
    results.push({ probe: 'URL worker', outcome: await probeUrlWorkerFetch() });

    const output = document.createElement('pre');
    output.id = 'csp-probes';
    output.textContent = JSON.stringify({ results, violations }, null, 2);
    document.body.appendChild(output);
    console.log('csp-probes', JSON.stringify({ results, violations }));
}
  • src/probeWorker.ts:
fetch('http://example.com/', { mode: 'no-cors' })
    .then(() => postMessage('ran'))
    .catch((error: Error) => postMessage(`blocked: ${error.name}`));
  • vite.bundled.config.ts (bundled dev mode run):
import { defineConfig, mergeConfig } from 'vite';

import baseConfig from './vite.config';

export default mergeConfig(baseConfig, defineConfig({ experimental: { bundledDev: true } }));
  • vite.swc.config.ts: vite.config.ts with @vitejs/plugin-react-swc and base: '/qa-base/'.

  • Run:

npx datadog-apps dev                                                    # http://localhost:5173/
npx vite --config vite.bundled.config.ts --port 5174 --strictPort      # bundled dev mode
npx vite --config vite.swc.config.ts --port 5175 --strictPort          # swc, /qa-base/
  • Read #csp-probes and the console on each page; for HMR, click the counter and edit Count v1.

Blast Radius

  • No feature flag and no opt-out: every app dev server, local and dev-verify; Vitest and appType: 'custom' hosts are exempt.
  • Low risk: report-only, so nothing is blocked; code the shim would block is logged. Production builds and uploads are unchanged.

Out of Scope / Follow-ups

7 items deferred
Item Status Next step
Live Preview drops the CSP header. Deferred Per-preview host plan.
vite preview sends no policy. Deferred Add it to configurePreviewServer if needed.
The raw-socket client works around the nock leak that [APPS-2946 Fix: reset the http patches any copy of nock leaves between test files](#613) fixes. Blocked Remove it once #613 merges.
Proxy keys with a bypass, and list-form writeHead, aren't covered. Accepted Restore if a real Vite path needs them.
Edge cases:
  • logger blames other policies
  • server.origin not allowed
  • HMR fallback port
  • HMR fallback host
  • non-UTF-8 bodies
Deferred Tiffany picks fixes.
Enforce the policy once the sandbox shim covers production. Deferred New PR.

Documentation

🤖 Generated with Claude Code

@tyffical
tyffical force-pushed the tiffany.trinh/apps-2962-local-app-csp branch from 4a9d33a to 514a601 Compare October 23, 2026 07:29
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 <noreply@anthropic.com>
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2962-local-app-csp branch from 514a601 to 298fe58 Compare October 23, 2026 13:37
@tyffical tyffical changed the title [APPS-2962] Add: enforce the app Content Security Policy in the apps dev server [APPS-2962] Add: report the app Content Security Policy from the apps dev server Oct 23, 2026
@datadog-datadog-prod-us1-2

datadog-datadog-prod-us1-2 Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Pipelines  Tests

🎉 All green!

🧪 All tests passed
❄️ No new flaky tests detected

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 298fe58 | Docs | View more details | Give us feedback!

@tyffical
tyffical requested a balanced review from Copilot October 23, 2026 15:24
@tyffical

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-23T15:28:12.327442Z 298fe58 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@tyffical
tyffical requested a review from oliverli October 23, 2026 15:26
@tyffical
tyffical marked this pull request as ready for review October 23, 2026 15:26
@tyffical
tyffical requested a review from a team as a code owner October 23, 2026 15:26
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🚀

Reviewed commit: 298fe58bb2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Response-method interception across Vite versions and browser-specific CSP behavior warrant final human validation despite the reported passing tests and manual checks.

No open issues 🎉

What changed in this PR

Adds report-only Content Security Policy checks to the Datadog Apps Vite dev server, helping developers discover sandbox-policy violations before uploading apps without blocking local execution.

Changes:

  • Adds CSP headers to HTML and worker responses, with allowances for React Refresh and HMR.
  • Injects a deduplicated browser-console logger, excluding Vitest and custom HTML hosts.
  • Adds policy-drift tooling, unit and integration coverage, and documentation of known limitations.
File Description
packages/​plugins/​apps/​src/​vite/​raw-http.fixtures.ts Adds a socket-based HTTP test client to avoid nock interference.
packages/​plugins/​apps/​src/​vite/​index.ts Registers CSP middleware and the dev-only logger.
packages/​plugins/​apps/​src/​vite/​index.test.ts Tests middleware registration and exemptions.
packages/​plugins/​apps/​src/​vite/​dev-server-request-guard.ts Exports the shared request-host helper.
packages/​plugins/​apps/​src/​vite/​dev-server-request-guard.test.ts Tests Host and HTTP/2 authority handling.
packages/​plugins/​apps/​src/​vite/​dev-server-csp.ts Implements report-only headers and development allowances.
packages/​plugins/​apps/​src/​vite/​dev-server-csp.test.ts Tests policy generation and HTTP response handling.
packages/​plugins/​apps/​src/​vite/​dev-server-csp.integration.test.ts Tests CSP and logger integration with Vite.
packages/​plugins/​apps/​src/​vite/​dev-server-csp.fixtures.ts Provides preamble and policy test helpers.
packages/​plugins/​apps/​src/​vite/​dev-server-csp-violations.ts Adds the browser violation logger and injection tag.
packages/​plugins/​apps/​src/​vite/​dev-server-csp-violations.test.ts Tests warning messages, deduplication, and script tags.
packages/​plugins/​apps/​src/​vite/​check-app-csp-drift.test.ts Tests drift detection and script error handling.
packages/​plugins/​apps/​src/​vite/​app-csp.ts Stores and formats the copied sandbox policy.
packages/​plugins/​apps/​scripts/​check-app-csp-drift.js Compares the policy copy against a web-ui checkout.
packages/​plugins/​apps/​README.md Documents report-only behavior and coverage gaps.
packages/​plugins/​apps/​package.json Exposes the policy-drift command.

Review effort: Balanced

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants