Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions packages/plugins/apps/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 `<script>` elements, `eval` and scripts from another origin;
- requests to `http:`, `ws:` and `wss:` URLs, and `https:` images;
- `blob:` workers, frames and form submissions.

Pages Vite transforms log what the policy would block to the browser console, once per rule and URL, with the rule involved. Two things production doesn't need are allowed: the HMR WebSocket in `connect-src`, and, by its hash, the inline React Refresh preamble that `@vitejs/plugin-react` and `@vitejs/plugin-react-swc` inject. The hash is taken from the page when its whole uncompressed body goes to `res.end()`, as Vite sends it.

Known gaps:

- What Vite inlines only in a build, such as a `?worker&inline` worker, isn't covered.
- Paths under [`server.proxy`](https://vite.dev/config/server-options#server-proxy) are left alone, even when a `bypass` hands the request back to Vite.
- In middleware mode, HTML from the host server's own routes gets the policy too.
- On Vite 7 and 8, the client's reconnect check after a dev server restart starts a `blob:` worker, which the logger reports.
- HTML that another plugin's own dev-server middleware sends isn't covered, since this plugin's middleware runs after normal-order plugins' middlewares.
- Worker scripts are recognized by their `Sec-Fetch-Dest` request header, which Safari sends from version 16.4.
- Under Vitest, the policy is off.
- Tools that serve their own HTML through Vite (`appType: 'custom'`, for example Storybook) aren't covered, since they never run under the sandbox shim.
- The HMR WebSocket source also admits `http:` requests to the dev server's own host and port.
- Violations inside a worker, in pages Vite doesn't transform (such as files in `public/`) and in Vite 8's bundled dev mode go only to the browser's own console error.

## Package output

A production `vite build` writes `datadog-app-assets.zip` beside the Vite output. The ZIP contains `frontend/`, `backend/`, and `manifest.json`. The app's identity is resolved by `@datadog/apps-cli` at deploy time.
Expand Down
1 change: 1 addition & 0 deletions packages/plugins/apps/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"./*": "./src/*.ts"
},
"scripts": {
"check-app-csp-drift": "node ./scripts/check-app-csp-drift.js",
"typecheck": "tsc -b tsconfig.client.json --emitDeclarationOnly && tsc --noEmit"
},
"dependencies": {
Expand Down
114 changes: 114 additions & 0 deletions packages/plugins/apps/scripts/check-app-csp-drift.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// 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.

/* eslint-disable no-console */

// Compares the dev server's copy of the app CSP with its production source in a web-ui
// checkout, which CI can't read: yarn workspace @dd/apps-plugin check-app-csp-drift <checkout> [ref],
// with the checkout's path absolute or relative to this repository's root.

const { execFileSync } = require('child_process');
const { transformSync } = require('esbuild');
const fs = require('fs');
const path = require('path');
const { runInNewContext } = require('vm');

const APP_CSP_PATH = path.resolve(__dirname, '../src/vite/app-csp.ts');

// Runs a module with no imports; an import means the source changed shape.
const loadExports = (source, label) => {
const { code } = transformSync(source, { loader: 'ts', format: 'cjs' });
const sandboxModule = { exports: {} };
const require = () => {
throw new Error(`${label} imports another module, so it can't be checked on its own.`);
};
runInNewContext(code, { module: sandboxModule, exports: sandboxModule.exports, require });
return sandboxModule.exports;
};

const toDirectives = (policy) =>
policy
.split(';')
.map((directive) => directive.trim().split(/\s+/).join(' '))
.filter((directive) => directive && !directive.startsWith('frame-ancestors'));

const compare = (webUiRoot, ref) => {
const git = (...args) =>
execFileSync('git', ['-C', webUiRoot, ...args], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
}).trim();

// no-dd-sa:javascript-node-security/detect-non-literal-fs-filename
const appCspSource = fs.readFileSync(APP_CSP_PATH, 'utf8');
const { APP_CSP_SOURCE, formatAppCsp } = loadExports(appCspSource, APP_CSP_PATH);
const productionSource = git('show', `${ref}:${APP_CSP_SOURCE.file}`);
const { getAppCsp } = loadExports(productionSource, APP_CSP_SOURCE.file);
if (typeof getAppCsp !== 'function') {
throw new Error(`${APP_CSP_SOURCE.file} at ${ref} does not export getAppCsp.`);
}

const productionPolicy = getAppCsp("'none'");
const production = toDirectives(productionPolicy);
const copyPolicy = formatAppCsp({});
const copy = toDirectives(copyPolicy);
const latestCommit = git(
'log',
'-1',
'--no-show-signature',
'--format=%H',
ref,
'--',
APP_CSP_SOURCE.file,
);

if (JSON.stringify(production) !== JSON.stringify(copy)) {
console.log(`The app CSP copy differs from ${APP_CSP_SOURCE.file} at ${latestCommit}:`);
for (const directive of production.filter((line) => !copy.includes(line))) {
console.log(` production: ${directive}`);
}
for (const directive of copy.filter((line) => !production.includes(line))) {
console.log(` copy: ${directive}`);
}
if (production.every((line) => copy.includes(line)) && production.length === copy.length) {
const order = production.map((line) => line.split(' ')[0]).join(', ');
console.log(` directive order: ${order}`);
}
console.log(`Update APP_CSP_DIRECTIVES and APP_CSP_SOURCE.commit in ${APP_CSP_PATH}.`);
return 1;
}
if (latestCommit !== APP_CSP_SOURCE.commit) {
console.log(
`The app CSP copy matches, but set APP_CSP_SOURCE.commit to ${latestCommit}, the source's latest change.`,
);
return 0;
}
console.log(`The app CSP copy matches ${APP_CSP_SOURCE.file} at ${latestCommit}.`);
return 0;
};

