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
120 changes: 120 additions & 0 deletions .claude/skills/csp-check/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
---
name: csp-check
description: >
Check the frontend Content-Security-Policy in frontend/nginx.conf against what the app
actually loads. Use when adding or upgrading a third-party frontend dependency (CDN,
analytics, payments, widgets), when a feature loads assets from a new external host,
when a CSP violation shows up in the browser console, or before flipping the policy out
of report-only mode.
---

# CSP Check

The frontend ships a `Content-Security-Policy-Report-Only` header from
`frontend/nginx.conf`. Report-only means violations are written to each user's browser
console and nowhere else, so a policy gap is invisible until someone looks. This skill is
how you look.

Three checks, cheapest first. Run 1 on every change that touches a frontend dependency;
run 2 and 3 before widening the policy or flipping it to enforcing.

## 1. Static: does the bundle reference a host the policy never allows?

```bash
cd .claude/skills/csp-check/scripts
python3 extract_policy.py # what the policy says today
python3 scan_origins.py --url https://us-central.unstract.com # or --dist frontend/dist
```

`scan_origins.py` pulls every `/assets/*.js|css` chunk (following relative imports),
extracts external `https://` hosts, and exits non-zero on any host no directive allows.
Hosts that only appear in doc links, XML namespaces and library error strings are listed
in its `IGNORED` set — extend it rather than widening the policy for a host nothing fetches.

This catches "a new dependency pulls from a new CDN". It cannot tell you *which*
directive loads a host — a font from a script-src-only host still violates. That is check 2.

## 2. Live: probe the deployed policy, directive by directive

With the chrome-devtools MCP on a page of the target deployment:

1. `python3 extract_policy.py --json` and paste `directives` into `DIRECTIVES` in
`scripts/probe.js`.
2. Run the whole file as the `function` argument of `evaluate_script`.

It loads one throwaway resource per (directive, host) pair and returns:

- `unexpected` — hosts the policy is meant to allow but the deployment still reports.
Non-empty means the running deployment does not serve the policy in this repo, or the
host is allowed on the wrong directive.
- `controlsNotReported` — must be empty. Non-empty means CSP is not being applied at all.

CSP evaluates **redirect targets**: a probe path that 404-redirects to another host
(`https://hooks.stripe.com/` → `https://stripe.com`) reports the target, not a real gap.

## 3. Real usage: collect violations while driving the app

Probes only test hosts already in the policy. To find what a feature loads that nobody
listed, record violations while using it. Navigate with this `initScript` (chrome-devtools
`navigate_page`), which survives SPA route changes:

```js
document.addEventListener('securitypolicyviolation', (e) => {
const k = '__cspAll';
const prev = JSON.parse(sessionStorage.getItem(k) || '[]');
prev.push({dir: e.effectiveDirective || e.violatedDirective, blocked: e.blockedURI,
src: (e.sourceFile || '') + ':' + (e.lineNumber || ''), page: location.pathname});
sessionStorage.setItem(k, JSON.stringify(prev.slice(-400)));
});
```

Then walk the feature and read `JSON.parse(sessionStorage.getItem('__cspAll'))`. SPA routes
can be walked without reloading:
`history.pushState({}, '', path); dispatchEvent(new PopStateEvent('popstate'))`.

Third-party widgets load lazily and per-plan, so exercise the actual flow — an integration
that never initialises reports nothing.

## Changing the policy

Edit the single `add_header Content-Security-Policy-Report-Only` line in
`frontend/nginx.conf`. Keep it one line: nginx accepts multi-line quoted strings, but the
newlines end up in the header value.

Verify before pushing — nginx will start with a malformed policy and browsers will silently
drop the bad directive:

```bash
docker run -d --name csp-probe -p 8899:80 \
-v "$PWD/frontend/nginx.conf:/etc/nginx/nginx.conf:ro" nginx:alpine
curl -sI http://localhost:8899/ | grep -i content-security-policy
```

Then point check 2 at `http://localhost:8899/` to confirm the new policy allows what it
should and still blocks the controls. `docker rm -f csp-probe` when done.

Which directive a host belongs in:

| Loaded as | Directive |
|---|---|
| `<script src>`, dynamic `import()` | `script-src` |
| `<link rel=stylesheet>`, `@import` | `style-src` |
| `<img>`, CSS `url()` background, tracking pixel | `img-src` |
| `@font-face`, `FontFace()` | `font-src` |
| `fetch`/XHR/`sendBeacon`/WebSocket | `connect-src` |
| `<iframe>` | `frame-src` |
| `<video>`/`<audio>` | `media-src` |
| `new Worker()` | `worker-src` |

A host loaded several ways needs an entry in each directive — that is the most common
miss. Path-scoped sources (`https://www.gstatic.com/recaptcha/`) keep the grant narrow and
are worth using when a vendor serves everything from one host.

## Notes

- `connect-src` carries no `wss:` wildcard. socket.io connects to `window.location.origin`
(`frontend/src/helpers/GetStaticData.js` `getBaseUrl`) and `'self'` covers same-origin
ws/wss per CSP3. Verified with a `ws://` probe against a local nginx serving the policy.
- The backend sends its own enforcing `Content-Security-Policy` from
`backend/middleware/content_security_policy.py`. It applies to backend responses (JSON
APIs, the OSS login page), not to the SPA — do not confuse the two when debugging.
52 changes: 52 additions & 0 deletions .claude/skills/csp-check/scripts/extract_policy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#!/usr/bin/env python3
"""Parse the Content-Security-Policy out of frontend/nginx.conf.

Usage:
python3 extract_policy.py [path/to/nginx.conf] # pretty-print per directive
python3 extract_policy.py --json [path/to/nginx.conf] # machine-readable
"""

