security: harden cookies + CSRF for reverse-proxy deployments
MREADME.md
| @@ -100,6 +100,20 @@ All settings are environment variables: | |||
|---|---|---|---|
| 100 | 100 | ||
| 101 | 101 | \*\* Set `TRUSTED_PROXY=1` only when Hearthforge is behind a reverse proxy that strips any incoming `X-Forwarded-For` from clients. Caddy and Traefik do this by default; nginx requires `proxy_set_header X-Forwarded-For $remote_addr;` (rather than the common `$proxy_add_x_forwarded_for`, which appends to a client-supplied value). Setting `TRUSTED_PROXY=1` in front of a proxy that does not strip means rate limits and any audit logging are spoofable per request. | |
| 102 | 102 | ||
| 103 | + | ### Reverse proxy deployment | |
| 104 | + | ||
| 105 | + | Hearthforge does not terminate TLS itself. For any production deployment, run it behind an HTTPS-terminating reverse proxy (Caddy, nginx, Traefik, …) and set: | |
| 106 | + | ||
| 107 | + | ``` | |
| 108 | + | BASE_URL=https://your-forge.example.com | |
| 109 | + | ``` | |
| 110 | + | ||
| 111 | + | A `BASE_URL` with the `https://` scheme is what activates HTTPS hardening: | |
| 112 | + | ||
| 113 | + | - Session and preference cookies are emitted with `Secure`, so the browser will only send them over HTTPS. | |
| 114 | + | - Every response includes `Strict-Transport-Security: max-age=31536000; includeSubDomains`. | |
| 115 | + | - The CSRF middleware compares the request `Origin` against `BASE_URL` (full origin, scheme + host + port), not the `Host` header. | |
| 116 | + | ||
| 103 | 117 | ## CI/CD Pipelines | |
| 104 | 118 | ||
| 105 | 119 | Hearthforge includes a built-in CI/CD system that runs pipelines in Docker or Podman containers, | |
Msrc/app.ts
| @@ -51,17 +51,54 @@ export async function createApp(port: number) { | |||
|---|---|---|---|
| 51 | 51 | }), | |
| 52 | 52 | ) | |
| 53 | 53 | .onRequest(({ request }) => { | |
| 54 | - | // CSRF defense-in-depth: reject mutating requests whose Origin | |
| 55 | - | // header is present but does not match the Host. Modern browsers | |
| 56 | - | // attach Origin to all cross-site mutating requests, so this | |
| 57 | - | // catches what SameSite=Lax cookies would have allowed (top-level | |
| 58 | - | // POSTs from same-origin contexts are unaffected). Non-browser | |
| 59 | - | // clients (git push, curl) typically omit Origin and pass through. | |
| 54 | + | // CSRF defense-in-depth. The actual CSRF defense is the session | |
| 55 | + | // cookie's `SameSite=Lax`, which tells the browser not to attach | |
| 56 | + | // the cookie to cross-site sub-requests at all and to attach it | |
| 57 | + | // on cross-site top-level navigations only for safe methods. This | |
| 58 | + | // middleware adds a second line on top of that. | |
| 59 | + | // | |
| 60 | + | // For mutating methods we require either no Origin header (allowed | |
| 61 | + | // because non-browser clients like `git push` and `curl` do not | |
| 62 | + | // send one, and they authenticate via HTTP Basic, not the session | |
| 63 | + | // cookie — so they aren't part of the CSRF surface), or an Origin | |
| 64 | + | // that matches a known good value. | |
| 65 | + | // | |
| 66 | + | // In HTTPS production mode (config.PUBLIC_HTTPS) we compare the | |
| 67 | + | // full Origin string against config.PUBLIC_ORIGIN (scheme + host + | |
| 68 | + | // port). This is stricter than a Host-header match and is the | |
| 69 | + | // right move when behind a reverse proxy, because the proxy may | |
| 70 | + | // rewrite Host to the backend (e.g. localhost:3000) and a Host | |
| 71 | + | // match could also be satisfied by an attacker-controlled | |
| 72 | + | // subdomain that shares cookies (we set Path=/ with no Domain, so | |
| 73 | + | // today there is no such subdomain — but a future *.your-forge | |
| 74 | + | // setup must not silently break this assumption). | |
| 75 | + | // | |
| 76 | + | // In dev mode (BASE_URL is plain http://) we keep the looser | |
| 77 | + | // Origin.host === Host check so localhost:3000 / 127.0.0.1:3000 | |
| 78 | + | // can be used interchangeably during local work. | |
| 79 | + | // | |
| 80 | + | // Two regressions would each break this design — re-read this | |
| 81 | + | // before changing either: | |
| 82 | + | // 1. Switching session cookies to SameSite=None re-opens | |
| 83 | + | // cross-site cookie attachment; the no-Origin allow branch | |
| 84 | + | // below would then need to be removed for cookie-authed | |
| 85 | + | // mutations. | |
| 86 | + | // 2. Introducing a state-mutating GET handler bypasses this | |
| 87 | + | // middleware (gated on method) and SameSite=Lax allows the | |
| 88 | + | // cookie on top-level GET navigations. Don't add such a | |
| 89 | + | // handler. | |
| 60 | 90 | const m = request.method; | |
| 61 | 91 | if (m !== "POST" && m !== "PUT" && m !== "PATCH" && m !== "DELETE") | |
| 62 | 92 | return; | |
| 63 | 93 | const origin = request.headers.get("origin"); | |
| 64 | 94 | if (!origin) return; | |
| 95 | + | if (config.PUBLIC_HTTPS) { | |
| 96 | + | if (origin !== config.PUBLIC_ORIGIN) | |
| 97 | + | return new Response("Cross-origin request rejected", { | |
| 98 | + | status: 403, | |
| 99 | + | }); | |
| 100 | + | return; | |
| 101 | + | } | |
| 65 | 102 | const host = request.headers.get("host"); | |
| 66 | 103 | let originHost: string; | |
| 67 | 104 | try { | |
| @@ -78,6 +115,12 @@ export async function createApp(port: number) { | |||
|---|---|---|---|
| 78 | 115 | if (response instanceof Response) { | |
| 79 | 116 | response.headers.set("Content-Security-Policy", CSP); | |
| 80 | 117 | response.headers.set("X-Frame-Options", "DENY"); | |
| 118 | + | if (config.PUBLIC_HTTPS) { | |
| 119 | + | response.headers.set( | |
| 120 | + | "Strict-Transport-Security", | |
| 121 | + | "max-age=31536000; includeSubDomains", | |
| 122 | + | ); | |
| 123 | + | } | |
| 81 | 124 | } | |
| 82 | 125 | }) | |
| 83 | 126 | .get("/health", async () => { | |
Msrc/config.ts
| @@ -29,6 +29,10 @@ const config = { | |||
|---|---|---|---|
| 29 | 29 | BASE_URL: | |
| 30 | 30 | env.BASE_URL ?? | |
| 31 | 31 | `http://localhost:${parseInt(env.PORT ?? "", 10) || 3000}`, | |
| 32 | + | // Derived from BASE_URL after the literal closes (see below). Declared | |
| 33 | + | // here so consumers get a typed config object. | |
| 34 | + | PUBLIC_HTTPS: false, | |
| 35 | + | PUBLIC_ORIGIN: "", | |
| 32 | 36 | DATA_DIR: path.resolve(env.DATA_DIR ?? "./data"), | |
| 33 | 37 | HIGHLIGHT_WORKERS: parseInt(env.HIGHLIGHT_WORKERS ?? "", 10) || 4, | |
| 34 | 38 | COMMITTER_NAME: env.COMMITTER_NAME ?? env.OWNER_DISPLAY_NAME ?? "Admin", | |
| @@ -62,8 +66,10 @@ const config = { | |||
|---|---|---|---|
| 62 | 66 | }; | |
| 63 | 67 | ||
| 64 | 68 | // Derived values that depend on other config fields | |
| 69 | + | const baseUrl = new URL(config.BASE_URL); | |
| 70 | + | config.PUBLIC_HTTPS = baseUrl.protocol === "https:"; | |
| 71 | + | config.PUBLIC_ORIGIN = baseUrl.origin; | |
| 65 | 72 | config.COMMITTER_EMAIL = | |
| 66 | - | env.COMMITTER_EMAIL ?? | |
| 67 | - | `${config.OWNER_DISPLAY_NAME}@${new URL(config.BASE_URL).hostname}`; | |
| 73 | + | env.COMMITTER_EMAIL ?? `${config.OWNER_DISPLAY_NAME}@${baseUrl.hostname}`; | |
| 68 | 74 | ||
| 69 | 75 | export default config; | |
Msrc/routes/auth.tsx
| @@ -60,11 +60,13 @@ async function createSession(userId: number): Promise<string> { | |||
|---|---|---|---|
| 60 | 60 | } | |
| 61 | 61 | ||
| 62 | 62 | function sessionCookie(id: string): string { | |
| 63 | - | return `session=${id}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_DURATION_SECONDS}`; | |
| 63 | + | const secure = config.PUBLIC_HTTPS ? "; Secure" : ""; | |
| 64 | + | return `session=${id}; Path=/; HttpOnly; SameSite=Lax${secure}; Max-Age=${SESSION_DURATION_SECONDS}`; | |
| 64 | 65 | } | |
| 65 | 66 | ||
| 66 | 67 | function clearCookie(): string { | |
| 67 | - | return "session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"; | |
| 68 | + | const secure = config.PUBLIC_HTTPS ? "; Secure" : ""; | |
| 69 | + | return `session=; Path=/; HttpOnly; SameSite=Lax${secure}; Max-Age=0`; | |
| 68 | 70 | } | |
| 69 | 71 | ||
| 70 | 72 | // In-memory challenge store (fine for single-process) | |
Msrc/routes/repos.tsx
| @@ -193,9 +193,10 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 193 | 193 | "/sort", | |
| 194 | 194 | ({ body }) => { | |
| 195 | 195 | const sort = body.sort === "name" ? "name" : "created"; | |
| 196 | + | const secure = config.PUBLIC_HTTPS ? "; Secure" : ""; | |
| 196 | 197 | return redirect( | |
| 197 | 198 | "/", | |
| 198 | - | `repo_sort=${sort}; Path=/; SameSite=Lax; Max-Age=${YEAR_SECONDS}`, | |
| 199 | + | `repo_sort=${sort}; Path=/; SameSite=Lax${secure}; Max-Age=${YEAR_SECONDS}`, | |
| 199 | 200 | ); | |
| 200 | 201 | }, | |
| 201 | 202 | { body: t.Object({ sort: t.String() }) }, | |
Msrc/routes/settings.tsx
| @@ -268,7 +268,8 @@ export const settingsRoutes = new Elysia() | |||
|---|---|---|---|
| 268 | 268 | return redirect("/settings?error=Invalid+theme"); | |
| 269 | 269 | } | |
| 270 | 270 | ||
| 271 | - | const cookieHeader = `theme=${theme}; Path=/; SameSite=Lax; Max-Age=${YEAR_SECONDS}`; | |
| 271 | + | const secure = config.PUBLIC_HTTPS ? "; Secure" : ""; | |
| 272 | + | const cookieHeader = `theme=${theme}; Path=/; SameSite=Lax${secure}; Max-Age=${YEAR_SECONDS}`; | |
| 272 | 273 | return redirect("/settings?success=theme", cookieHeader); | |
| 273 | 274 | }, | |
| 274 | 275 | { | |
Atests/e2e.csrf.test.ts
| @@ -0,0 +1,175 @@ | |||
|---|---|---|---|
| 1 | + | import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; | |
| 2 | + | import config from '../src/config.ts'; | |
| 3 | + | import { | |
| 4 | + | BASE, | |
| 5 | + | ADMIN_PASS, | |
| 6 | + | setupTestEnv, | |
| 7 | + | spawnServer, | |
| 8 | + | killServer, | |
| 9 | + | } from './helpers.ts'; | |
| 10 | + | ||
| 11 | + | let server: Awaited<ReturnType<typeof spawnServer>>; | |
| 12 | + | ||
| 13 | + | // The CSRF middleware reads `config.PUBLIC_HTTPS` / `config.PUBLIC_ORIGIN` per | |
| 14 | + | // request, so we toggle those between the dev-mode and HTTPS-mode describe | |
| 15 | + | // blocks rather than spinning up two servers. | |
| 16 | + | const ORIGINAL_PUBLIC_HTTPS = config.PUBLIC_HTTPS; | |
| 17 | + | const ORIGINAL_PUBLIC_ORIGIN = config.PUBLIC_ORIGIN; | |
| 18 | + | ||
| 19 | + | beforeAll(async () => { | |
| 20 | + | await setupTestEnv(); | |
| 21 | + | server = await spawnServer(); | |
| 22 | + | }); | |
| 23 | + | ||
| 24 | + | afterAll(async () => { | |
| 25 | + | await killServer(server); | |
| 26 | + | config.PUBLIC_HTTPS = ORIGINAL_PUBLIC_HTTPS; | |
| 27 | + | config.PUBLIC_ORIGIN = ORIGINAL_PUBLIC_ORIGIN; | |
| 28 | + | }); | |
| 29 | + | ||
| 30 | + | // `bun:test` runs describe blocks in source order, so the dev-mode block runs | |
| 31 | + | // first against the unmodified config, then we flip into HTTPS mode. | |
| 32 | + | describe('CSRF / Secure cookie — dev mode (http BASE_URL)', () => { | |
| 33 | + | test('starts with PUBLIC_HTTPS off', () => { | |
| 34 | + | expect(config.PUBLIC_HTTPS).toBe(false); | |
| 35 | + | }); | |
| 36 | + | ||
| 37 | + | test('POST with no Origin is allowed (non-browser path)', async () => { | |
| 38 | + | const r = await fetch(`${BASE}/login`, { | |
| 39 | + | method: 'POST', | |
| 40 | + | headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, | |
| 41 | + | body: 'username=admin&password=wrong', | |
| 42 | + | redirect: 'manual', | |
| 43 | + | }); | |
| 44 | + | expect(r.status).not.toBe(403); | |
| 45 | + | }); | |
| 46 | + | ||
| 47 | + | test('POST with same-origin Origin is allowed', async () => { | |
| 48 | + | const r = await fetch(`${BASE}/login`, { | |
| 49 | + | method: 'POST', | |
| 50 | + | headers: { | |
| 51 | + | 'Content-Type': 'application/x-www-form-urlencoded', | |
| 52 | + | Origin: BASE, | |
| 53 | + | }, | |
| 54 | + | body: 'username=admin&password=wrong', | |
| 55 | + | redirect: 'manual', | |
| 56 | + | }); | |
| 57 | + | expect(r.status).not.toBe(403); | |
| 58 | + | }); | |
| 59 | + | ||
| 60 | + | test('POST with mismatched Origin is rejected', async () => { | |
| 61 | + | const r = await fetch(`${BASE}/login`, { | |
| 62 | + | method: 'POST', | |
| 63 | + | headers: { | |
| 64 | + | 'Content-Type': 'application/x-www-form-urlencoded', | |
| 65 | + | Origin: 'http://attacker.example', | |
| 66 | + | }, | |
| 67 | + | body: 'username=admin&password=wrong', | |
| 68 | + | redirect: 'manual', | |
| 69 | + | }); | |
| 70 | + | expect(r.status).toBe(403); | |
| 71 | + | }); | |
| 72 | + | ||
| 73 | + | test('successful login Set-Cookie omits Secure', async () => { | |
| 74 | + | const r = await fetch(`${BASE}/login`, { | |
| 75 | + | method: 'POST', | |
| 76 | + | headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, | |
| 77 | + | body: `username=admin&password=${encodeURIComponent(ADMIN_PASS)}`, | |
| 78 | + | redirect: 'manual', | |
| 79 | + | }); | |
| 80 | + | expect(r.status).toBe(302); | |
| 81 | + | const cookie = r.headers.get('set-cookie') ?? ''; | |
| 82 | + | expect(cookie).toContain('session='); | |
| 83 | + | expect(cookie).not.toContain('Secure'); | |
| 84 | + | }); | |
| 85 | + | ||
| 86 | + | test('responses do not include Strict-Transport-Security', async () => { | |
| 87 | + | const r = await fetch(`${BASE}/health`); | |
| 88 | + | expect(r.headers.get('strict-transport-security')).toBeNull(); | |
| 89 | + | }); | |
| 90 | + | }); | |
| 91 | + | ||
| 92 | + | describe('CSRF / Secure cookie — HTTPS mode (https BASE_URL)', () => { | |
| 93 | + | beforeAll(() => { | |
| 94 | + | // Simulate `BASE_URL=https://forge.test`. Note that the test client still | |
| 95 | + | // talks to the server over plain HTTP on localhost — that's the whole | |
| 96 | + | // point of the reverse-proxy story: the app trusts BASE_URL, not the | |
| 97 | + | // transport it sees on the proxy↔app hop. | |
| 98 | + | config.PUBLIC_HTTPS = true; | |
| 99 | + | config.PUBLIC_ORIGIN = 'https://forge.test'; | |
| 100 | + | }); | |
| 101 | + | ||
| 102 | + | test('POST with no Origin is allowed (non-browser path)', async () => { | |
| 103 | + | const r = await fetch(`${BASE}/login`, { | |
| 104 | + | method: 'POST', | |
| 105 | + | headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, | |
| 106 | + | body: 'username=admin&password=wrong', | |
| 107 | + | redirect: 'manual', | |
| 108 | + | }); | |
| 109 | + | expect(r.status).not.toBe(403); | |
| 110 | + | }); | |
| 111 | + | ||
| 112 | + | test('POST with matching public Origin is allowed', async () => { | |
| 113 | + | const r = await fetch(`${BASE}/login`, { | |
| 114 | + | method: 'POST', | |
| 115 | + | headers: { | |
| 116 | + | 'Content-Type': 'application/x-www-form-urlencoded', | |
| 117 | + | Origin: 'https://forge.test', | |
| 118 | + | }, | |
| 119 | + | body: 'username=admin&password=wrong', | |
| 120 | + | redirect: 'manual', | |
| 121 | + | }); | |
| 122 | + | expect(r.status).not.toBe(403); | |
| 123 | + | }); | |
| 124 | + | ||
| 125 | + | test('POST whose Origin only matches Host (not BASE_URL) is rejected', async () => { | |
| 126 | + | // Stricter than dev mode: `Origin: ${BASE}` (http://localhost:PORT) would | |
| 127 | + | // pass the Host-match check but must fail the BASE_URL check. | |
| 128 | + | const r = await fetch(`${BASE}/login`, { | |
| 129 | + | method: 'POST', | |
| 130 | + | headers: { | |
| 131 | + | 'Content-Type': 'application/x-www-form-urlencoded', | |
| 132 | + | Origin: BASE, | |
| 133 | + | }, | |
| 134 | + | body: 'username=admin&password=wrong', | |
| 135 | + | redirect: 'manual', | |
| 136 | + | }); | |
| 137 | + | expect(r.status).toBe(403); | |
| 138 | + | }); | |
| 139 | + | ||
| 140 | + | test('POST with attacker Origin is rejected', async () => { | |
| 141 | + | const r = await fetch(`${BASE}/login`, { | |
| 142 | + | method: 'POST', | |
| 143 | + | headers: { | |
| 144 | + | 'Content-Type': 'application/x-www-form-urlencoded', | |
| 145 | + | Origin: 'https://attacker.example', | |
| 146 | + | }, | |
| 147 | + | body: 'username=admin&password=wrong', | |
| 148 | + | redirect: 'manual', | |
| 149 | + | }); | |
| 150 | + | expect(r.status).toBe(403); | |
| 151 | + | }); | |
| 152 | + | ||
| 153 | + | test('successful login Set-Cookie includes Secure', async () => { | |
| 154 | + | const r = await fetch(`${BASE}/login`, { | |
| 155 | + | method: 'POST', | |
| 156 | + | headers: { | |
| 157 | + | 'Content-Type': 'application/x-www-form-urlencoded', | |
| 158 | + | Origin: 'https://forge.test', | |
| 159 | + | }, | |
| 160 | + | body: `username=admin&password=${encodeURIComponent(ADMIN_PASS)}`, | |
| 161 | + | redirect: 'manual', | |
| 162 | + | }); | |
| 163 | + | expect(r.status).toBe(302); | |
| 164 | + | const cookie = r.headers.get('set-cookie') ?? ''; | |
| 165 | + | expect(cookie).toContain('session='); | |
| 166 | + | expect(cookie).toContain('Secure'); | |
| 167 | + | }); | |
| 168 | + | ||
| 169 | + | test('responses include Strict-Transport-Security', async () => { | |
| 170 | + | const r = await fetch(`${BASE}/health`); | |
| 171 | + | expect(r.headers.get('strict-transport-security')).toBe( | |
| 172 | + | 'max-age=31536000; includeSubDomains', | |
| 173 | + | ); | |
| 174 | + | }); | |
| 175 | + | }); | |