Skip to content

Commit ed26434

Browse files
authored
feat(security): implement Content Security Policy (#82)
* feat(security): implement Content Security Policy - Add kit.csp to svelte.config.js with mode: 'hash': default-src/connect-src/img-src 'self'; script-src with hashes; style-src 'self'; font-src/object-src 'none'; base-uri/form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests - CSP delivered as HTTP header (Cloudflare Worker SSR) so frame-ancestors is enforced; X-Frame-Options remains as legacy fallback - Replace style="display: contents" in app.html with Tailwind contents class to eliminate the one inline style attribute - Manually hash the theme-detection inline script in app.html; SvelteKit auto-hashes its bootstrap script per render - Document method restriction (405 on non-GET), CSP reporting gap, and coordinated disclosure policy in SECURITY.md - Restructure SECURITY.md: new Runtime protections section between How the application works and Supply chain controls * chore: add security.txt (RFC 9116)
1 parent 66310c2 commit ed26434

4 files changed

Lines changed: 61 additions & 15 deletions

File tree

SECURITY.md

Lines changed: 38 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ Include:
2323

2424
You can expect an acknowledgement within 48 hours and a resolution or status update within 7 days.
2525

26+
**Disclosure policy:** Reported vulnerabilities are kept confidential until a fix is deployed. The default embargo window is 90 days from initial report, though critical issues affecting user data will be patched and disclosed faster. After a fix ships, a GitHub Security Advisory will be published. CVE assignment will be requested for vulnerabilities with a CVSS score of 7.0 or higher.
27+
2628
---
2729

2830
## Threat model
@@ -31,7 +33,7 @@ You can expect an acknowledgement within 48 hours and a resolution or status upd
3133

3234
| Adversary | Goal | Primary controls |
3335
|---|---|---|
34-
| Malicious paste / shared link | Trigger XSS via crafted SAML or JWT content rendered in the DOM | Svelte's default text escaping, `esc()` in the XML highlighter, CSP (planned) |
36+
| Malicious paste / shared link | Trigger XSS via crafted SAML or JWT content rendered in the DOM | Svelte's default text escaping, `esc()` in the XML highlighter, CSP (`default-src 'self'`, no `unsafe-inline`, hash-pinned inline scripts) |
3537
| Compromised npm package | Inject malicious code into the build or CI environment | GuardDog, OSV-Scanner, Grype, `npm audit`, SHA-pinned actions, `npm ci` lockfile enforcement |
3638
| Compromised upstream GitHub Action | Substitute malicious CI code via a tampered version tag | All actions pinned to commit SHA, Dependabot rotates pins daily |
3739
| SSRF via OIDC proxy | Use the discovery Worker to reach internal infrastructure | `redirect: 'error'`, 5-second timeout, 100 KB cap, response validation |
@@ -83,6 +85,41 @@ The Worker does not receive, log, or store any JWT token. It receives only the i
8385
- **100 KB response cap** — the `Content-Length` header is checked before reading the body, and the body itself is capped at 100,000 bytes; oversized responses are rejected with 502
8486
- **Response validation** — the body must be valid JSON, must be a non-null object, and must contain an `issuer` field (required by RFC 8414); anything else returns 502
8587
- **Origin restriction** — responses include `Access-Control-Allow-Origin: https://samlguy.com`, preventing other browser origins from using the Worker as a free fetch proxy; this does not prevent the outbound request itself, only cross-origin response reads
88+
- **Method restriction** — only `GET` is exported from the endpoint; SvelteKit returns `405 Method Not Allowed` with an `Allow: GET, HEAD` header for any other method automatically
89+
90+
---
91+
92+
## Runtime protections
93+
94+
### Content Security Policy
95+
96+
CSP is configured via `kit.csp` in `svelte.config.js` using `mode: 'hash'` and delivered as a `Content-Security-Policy` HTTP response header on every request (not a meta tag — the Cloudflare Worker SSR path sets headers directly, which means `frame-ancestors` is respected):
97+
98+
- `default-src 'self'` — baseline: only same-origin resources allowed
99+
- `script-src 'self' <hashes>` — no `unsafe-inline`; SvelteKit automatically hashes its bootstrap script per render; the theme-detection script in `app.html` is covered by a manually computed static hash
100+
- `style-src 'self'` — all styles compile to external files; no inline `<style>` tags remain in the rendered HTML
101+
- `connect-src 'self'` — fetch calls (OIDC discovery proxy) are same-origin
102+
- `img-src 'self'` — favicon and assets only
103+
- `font-src 'none'` — no external fonts loaded
104+
- `object-src 'none'` — blocks plugins
105+
- `base-uri 'self'` — prevents base tag injection
106+
- `form-action 'self'` — prevents form hijacking
107+
- `frame-ancestors 'none'` — blocks the page from being embedded in any frame; delivered as an HTTP header so this directive is enforced (meta-tag CSP does not support `frame-ancestors`)
108+
- `upgrade-insecure-requests` — instructs browsers to upgrade any HTTP sub-resource requests to HTTPS
109+
110+
No `report-uri` or `report-to` directive is currently configured; CSP violations in production are silent. A future improvement is a lightweight violation-reporting endpoint that drops the `document-uri` field (which would contain the URL fragment and therefore the user's paste) before logging.
111+
112+
### HTTP security headers
113+
114+
A `_headers` file at the project root is processed by `@sveltejs/adapter-cloudflare` and deployed to every Cloudflare Pages response:
115+
116+
- `Strict-Transport-Security: max-age=63072000; includeSubDomains; preload` — instructs browsers to use HTTPS exclusively for two years; `preload` makes the domain eligible for inclusion in browser HSTS preload lists
117+
- `X-Content-Type-Options: nosniff` — prevents MIME-type sniffing
118+
- `X-Frame-Options: DENY` — legacy framing protection for browsers that do not support CSP `frame-ancestors`; modern browsers use the CSP directive above
119+
- `Referrer-Policy: no-referrer` — suppresses the `Referer` header on all outbound navigation; prevents token or payload data from leaking via URL referrers if a user clicks an external link
120+
- `Permissions-Policy` — disables camera, microphone, and geolocation access
121+
122+
The adapter additionally appends `Cache-Control: public, immutable, max-age=31536000` on all `/_app/immutable/*` assets and `Cache-Control: no-cache` on mutable app assets.
86123

87124
---
88125

@@ -141,18 +178,6 @@ The Cloudflare API token stored as a repository secret is scoped to Cloudflare P
141178

142179
The deploy job targets a GitHub Environment named `production`. This provides a configuration point for adding manual approval requirements, allowed-branch restrictions, or deployment protection rules in the future without changing any workflow code.
143180

144-
### HTTP security headers
145-
146-
A `_headers` file at the project root is processed by `@sveltejs/adapter-cloudflare` and deployed to every Cloudflare Pages response:
147-
148-
- `Strict-Transport-Security: max-age=63072000; includeSubDomains; preload` — instructs browsers to use HTTPS exclusively for two years; `preload` makes the domain eligible for inclusion in browser HSTS preload lists
149-
- `X-Content-Type-Options: nosniff` — prevents MIME-type sniffing
150-
- `X-Frame-Options: DENY` — blocks the site from being embedded in an iframe (clickjacking mitigation)
151-
- `Referrer-Policy: no-referrer` — suppresses the `Referer` header on all outbound navigation; prevents token or payload data from leaking via URL referrers if a user clicks an external link
152-
- `Permissions-Policy` — disables camera, microphone, and geolocation access
153-
154-
The adapter additionally appends `Cache-Control: public, immutable, max-age=31536000` on all `/_app/immutable/*` assets and `Cache-Control: no-cache` on mutable app assets.
155-
156181
### Dependency auditing
157182

158183
The CI workflow runs `npm audit --audit-level=moderate` on every push and pull request. The build fails if any moderate, high, or critical vulnerabilities are present in the transitive dependency tree. Dependencies are installed with `npm ci` (not `npm install`), which installs exactly what is recorded in `package-lock.json` and fails if there is any discrepancy — preventing lockfile drift and ensuring reproducible installs.

src/app.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,6 @@
1515
%sveltekit.head%
1616
</head>
1717
<body data-sveltekit-preload-data="hover">
18-
<div style="display: contents">%sveltekit.body%</div>
18+
<div class="contents">%sveltekit.body%</div>
1919
</body>
2020
</html>

static/.well-known/security.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Contact: https://github.com/kellenmurphy/samlguy/security/advisories/new
2+
Contact: mailto:me@kellenmurphy.com
3+
Expires: 2027-05-28T00:00:00.000Z
4+
Canonical: https://samlguy.com/.well-known/security.txt
5+
Preferred-Languages: en

svelte.config.js

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,22 @@ import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
44
export default {
55
preprocess: vitePreprocess(),
66
kit: {
7-
adapter: adapter()
7+
adapter: adapter(),
8+
csp: {
9+
mode: 'hash',
10+
directives: {
11+
'default-src': ['self'],
12+
'script-src': ['self', 'sha256-tQByV08yFR2Qu6IDiPNAOwdmQmbdiJs0AWzOhHN9fV0='],
13+
'style-src': ['self'],
14+
'img-src': ['self'],
15+
'connect-src': ['self'],
16+
'font-src': ['none'],
17+
'object-src': ['none'],
18+
'base-uri': ['self'],
19+
'form-action': ['self'],
20+
'upgrade-insecure-requests': true,
21+
'frame-ancestors': ['none']
22+
}
23+
}
824
}
925
};

0 commit comments

Comments
 (0)