diff --git a/package.json b/package.json index 47430ea..ceec9db 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "test:embed-policy": "node --test tests/embed-policy.test.mjs" }, "dependencies": { "@gcs/analytics": "file:./vendor/gcs-analytics", diff --git a/tests/embed-policy.test.mjs b/tests/embed-policy.test.mjs new file mode 100644 index 0000000..406800b --- /dev/null +++ b/tests/embed-policy.test.mjs @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +const config = JSON.parse(await readFile(new URL('../vercel.json', import.meta.url), 'utf8')) +const matchesEmbed = (condition) => + condition?.type === 'query' && condition.key === 'embed' && condition.value === '^9line$' +const headerValue = (rule, key) => rule.headers.find((header) => header.key === key)?.value + +test('only the owned 9Line embed mode relaxes Roadrunner framing', () => { + const standalone = config.headers.find((rule) => rule.missing?.some(matchesEmbed)) + const embed = config.headers.find((rule) => rule.has?.some(matchesEmbed)) + + assert.equal(headerValue(standalone, 'X-Frame-Options'), 'SAMEORIGIN') + assert.match(headerValue(standalone, 'Content-Security-Policy'), /frame-ancestors 'none'/) + assert.equal(headerValue(embed, 'X-Frame-Options'), undefined) + assert.match(headerValue(embed, 'Content-Security-Policy'), /frame-ancestors https:\/\/9line\.dev(?:;|$)/) + assert.doesNotMatch(headerValue(embed, 'Content-Security-Policy'), /frame-ancestors[^;]*\*/) +}) diff --git a/vercel.json b/vercel.json index 4b35681..780a413 100644 --- a/vercel.json +++ b/vercel.json @@ -28,6 +28,9 @@ "headers": [ { "source": "/(.*)", + "missing": [ + { "type": "query", "key": "embed", "value": "^9line$" } + ], "headers": [ { "key": "X-Content-Type-Options", "value": "nosniff" }, { "key": "X-Frame-Options", "value": "SAMEORIGIN" }, @@ -37,6 +40,19 @@ { "key": "Content-Security-Policy", "value": "default-src 'self'; script-src 'self' 'unsafe-inline' https://www.googletagmanager.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; media-src 'self'; connect-src 'self' https://api.9line.dev https://www.google-analytics.com https://region1.google-analytics.com https://analytics.google.com https://stats.g.doubleclick.net; frame-ancestors 'none'" } ] }, + { + "source": "/(.*)", + "has": [ + { "type": "query", "key": "embed", "value": "^9line$" } + ], + "headers": [ + { "key": "X-Content-Type-Options", "value": "nosniff" }, + { "key": "X-XSS-Protection", "value": "1; mode=block" }, + { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" }, + { "key": "Permissions-Policy", "value": "camera=(), microphone=(), geolocation=()" }, + { "key": "Content-Security-Policy", "value": "default-src 'self'; script-src 'self' 'unsafe-inline' https://www.googletagmanager.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; media-src 'self'; connect-src 'self' https://api.9line.dev https://www.google-analytics.com https://region1.google-analytics.com https://analytics.google.com https://stats.g.doubleclick.net; frame-ancestors https://9line.dev" } + ] + }, { "source": "/assets/(.*)", "headers": [