import json
import re
import sys
from pathlib import Path

HEADER_RE = re.compile(
r'add_header\s+(Content-Security-Policy(?:-Report-Only)?)\s+"(?P<policy>[^"]*)"',
re.IGNORECASE,
)
DEFAULT_CONF = Path(__file__).resolve().parents[4] / "frontend" / "nginx.conf"


def parse(conf_path: Path) -> tuple[str, dict[str, list[str]]]:
"""Return (header_name, {directive: [sources]}) for the conf's CSP header."""
text = conf_path.read_text()
match = HEADER_RE.search(text)
if not match:
raise SystemExit(f"No Content-Security-Policy add_header found in {conf_path}")
header = match.group(1)
directives = {}
for chunk in match.group("policy").split(";"):
parts = chunk.split()
if parts:
directives[parts[0]] = parts[1:]
return header, directives


def main() -> None:
args = [a for a in sys.argv[1:] if a != "--json"]
as_json = "--json" in sys.argv[1:]
conf = Path(args[0]) if args else DEFAULT_CONF
header, directives = parse(conf)
if as_json:
print(json.dumps({"header": header, "directives": directives}, indent=2))
return
print(f"{header} ({conf})")
for directive, sources in directives.items():
print(f"\n {directive}")
for source in sources:
print(f" {source}")


if __name__ == "__main__":
main()
111 changes: 111 additions & 0 deletions .claude/skills/csp-check/scripts/probe.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/**
* Browser-side CSP probe.
*
* Paste the whole file as the `function` argument of the chrome-devtools MCP
* `evaluate_script` tool while a page from the target deployment is selected, with the
* output of `extract_policy.py --json` inlined as DIRECTIVES below.
*
* It loads one throwaway resource per (directive, host) pair and records what the live
* policy reports, so it answers two questions the config file alone cannot:
* 1. does the deployment actually serve the policy we think it does?
* 2. is each host allowed on the directive that will really load it?
* Four control probes must always be reported -- if they are not, CSP is not applied.
*
* Note: CSP evaluates redirect targets. A probe path that 404-redirects to another host
* (https://hooks.stripe.com/ -> https://stripe.com) reports that target, not a real gap.
*/
async () => {
const DIRECTIVES = {
/* paste extract_policy.py --json "directives" here */
};

const KIND_BY_DIRECTIVE = {
"script-src": "script",
"style-src": "style",
"img-src": "img",
"font-src": "font",
"connect-src": "connect",
"frame-src": "frame",
"media-src": "media",
"worker-src": "worker",
};

const probes = [];
for (const [directive, sources] of Object.entries(DIRECTIVES)) {
const kind = KIND_BY_DIRECTIVE[directive];
if (!kind) continue;
for (const source of sources) {
if (!source.startsWith("https://")) continue;
probes.push([kind, source.replace(/\/$/, "") + "/__csp_probe", false]);
}
}
for (const kind of ["img", "connect", "script"]) {
probes.push([kind, "https://csp-control.invalid/__csp_probe", true]);
}
probes.push(["connect", "wss://csp-control.invalid/__csp_probe", true]);

const hits = [];
const onViolation = (e) =>
hits.push({
directive: e.effectiveDirective || e.violatedDirective,
blocked: e.blockedURI,
});
document.addEventListener("securitypolicyviolation", onViolation);
const wait = (ms) => new Promise((r) => setTimeout(r, ms));

const load = (kind, url) => {
if (kind === "script") {
const el = document.createElement("script");
el.src = url;
document.head.appendChild(el);
} else if (kind === "style") {
const el = document.createElement("link");
el.rel = "stylesheet";
el.href = url;
document.head.appendChild(el);
} else if (kind === "img") {
new Image().src = url;
} else if (kind === "font") {
new FontFace("cspProbe", `url(${url})`).load().catch(() => {});
} else if (kind === "connect") {
if (url.startsWith("wss:")) new WebSocket(url);
else fetch(url, { mode: "no-cors" }).catch(() => {});
} else if (kind === "frame") {
const el = document.createElement("iframe");
el.src = url;
el.style.display = "none";
document.body.appendChild(el);
} else if (kind === "media") {
const el = document.createElement("video");
el.src = url;
document.body.appendChild(el);
el.load();
} else if (kind === "worker") {
new Worker(url);
}
};

const expected = [];
for (const [kind, url, isControl] of probes) {
try {
load(kind, url);
} catch (e) {
/* cross-origin Worker/WebSocket constructors can throw; CSP still reports first */
}
if (isControl) expected.push(url);
await wait(250);
}
await wait(3000);
document.removeEventListener("securitypolicyviolation", onViolation);

const reported = new Set(hits.map((h) => h.blocked));
return {
// Hosts the policy is supposed to allow but the deployment still blocks.
unexpected: hits.filter((h) => !h.blocked.includes("csp-control.invalid")),
// Empty means CSP is live and restrictive. Non-empty means it is not applied at all.
controlsNotReported: expected.filter(
(url) => !reported.has(url) && !reported.has(new URL(url).origin)
),
probeCount: probes.length,
};
};
Loading
Loading