const main = () => {
const [webUiPath, ref = 'origin/preprod'] = process.argv.slice(2);
if (!webUiPath) {
console.error('Usage: check-app-csp-drift <web-ui checkout> [ref, default origin/preprod]');
return 2;
}
if (ref.startsWith('-')) {
console.error(`A ref can't start with '-', which git reads as an option: ${ref}`);
return 2;
}
// yarn workspace runs this from the package, so a relative path is taken from the repo root.
const projectRoot = process.env.PROJECT_CWD || process.cwd();
const webUiRoot = path.resolve(projectRoot, webUiPath);
try {
return compare(webUiRoot, ref);
} catch (error) {
const gitError = error && error.stderr ? String(error.stderr).trim() : '';
const message = error instanceof Error ? error.message : String(error);
console.error(`Could not compare the app CSP at ${ref}: ${gitError || message}`);
return 1;
}
};

process.exitCode = main();
33 changes: 33 additions & 0 deletions packages/plugins/apps/src/vite/app-csp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// 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.

// Keep this file free of imports: scripts/check-app-csp-drift.js evaluates it on its own.

/** Where in web-ui the copy below comes from. */
export const APP_CSP_SOURCE = {
file: 'packages/apps/app-builder/lib/sandbox-shim/csp.ts',
commit: '228286c7c3d8dd295ea8936190fc6a54308fde25',
};

/** The CSP production serves app files with, minus `frame-ancestors`, which the dev server has no frame for. */
export const APP_CSP_DIRECTIVES: ReadonlyArray<readonly [string, readonly string[]]> = [
['default-src', ["'none'"]],
['script-src', ["'self'"]],
['style-src', ["'self'", "'unsafe-inline'"]],
['img-src', ["'self'", 'data:', 'blob:']],
['font-src', ["'self'", 'data:']],
['media-src', ["'self'", 'blob:']],
['connect-src', ["'self'", 'https:']],
['worker-src', ["'self'"]],
['manifest-src', ["'none'"]],
['frame-src', ["'none'"]],
['object-src', ["'none'"]],
['base-uri', ["'none'"]],
['form-action', ["'none'"]],
];

export const formatAppCsp = (extraSources: Readonly<Record<string, readonly string[]>>): string =>
APP_CSP_DIRECTIVES.map(([name, sources]) =>
[name, ...sources, ...(extraSources[name] ?? [])].join(' '),
).join('; ');
194 changes: 194 additions & 0 deletions packages/plugins/apps/src/vite/check-app-csp-drift.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
// 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 { APP_CSP_DIRECTIVES, APP_CSP_SOURCE } from '@dd/apps-plugin/vite/app-csp';
import { outputFileSync, rmSync } from '@dd/core/helpers/fs';
import { getTempWorkingDir } from '@dd/tests/_jest/helpers/env';
import { execFileSync, spawnSync } from 'child_process';
import path from 'path';

const DRIFT_SCRIPT = path.resolve(__dirname, '../../scripts/check-app-csp-drift.js');
const DRIFT_SCRIPT_DIR = path.dirname(DRIFT_SCRIPT);

type Directives = ReadonlyArray<readonly [string, readonly string[]]>;

// The shape of the production source: a function joining directives, frame-ancestors last.
const formatSource = (directives: Directives) =>
[
'export function getAppCsp(frameAncestors: string): string {',
' return [',
...directives.map(
([name, sources]) => ` ${JSON.stringify([name, ...sources].join(' '))},`,
),
" 'frame-ancestors ' + frameAncestors,",
" ].join('; ');",
'}',
'',
].join('\n');

