Skip to content
Open
9 changes: 7 additions & 2 deletions website/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<meta http-equiv>` 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

Expand Down
15 changes: 15 additions & 0 deletions website/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,22 @@ export default defineConfig({
site: 'https://owasp.github.io',
base: '/openshield',
integrations: [sitemap()],
build: {
// Never inline hoisted <script> or 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 <script> block would be silently blocked
// by the browser on the deployed GitHub Pages site. Emitting every script
// as a same-origin file keeps it inside 'self'. verify-site.mjs also fails
// the build if an executable inline <script> slips into any page.
inlineStylesheets: 'never',
assetsInlineLimit: 0,
},
vite: {
build: {
// Same reason: stop Vite from inlining small chunks as data: URIs or
// inline script text during Astro's client bundle step.
assetsInlineLimit: 0,
},
server: {
fs: {
// repoData.ts reads scanner/rules, playbooks and docs from the repo root
Expand Down
44 changes: 44 additions & 0 deletions website/scripts/verify-site.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,50 @@ for (const file of htmlFiles) {
if (!html.includes('href="#main-content"')) failures.push(`${relative} has no skip link`);
if (!html.includes('<meta name="description"')) failures.push(`${relative} has no meta description`);
if (!html.includes('<link rel="canonical"')) failures.push(`${relative} has no canonical URL`);
const csp = html.match(/<meta http-equiv="Content-Security-Policy" content="([^"]+)"/i)?.[1] || '';
if (!csp) failures.push(`${relative} has no Content-Security-Policy meta policy`);
const scriptPolicy = csp.split(';').find((directive) => directive.trim().toLowerCase().startsWith('script-src')) || '';
// This site deliberately permits scripts from its own origin only. Checking
// merely for the absence of unsafe-inline would allow a future remote source
// to weaken the CSP without failing the production-build verification.
if (scriptPolicy.trim() !== "script-src 'self'") {
failures.push(`${relative} must use exactly script-src 'self' in its Content-Security-Policy`);
}
// script-src 'self' with no 'unsafe-inline'/nonce/hash means the browser
// silently blocks any inline script on the deployed site. Checking the CSP
// string alone would pass while the page is actually broken, so assert the
// built HTML carries no executable inline script - every script must be an
// external same-origin file (data blocks like application/json and
// application/ld+json are not executed and are fine). Astro is configured
// (build.assetsInlineLimit: 0) to emit hoisted scripts as files for exactly
// this reason. Scanned by hand rather than a tag regex so this is not a
// brittle HTML filter (CodeQL js/bad-tag-filter): a case-insensitive index
// walk from each opening tag to its closing tag.
const lower = html.toLowerCase();
for (let open = lower.indexOf('<script'); open !== -1; open = lower.indexOf('<script', open + 7)) {
const tagEnd = html.indexOf('>', open);
if (tagEnd === -1) break;
const attrs = html.slice(open + 7, tagEnd);
const close = lower.indexOf('</script', tagEnd);
const body = close === -1 ? '' : html.slice(tagEnd + 1, close).trim();
if (/\bsrc\s*=/i.test(attrs)) 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 <meta http-equiv> 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;
Expand Down
13 changes: 9 additions & 4 deletions website/src/layouts/Base.astro
Original file line number Diff line number Diff line change
Expand Up @@ -98,11 +98,16 @@ const siteJsonLd = {
{breadcrumbs && <script type="application/ld+json" set:html={JSON.stringify(breadcrumbs)} is:inline></script>}

<!--
GitHub Pages cannot configure response headers. This meta policy covers the
directives browsers support in document metadata. Hosting-level directives
must be added if the deployment moves to a configurable edge.
GitHub Pages cannot configure response headers, so this policy ships in a
<meta http-equiv> tag. Per the CSP spec, a meta policy enforces only the
directives below; frame-ancestors, report-uri/report-to, and sandbox are
ignored when delivered this way. Clickjacking protection therefore requires
a real Content-Security-Policy (or X-Frame-Options) HTTP response header and
must be added at the edge if this site ever moves to configurable hosting;
a frame-ancestors directive here would only give a false sense of coverage
(verify-site.mjs intentionally does not check for it).
-->
<meta http-equiv="Content-Security-Policy" content={`default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data:; connect-src 'self' https://api.github.com; object-src 'none'; base-uri 'self'`}>
<meta http-equiv="Content-Security-Policy" content={`default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data:; connect-src 'self' https://api.github.com; object-src 'none'; base-uri 'self'; form-action 'self'`}>
<meta name="referrer" content="strict-origin-when-cross-origin">
</head>
<body>
Expand Down
Loading