diff --git a/docs/input-validation-audit.md b/docs/input-validation-audit.md
index bec41a81..46fa12f5 100644
--- a/docs/input-validation-audit.md
+++ b/docs/input-validation-audit.md
@@ -29,8 +29,7 @@ Azure/Sentinel integrations, subprocesses, or external AI providers.
| Sentinel ingestion CLI | JSON file, scan ID, finding records and environment configuration | Existing regular `.json` file under 10 MiB; at most 1,000 object findings; bounded fields; severity/config format checks |
| Azure resource data | Management-plane SDK objects | Typed SDK accessors; failures preserved as unknown; no subprocess interpolation |
| Playbook selection | Rule ID derived from stored finding | Allowlisted identifier converted to a filename and constrained beneath `playbooks/cli` |
-| Website media URLs | User-entered video URL | HTTPS host allowlist and embed conversion tests in `website/test_toEmbedUrl.mjs` |
-| Website editor text | Titles, excerpts, names and Markdown content | Intentionally free-form client-side content; repository write still requires the operator's GitHub token and GitHub authorization |
+| Website content | Titles, excerpts, names and Markdown content authored in Decap CMS | No browser-side renderer remains: content is committed to the repository and rendered by Astro at build time, so nothing user-entered reaches a live DOM sink. Repository writes still require GitHub authorization, and the deployed pages are served under `script-src 'self'` with no inline script |
## Intentionally unrestricted text
diff --git a/website/README.md b/website/README.md
index 20aeb129..85b488a8 100644
--- a/website/README.md
+++ b/website/README.md
@@ -49,8 +49,13 @@ to GitHub Pages, so the official OWASP site reflects release-controlled source.
Manual runs can deploy only when dispatched from `main`.
GitHub Pages does not support custom response headers. The document-level
-content security policy covers supported directives, but hosting-level headers
-such as `frame-ancestors` require a configurable hosting edge.
+content security policy in `src/layouts/Base.astro` covers only the directives
+a `` policy actually enforces. `frame-ancestors` (and
+`report-uri`/`report-to`, `sandbox`) are ignored in a meta policy, so
+clickjacking protection is **not** in place: it needs a real
+`Content-Security-Policy` or `X-Frame-Options` HTTP response header from a
+configurable hosting edge. `scripts/verify-site.mjs` deliberately does not
+assert `frame-ancestors` so CI never reports protection that does not exist.
## One-time maintainer setup
diff --git a/website/astro.config.mjs b/website/astro.config.mjs
index 545f8231..d58c401a 100644
--- a/website/astro.config.mjs
+++ b/website/astro.config.mjs
@@ -6,7 +6,23 @@ export default defineConfig({
site: 'https://owasp.github.io',
base: '/openshield',
integrations: [sitemap()],
+ build: {
+ // Never inline bundled CSS into the HTML. The site's CSP
+ // (src/layouts/Base.astro) is script-src 'self' with no 'unsafe-inline'
+ // and no nonce/hash - an inlined ]+)/i.exec(attrs);
+ if (srcMatch) {
+ // An external script is only loadable under script-src 'self'; a remote
+ // src would build green and then be CSP-blocked in the browser, which is
+ // the same broken-but-verified class this scan exists to prevent.
+ if (!srcMatch[1].startsWith('/openshield/')) {
+ failures.push(`${relative} loads script ${srcMatch[1]}, which script-src 'self' will block on the deployed site`);
+ }
+ continue;
+ }
+ const typeMatch = /\btype\s*=\s*["']?([^"'\s>]+)/i.exec(attrs);
+ const type = typeMatch ? typeMatch[1].toLowerCase() : '';
+ if (type === 'application/json' || type === 'application/ld+json' || type === 'speculationrules') continue;
+ if (body) {
+ failures.push(`${relative} has an inline script element that script-src 'self' will block on the deployed site`);
+ break;
+ }
+ }
+ // Only directives a CSP actually enforces. frame-ancestors
+ // is deliberately absent: browsers ignore it in a meta policy, and GitHub
+ // Pages cannot set the HTTP response header that would make it effective, so
+ // asserting its presence here would report clickjacking protection that does
+ // not exist. That protection has to come from a real edge/header if the site
+ // ever moves to configurable hosting.
+ for (const directive of ['object-src \'none\'', 'base-uri \'self\'', 'form-action \'self\'']) {
+ if (!csp.includes(directive)) failures.push(`${relative} is missing CSP directive ${directive}`);
+ }
for (const match of html.matchAll(/href="([^"]+)"/g)) {
const href = match[1];
if (!href.startsWith('/openshield/')) continue;
diff --git a/website/src/layouts/Base.astro b/website/src/layouts/Base.astro
index ff9f1f60..ae143ad6 100644
--- a/website/src/layouts/Base.astro
+++ b/website/src/layouts/Base.astro
@@ -98,11 +98,16 @@ const siteJsonLd = {
{breadcrumbs && }
-
+