describe('check-app-csp-drift script', () => {
let repository: string;

const git = (...args: string[]) =>
execFileSync(
'git',
[
'-C',
repository,
'-c',
'user.name=test',
'-c',
'user.email=test@example.com',
'-c',
'commit.gpgsign=false',
'-c',
'core.hooksPath=/dev/null',
...args,
],
{ encoding: 'utf8' },
).trim();

const commitRawSource = (source: string) => {
const sourcePath = path.join(repository, APP_CSP_SOURCE.file);
outputFileSync(sourcePath, source);
git('add', '-A');
git('commit', '-q', '-m', 'csp');
return git('rev-parse', 'HEAD');
};

const commitSource = (directives: Directives) => {
const source = formatSource(directives);
return commitRawSource(source);
};

const runScript = (...args: string[]) =>
spawnSync(process.execPath, [DRIFT_SCRIPT, ...args], { encoding: 'utf8' });

const runScriptFrom = (projectCwd: string, ...args: string[]) =>
spawnSync(process.execPath, [DRIFT_SCRIPT, ...args], {
cwd: DRIFT_SCRIPT_DIR,
encoding: 'utf8',
env: { PATH: process.env.PATH, HOME: process.env.HOME, PROJECT_CWD: projectCwd },
});

beforeEach(() => {
repository = getTempWorkingDir(`apps-csp-drift-${Date.now()}`);
git('init', '-q');
});

afterEach(() => {
rmSync(repository);
});

test('Should pass and name the commit to record when only the source commit moved', () => {
const commit = commitSource(APP_CSP_DIRECTIVES);
const result = runScript(repository, 'HEAD');

expect(result.status).toBe(0);
expect(result.stdout).toContain(commit);
});

test('Should name a bare commit when git log shows signatures', () => {
const keyDir = getTempWorkingDir(`apps-csp-drift-key-${Date.now()}`);
try {
const keyPath = path.join(keyDir, 'key');
execFileSync('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-f', keyPath]);
git('config', 'gpg.format', 'ssh');
git('config', 'user.signingkey', keyPath);
git('config', 'log.showSignature', 'true');
const sourcePath = path.join(repository, APP_CSP_SOURCE.file);
const source = formatSource(APP_CSP_DIRECTIVES);
outputFileSync(sourcePath, source);
git('add', '-A');
git('commit', '-q', '-S', '-m', 'csp');
const commit = git('rev-parse', 'HEAD');
const result = runScript(repository, 'HEAD');

expect(result.status).toBe(0);
expect(result.stdout).toContain(`set APP_CSP_SOURCE.commit to ${commit},`);
} finally {
rmSync(keyDir);
}
});

test('Should fail and name the directive that drifted', () => {
const drifted: Directives = APP_CSP_DIRECTIVES.map(([name, sources]) =>
name === 'img-src' ? [name, [...sources, 'https:']] : [name, sources],
);
commitSource(drifted);
const result = runScript(repository, 'HEAD');

expect(result.status).toBe(1);
expect(result.stdout).toContain('img-src');
expect(result.stdout).toContain('https:');
});

test('Should pass when only the source formatting differs', () => {
const source = formatSource(APP_CSP_DIRECTIVES)
.replace("].join('; ');", "].join('; ') + ';';")
.replace("'self' 'unsafe-inline'", "'self' 'unsafe-inline'");
commitRawSource(source);
const result = runScript(repository, 'HEAD');

expect(result.status).toBe(0);
});

const failureCases: { description: string; source: string; ref: string; message: string }[] = [
{
description: 'no longer exports getAppCsp',
source: 'export const other = 1;\n',
ref: 'HEAD',
message: 'getAppCsp',
},
{
description: 'imports another module',
source: "import { x } from './x';\nexport const getAppCsp = () => x;\n",
ref: 'HEAD',
message: 'imports',
},
{
description: 'is read at a ref the checkout lacks',
source: formatSource(APP_CSP_DIRECTIVES),
ref: 'origin/no-such-branch',
message: 'origin/no-such-branch',
},
];

test.each(failureCases)(
'Should fail with a clear message when the source $description',
({ source, ref, message }) => {
commitRawSource(source);
const result = runScript(repository, ref);

expect(result.status).toBe(1);
expect(result.stderr).toContain(message);
expect(result.stderr).not.toMatch(/^\s+at /m);
},
);

test('Should resolve a relative checkout path from the repository root', () => {
commitSource(APP_CSP_DIRECTIVES);
const parent = path.dirname(repository);
const relativeRepository = path.basename(repository);
const result = runScriptFrom(parent, relativeRepository, 'HEAD');

expect(result.status).toBe(0);
});

test('Should refuse a ref that git would read as an option', () => {
commitSource(APP_CSP_DIRECTIVES);
const result = runScript(repository, '--output=drift-output');

expect(result.status).toBe(2);
expect(result.stderr).toContain('--output=drift-output');
expect(git('status', '--porcelain')).toBe('');
});

test('Should print its usage without a checkout path', () => {
const result = runScript();

expect(result.status).toBe(2);
expect(result.stderr).toContain('Usage');
});
});
Loading
Loading