fix(deploy): harden the Node container after review
Bundle every dependency so the runtime image ships only build/, drop the unused Turso/drizzle stack, add cache headers for static assets, an /api/health route, env-driven log level, ORIGIN-based CSRF check, and read the client address only for API routes.
- commit
- c76e7e652765798490ba9d459168c1bb4f2875ce
- author
- Kit · · 2026-10-12 12:51 UTC
- parent
- 5d60e39
- signature
- SSH Unverified
SHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6TkThe key is unknown
11 files · +100 -1109
M .dockerignore +3 -1
| @@ -2,10 +2,12 @@ node_modules | |||
| 2 | 2 | .svelte-kit | |
| 3 | 3 | build | |
| 4 | 4 | .git | |
| 5 | + | .github | |
| 5 | 6 | .worktrees | |
| 6 | 7 | .env | |
| 7 | 8 | .env.* | |
| 8 | - | !.env.example | |
| 9 | + | .env example | |
| 10 | + | *.md | |
| 9 | 11 | docs | |
| 10 | 12 | .claude | |
| 11 | 13 | .cursor | |
| @@ -2,10 +2,12 @@ node_modules | |||||
| 2 | .svelte-kit | 2 | .svelte-kit | ||
| 3 | build | 3 | build | ||
| 4 | .git | 4 | .git | ||
| 5 | + | .github | |||
| 5 | .worktrees | 6 | .worktrees | ||
| 6 | .env | 7 | .env | ||
| 7 | .env.* | 8 | .env.* | ||
| 8 | - | !.env.example | 9 | + | .env example |
| 10 | + | *.md | |||
| 9 | docs | 11 | docs | ||
| 10 | .claude | 12 | .claude | ||
| 11 | .cursor | 13 | .cursor | ||
M .github/dependabot.yml +0 -8
| @@ -40,14 +40,6 @@ updates: | |||
| 40 | 40 | update-types: | |
| 41 | 41 | - 'minor' | |
| 42 | 42 | - 'patch' | |
| 43 | - | # Group Drizzle ORM related packages | |
| 44 | - | drizzle: | |
| 45 | - | patterns: | |
| 46 | - | - 'drizzle-*' | |
| 47 | - | - '@libsql/*' | |
| 48 | - | update-types: | |
| 49 | - | - 'minor' | |
| 50 | - | - 'patch' | |
| 51 | 43 | # Set PR limits to avoid overwhelming the team | |
| 52 | 44 | open-pull-requests-limit: 8 | |
| 53 | 45 | # Ignore packages that require manual updates or are pinned for specific reasons | |
| @@ -40,14 +40,6 @@ updates: | |||||
| 40 | update-types: | 40 | update-types: | ||
| 41 | - 'minor' | 41 | - 'minor' | ||
| 42 | - 'patch' | 42 | - 'patch' | ||
| 43 | - | # Group Drizzle ORM related packages | |||
| 44 | - | drizzle: | |||
| 45 | - | patterns: | |||
| 46 | - | - 'drizzle-*' | |||
| 47 | - | - '@libsql/*' | |||
| 48 | - | update-types: | |||
| 49 | - | - 'minor' | |||
| 50 | - | - 'patch' | |||
| 51 | # Set PR limits to avoid overwhelming the team | 43 | # Set PR limits to avoid overwhelming the team | ||
| 52 | open-pull-requests-limit: 8 | 44 | open-pull-requests-limit: 8 | ||
| 53 | # Ignore packages that require manual updates or are pinned for specific reasons | 45 | # Ignore packages that require manual updates or are pinned for specific reasons | ||
M .github/workflows/ci.yml +0 -2
| @@ -18,8 +18,6 @@ on: | |||
| 18 | 18 | ||
| 19 | 19 | env: | |
| 20 | 20 | # Dummy values for svelte-check type generation | |
| 21 | - | TURSO_DATABASE_URL: 'libsql://dummy.turso.io' | |
| 22 | - | TURSO_AUTH_TOKEN: 'dummy-token' | |
| 23 | 21 | TURNSTILE_SECRET_KEY: '1x0000000000000000000000000000000AA' | |
| 24 | 22 | PUBLIC_TURNSTILE_SITE_KEY: '1x00000000000000000000AA' | |
| 25 | 23 | PUBLIC_DEFAULT_CHAT_ROOM_ID: '00000000-0000-0000-0000-000000000001' | |
| @@ -18,8 +18,6 @@ on: | |||||
| 18 | 18 | ||||
| 19 | env: | 19 | env: | ||
| 20 | # Dummy values for svelte-check type generation | 20 | # Dummy values for svelte-check type generation | ||
| 21 | - | TURSO_DATABASE_URL: 'libsql://dummy.turso.io' | |||
| 22 | - | TURSO_AUTH_TOKEN: 'dummy-token' | |||
| 23 | TURNSTILE_SECRET_KEY: '1x0000000000000000000000000000000AA' | 21 | TURNSTILE_SECRET_KEY: '1x0000000000000000000000000000000AA' | ||
| 24 | PUBLIC_TURNSTILE_SITE_KEY: '1x00000000000000000000AA' | 22 | PUBLIC_TURNSTILE_SITE_KEY: '1x00000000000000000000AA' | ||
| 25 | PUBLIC_DEFAULT_CHAT_ROOM_ID: '00000000-0000-0000-0000-000000000001' | 23 | PUBLIC_DEFAULT_CHAT_ROOM_ID: '00000000-0000-0000-0000-000000000001' | ||
M Dockerfile +4 -11
| @@ -8,25 +8,18 @@ RUN pnpm fetch | |||
| 8 | 8 | COPY . . | |
| 9 | 9 | RUN pnpm install --frozen-lockfile --offline && pnpm run build | |
| 10 | 10 | ||
| 11 | - | FROM node:22-alpine AS deps | |
| 12 | - | WORKDIR /app | |
| 13 | - | ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 | |
| 14 | - | RUN corepack enable | |
| 15 | - | COPY package.json pnpm-lock.yaml ./ | |
| 16 | - | RUN pnpm install --prod --frozen-lockfile --ignore-scripts | |
| 17 | - | ||
| 11 | + | # Every dependency is bundled into build/, so the runtime image needs no node_modules | |
| 18 | 12 | FROM node:22-alpine | |
| 19 | 13 | WORKDIR /app | |
| 20 | 14 | ENV NODE_ENV=production \ | |
| 21 | 15 | HOST=0.0.0.0 \ | |
| 22 | 16 | PORT=3000 | |
| 23 | 17 | ||
| 24 | - | COPY --from=deps /app/node_modules ./node_modules | |
| 25 | 18 | COPY --from=build /app/build ./build | |
| 26 | - | COPY package.json ./ | |
| 19 | + | COPY package.json server.js ./ | |
| 27 | 20 | ||
| 28 | 21 | USER node | |
| 29 | 22 | EXPOSE 3000 | |
| 30 | 23 | HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ | |
| 31 | - | CMD node -e "fetch('http://127.0.0.1:3000/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" | |
| 32 | - | CMD ["node", "build"] | |
| 24 | + | CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" | |
| 25 | + | CMD ["node", "server.js"] | |
| @@ -8,25 +8,18 @@ RUN pnpm fetch | |||||
| 8 | COPY . . | 8 | COPY . . | ||
| 9 | RUN pnpm install --frozen-lockfile --offline && pnpm run build | 9 | RUN pnpm install --frozen-lockfile --offline && pnpm run build | ||
| 10 | 10 | ||||
| 11 | - | FROM node:22-alpine AS deps | 11 | + | # Every dependency is bundled into build/, so the runtime image needs no node_modules |
| 12 | - | WORKDIR /app | |||
| 13 | - | ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 | |||
| 14 | - | RUN corepack enable | |||
| 15 | - | COPY package.json pnpm-lock.yaml ./ | |||
| 16 | - | RUN pnpm install --prod --frozen-lockfile --ignore-scripts | |||
| 17 | - | ||||
| 18 | FROM node:22-alpine | 12 | FROM node:22-alpine | ||
| 19 | WORKDIR /app | 13 | WORKDIR /app | ||
| 20 | ENV NODE_ENV=production \ | 14 | ENV NODE_ENV=production \ | ||
| 21 | HOST=0.0.0.0 \ | 15 | HOST=0.0.0.0 \ | ||
| 22 | PORT=3000 | 16 | PORT=3000 | ||
| 23 | 17 | ||||
| 24 | - | COPY --from=deps /app/node_modules ./node_modules | |||
| 25 | COPY --from=build /app/build ./build | 18 | COPY --from=build /app/build ./build | ||
| 26 | - | COPY package.json ./ | 19 | + | COPY package.json server.js ./ |
| 27 | 20 | ||||
| 28 | USER node | 21 | USER node | ||
| 29 | EXPOSE 3000 | 22 | EXPOSE 3000 | ||
| 30 | HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ | 23 | HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ | ||
| 31 | - | CMD node -e "fetch('http://127.0.0.1:3000/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" | 24 | + | CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" |
| 32 | - | CMD ["node", "build"] | 25 | + | CMD ["node", "server.js"] |
M package.json +13 -25
| @@ -16,15 +16,12 @@ | |||
| 16 | 16 | "format": "prettier --write .", | |
| 17 | 17 | "lint": "prettier --check . && eslint .", | |
| 18 | 18 | "test:unit": "vitest", | |
| 19 | - | "test": "npm run test:unit -- --run", | |
| 20 | - | "db:generate": "npx drizzle-kit generate", | |
| 21 | - | "db:push": "npx drizzle-kit push", | |
| 22 | - | "db:push-prod": "bash -c 'source .env && TURSO_DATABASE_URL=\"$TURSO_DATABASE_URL_PROD\" TURSO_AUTH_TOKEN=\"$TURSO_AUTH_TOKEN_PROD\" npx drizzle-kit push'", | |
| 23 | - | "db:flush": "tsx utils/db-flush.ts" | |
| 19 | + | "test": "npm run test:unit -- --run" | |
| 24 | 20 | }, | |
| 25 | 21 | "devDependencies": { | |
| 26 | 22 | "@eslint/compat": "^2.1.1", | |
| 27 | 23 | "@eslint/js": "^10.0.1", | |
| 24 | + | "@lucide/svelte": "^0.564.0", | |
| 28 | 25 | "@sveltejs/adapter-node": "^5.5.7", | |
| 29 | 26 | "@sveltejs/kit": "^2.70.3", | |
| 30 | 27 | "@sveltejs/vite-plugin-svelte": "^6.2.4", | |
| @@ -34,15 +31,23 @@ | |||
| 34 | 31 | "@tailwindcss/vite": "^4.3.3", | |
| 35 | 32 | "@types/node": "^25.9.9", | |
| 36 | 33 | "autoprefixer": "^10.6.1", | |
| 37 | - | "drizzle-kit": "^0.31.11", | |
| 34 | + | "convex": "^1.46.0", | |
| 35 | + | "convex-helpers": "^0.1.128", | |
| 36 | + | "convex-svelte": "^0.0.12", | |
| 38 | 37 | "eslint": "^10.12.0", | |
| 39 | 38 | "eslint-config-prettier": "^10.1.8", | |
| 40 | 39 | "eslint-plugin-svelte": "^3.23.1", | |
| 41 | 40 | "globals": "^17.13.0", | |
| 42 | 41 | "jsdom": "^28.1.0", | |
| 42 | + | "pino": "^10.4.0", | |
| 43 | 43 | "prettier": "^3.9.10", | |
| 44 | 44 | "prettier-plugin-svelte": "^3.5.2", | |
| 45 | 45 | "prettier-plugin-tailwindcss": "^0.7.4", | |
| 46 | + | "rehype": "^13.0.2", | |
| 47 | + | "rehype-sanitize": "^6.0.0", | |
| 48 | + | "rehype-stringify": "^10.0.1", | |
| 49 | + | "remark-parse": "^11.0.0", | |
| 50 | + | "remark-rehype": "^11.1.2", | |
| 46 | 51 | "svelte": "^5.57.2", | |
| 47 | 52 | "svelte-check": "^4.7.6", | |
| 48 | 53 | "tailwindcss": "^4.3.3", | |
| @@ -51,26 +56,9 @@ | |||
| 51 | 56 | "tw-animate-css": "^1.4.0", | |
| 52 | 57 | "typescript": "^5.9.3", | |
| 53 | 58 | "typescript-eslint": "^8.71.1", | |
| 54 | - | "vite": "^7.3.7", | |
| 55 | - | "vitest": "^4.1.11" | |
| 56 | - | }, | |
| 57 | - | "dependencies": { | |
| 58 | - | "@libsql/client": "^0.17.4", | |
| 59 | - | "@lucide/svelte": "^0.564.0", | |
| 60 | - | "convex": "^1.46.0", | |
| 61 | - | "convex-helpers": "^0.1.128", | |
| 62 | - | "convex-svelte": "^0.0.12", | |
| 63 | - | "dotenv": "^17.4.2", | |
| 64 | - | "drizzle-orm": "^0.45.4", | |
| 65 | - | "pino": "^10.4.0", | |
| 66 | - | "pino-pretty": "^13.2.0", | |
| 67 | - | "rehype": "^13.0.2", | |
| 68 | - | "rehype-sanitize": "^6.0.0", | |
| 69 | - | "rehype-stringify": "^10.0.1", | |
| 70 | - | "remark-parse": "^11.0.0", | |
| 71 | - | "remark-rehype": "^11.1.2", | |
| 72 | 59 | "unified": "^11.0.5", | |
| 73 | - | "uuid": "^13.0.2", | |
| 60 | + | "vite": "^7.3.7", | |
| 61 | + | "vitest": "^4.1.11", | |
| 74 | 62 | "xp.css": "^0.2.6", | |
| 75 | 63 | "zod": "^4.6.6" | |
| 76 | 64 | }, | |
| @@ -16,15 +16,12 @@ | |||||
| 16 | "format": "prettier --write .", | 16 | "format": "prettier --write .", | ||
| 17 | "lint": "prettier --check . && eslint .", | 17 | "lint": "prettier --check . && eslint .", | ||
| 18 | "test:unit": "vitest", | 18 | "test:unit": "vitest", | ||
| 19 | - | "test": "npm run test:unit -- --run", | 19 | + | "test": "npm run test:unit -- --run" |
| 20 | - | "db:generate": "npx drizzle-kit generate", | |||
| 21 | - | "db:push": "npx drizzle-kit push", | |||
| 22 | - | "db:push-prod": "bash -c 'source .env && TURSO_DATABASE_URL=\"$TURSO_DATABASE_URL_PROD\" TURSO_AUTH_TOKEN=\"$TURSO_AUTH_TOKEN_PROD\" npx drizzle-kit push'", | |||
| 23 | - | "db:flush": "tsx utils/db-flush.ts" | |||
| 24 | }, | 20 | }, | ||
| 25 | "devDependencies": { | 21 | "devDependencies": { | ||
| 26 | "@eslint/compat": "^2.1.1", | 22 | "@eslint/compat": "^2.1.1", | ||
| 27 | "@eslint/js": "^10.0.1", | 23 | "@eslint/js": "^10.0.1", | ||
| 24 | + | "@lucide/svelte": "^0.564.0", | |||
| 28 | "@sveltejs/adapter-node": "^5.5.7", | 25 | "@sveltejs/adapter-node": "^5.5.7", | ||
| 29 | "@sveltejs/kit": "^2.70.3", | 26 | "@sveltejs/kit": "^2.70.3", | ||
| 30 | "@sveltejs/vite-plugin-svelte": "^6.2.4", | 27 | "@sveltejs/vite-plugin-svelte": "^6.2.4", | ||
| @@ -34,15 +31,23 @@ | |||||
| 34 | "@tailwindcss/vite": "^4.3.3", | 31 | "@tailwindcss/vite": "^4.3.3", | ||
| 35 | "@types/node": "^25.9.9", | 32 | "@types/node": "^25.9.9", | ||
| 36 | "autoprefixer": "^10.6.1", | 33 | "autoprefixer": "^10.6.1", | ||
| 37 | - | "drizzle-kit": "^0.31.11", | 34 | + | "convex": "^1.46.0", |
| 35 | + | "convex-helpers": "^0.1.128", | |||
| 36 | + | "convex-svelte": "^0.0.12", | |||
| 38 | "eslint": "^10.12.0", | 37 | "eslint": "^10.12.0", | ||
| 39 | "eslint-config-prettier": "^10.1.8", | 38 | "eslint-config-prettier": "^10.1.8", | ||
| 40 | "eslint-plugin-svelte": "^3.23.1", | 39 | "eslint-plugin-svelte": "^3.23.1", | ||
| 41 | "globals": "^17.13.0", | 40 | "globals": "^17.13.0", | ||
| 42 | "jsdom": "^28.1.0", | 41 | "jsdom": "^28.1.0", | ||
| 42 | + | "pino": "^10.4.0", | |||
| 43 | "prettier": "^3.9.10", | 43 | "prettier": "^3.9.10", | ||
| 44 | "prettier-plugin-svelte": "^3.5.2", | 44 | "prettier-plugin-svelte": "^3.5.2", | ||
| 45 | "prettier-plugin-tailwindcss": "^0.7.4", | 45 | "prettier-plugin-tailwindcss": "^0.7.4", | ||
| 46 | + | "rehype": "^13.0.2", | |||
| 47 | + | "rehype-sanitize": "^6.0.0", | |||
| 48 | + | "rehype-stringify": "^10.0.1", | |||
| 49 | + | "remark-parse": "^11.0.0", | |||
| 50 | + | "remark-rehype": "^11.1.2", | |||
| 46 | "svelte": "^5.57.2", | 51 | "svelte": "^5.57.2", | ||
| 47 | "svelte-check": "^4.7.6", | 52 | "svelte-check": "^4.7.6", | ||
| 48 | "tailwindcss": "^4.3.3", | 53 | "tailwindcss": "^4.3.3", | ||
| @@ -51,26 +56,9 @@ | |||||
| 51 | "tw-animate-css": "^1.4.0", | 56 | "tw-animate-css": "^1.4.0", | ||
| 52 | "typescript": "^5.9.3", | 57 | "typescript": "^5.9.3", | ||
| 53 | "typescript-eslint": "^8.71.1", | 58 | "typescript-eslint": "^8.71.1", | ||
| 54 | - | "vite": "^7.3.7", | |||
| 55 | - | "vitest": "^4.1.11" | |||
| 56 | - | }, | |||
| 57 | - | "dependencies": { | |||
| 58 | - | "@libsql/client": "^0.17.4", | |||
| 59 | - | "@lucide/svelte": "^0.564.0", | |||
| 60 | - | "convex": "^1.46.0", | |||
| 61 | - | "convex-helpers": "^0.1.128", | |||
| 62 | - | "convex-svelte": "^0.0.12", | |||
| 63 | - | "dotenv": "^17.4.2", | |||
| 64 | - | "drizzle-orm": "^0.45.4", | |||
| 65 | - | "pino": "^10.4.0", | |||
| 66 | - | "pino-pretty": "^13.2.0", | |||
| 67 | - | "rehype": "^13.0.2", | |||
| 68 | - | "rehype-sanitize": "^6.0.0", | |||
| 69 | - | "rehype-stringify": "^10.0.1", | |||
| 70 | - | "remark-parse": "^11.0.0", | |||
| 71 | - | "remark-rehype": "^11.1.2", | |||
| 72 | "unified": "^11.0.5", | 59 | "unified": "^11.0.5", | ||
| 73 | - | "uuid": "^13.0.2", | 60 | + | "vite": "^7.3.7", |
| 61 | + | "vitest": "^4.1.11", | |||
| 74 | "xp.css": "^0.2.6", | 62 | "xp.css": "^0.2.6", | ||
| 75 | "zod": "^4.6.6" | 63 | "zod": "^4.6.6" | ||
| 76 | }, | 64 | }, | ||
M pnpm-lock.yaml +43 -1055 generated file: collapsed
A server.js +25 -0
| @@ -0,0 +1,25 @@ | |||
| 1 | + | // Production entry: adds cache headers for static assets, then delegates to SvelteKit. | |
| 2 | + | // Copied into the image next to build/ (see Dockerfile). | |
| 3 | + | import http from 'node:http'; | |
| 4 | + | import { handler } from './build/handler.js'; | |
| 5 | + | ||
| 6 | + | const host = process.env.HOST ?? '0.0.0.0'; | |
| 7 | + | const port = Number(process.env.PORT ?? 3000); | |
| 8 | + | ||
| 9 | + | const cacheRules = [ | |
| 10 | + | [/^\/fonts\//, 'public, max-age=31536000, immutable'], | |
| 11 | + | [/^\/(aim|desktop|sounds|smileys)\/|^\/favicon\.png$/, 'public, max-age=2592000'] | |
| 12 | + | ]; | |
| 13 | + | ||
| 14 | + | const server = http.createServer((req, res) => { | |
| 15 | + | const path = req.url?.split('?')[0] ?? '/'; | |
| 16 | + | const rule = cacheRules.find(([pattern]) => pattern.test(path)); | |
| 17 | + | if (rule) res.setHeader('Cache-Control', rule[1]); | |
| 18 | + | handler(req, res); | |
| 19 | + | }); | |
| 20 | + | ||
| 21 | + | server.listen(port, host, () => console.log(`Listening on http://${host}:${port}`)); | |
| 22 | + | ||
| 23 | + | for (const signal of ['SIGINT', 'SIGTERM']) { | |
| 24 | + | process.on(signal, () => server.close(() => process.exit(0))); | |
| 25 | + | } | |
| @@ -0,0 +1,25 @@ | |||||
| 1 | + | // Production entry: adds cache headers for static assets, then delegates to SvelteKit. | |||
| 2 | + | // Copied into the image next to build/ (see Dockerfile). | |||
| 3 | + | import http from 'node:http'; | |||
| 4 | + | import { handler } from './build/handler.js'; | |||
| 5 | + | ||||
| 6 | + | const host = process.env.HOST ?? '0.0.0.0'; | |||
| 7 | + | const port = Number(process.env.PORT ?? 3000); | |||
| 8 | + | ||||
| 9 | + | const cacheRules = [ | |||
| 10 | + | [/^\/fonts\//, 'public, max-age=31536000, immutable'], | |||
| 11 | + | [/^\/(aim|desktop|sounds|smileys)\/|^\/favicon\.png$/, 'public, max-age=2592000'] | |||
| 12 | + | ]; | |||
| 13 | + | ||||
| 14 | + | const server = http.createServer((req, res) => { | |||
| 15 | + | const path = req.url?.split('?')[0] ?? '/'; | |||
| 16 | + | const rule = cacheRules.find(([pattern]) => pattern.test(path)); | |||
| 17 | + | if (rule) res.setHeader('Cache-Control', rule[1]); | |||
| 18 | + | handler(req, res); | |||
| 19 | + | }); | |||
| 20 | + | ||||
| 21 | + | server.listen(port, host, () => console.log(`Listening on http://${host}:${port}`)); | |||
| 22 | + | ||||
| 23 | + | for (const signal of ['SIGINT', 'SIGTERM']) { | |||
| 24 | + | process.on(signal, () => server.close(() => process.exit(0))); | |||
| 25 | + | } | |||
M src/hooks.server.ts +5 -4
| @@ -85,7 +85,6 @@ export const handle: Handle = async ({ event, resolve }) => { | |||
| 85 | 85 | // CSRF protection: validate Origin header for state-changing requests | |
| 86 | 86 | if (event.request.method !== 'GET' && event.request.method !== 'HEAD') { | |
| 87 | 87 | const origin = event.request.headers.get('origin'); | |
| 88 | - | const host = event.request.headers.get('host'); | |
| 89 | 88 | ||
| 90 | 89 | if (!origin) { | |
| 91 | 90 | // Browsers always send Origin on state-changing requests. | |
| @@ -98,11 +97,12 @@ export const handle: Handle = async ({ event, resolve }) => { | |||
| 98 | 97 | } | |
| 99 | 98 | ||
| 100 | 99 | try { | |
| 100 | + | // event.url comes from ORIGIN in production, so proxies rewriting Host do not matter | |
| 101 | 101 | const originUrl = new URL(origin); | |
| 102 | - | if (originUrl.host !== host) { | |
| 102 | + | if (originUrl.origin !== event.url.origin) { | |
| 103 | 103 | log.warn('CSRF check failed - origin mismatch', { | |
| 104 | - | origin: originUrl.host, | |
| 105 | - | host | |
| 104 | + | origin: originUrl.origin, | |
| 105 | + | expected: event.url.origin | |
| 106 | 106 | }); | |
| 107 | 107 | return new Response('Forbidden', { status: 403 }); | |
| 108 | 108 | } | |
| @@ -144,6 +144,7 @@ export const handle: Handle = async ({ event, resolve }) => { | |||
| 144 | 144 | // Public routes that don't require authentication | |
| 145 | 145 | const publicRoutes = [ | |
| 146 | 146 | '/', | |
| 147 | + | '/api/health', | |
| 147 | 148 | '/api/session/login', | |
| 148 | 149 | '/api/session/validate', | |
| 149 | 150 | '/api/register', | |
| @@ -85,7 +85,6 @@ export const handle: Handle = async ({ event, resolve }) => { | |||||
| 85 | // CSRF protection: validate Origin header for state-changing requests | 85 | // CSRF protection: validate Origin header for state-changing requests | ||
| 86 | if (event.request.method !== 'GET' && event.request.method !== 'HEAD') { | 86 | if (event.request.method !== 'GET' && event.request.method !== 'HEAD') { | ||
| 87 | const origin = event.request.headers.get('origin'); | 87 | const origin = event.request.headers.get('origin'); | ||
| 88 | - | const host = event.request.headers.get('host'); | |||
| 89 | 88 | ||||
| 90 | if (!origin) { | 89 | if (!origin) { | ||
| 91 | // Browsers always send Origin on state-changing requests. | 90 | // Browsers always send Origin on state-changing requests. | ||
| @@ -98,11 +97,12 @@ export const handle: Handle = async ({ event, resolve }) => { | |||||
| 98 | } | 97 | } | ||
| 99 | 98 | ||||
| 100 | try { | 99 | try { | ||
| 100 | + | // event.url comes from ORIGIN in production, so proxies rewriting Host do not matter | |||
| 101 | const originUrl = new URL(origin); | 101 | const originUrl = new URL(origin); | ||
| 102 | - | if (originUrl.host !== host) { | 102 | + | if (originUrl.origin !== event.url.origin) { |
| 103 | log.warn('CSRF check failed - origin mismatch', { | 103 | log.warn('CSRF check failed - origin mismatch', { | ||
| 104 | - | origin: originUrl.host, | 104 | + | origin: originUrl.origin, |
| 105 | - | host | 105 | + | expected: event.url.origin |
| 106 | }); | 106 | }); | ||
| 107 | return new Response('Forbidden', { status: 403 }); | 107 | return new Response('Forbidden', { status: 403 }); | ||
| 108 | } | 108 | } | ||
| @@ -144,6 +144,7 @@ export const handle: Handle = async ({ event, resolve }) => { | |||||
| 144 | // Public routes that don't require authentication | 144 | // Public routes that don't require authentication | ||
| 145 | const publicRoutes = [ | 145 | const publicRoutes = [ | ||
| 146 | '/', | 146 | '/', | ||
| 147 | + | '/api/health', | |||
| 147 | '/api/session/login', | 148 | '/api/session/login', | ||
| 148 | '/api/session/validate', | 149 | '/api/session/validate', | ||
| 149 | '/api/register', | 150 | '/api/register', | ||
M src/lib/api/rate-limiter.ts +2 -1
| @@ -98,7 +98,6 @@ function isRateLimited( | |||
| 98 | 98 | } | |
| 99 | 99 | ||
| 100 | 100 | export async function handleRateLimit(event: RequestEvent): Promise<Response | null> { | |
| 101 | - | const ip = event.getClientAddress(); | |
| 102 | 101 | const url = new URL(event.request.url); | |
| 103 | 102 | const isPublic = url.searchParams.get('public') === 'true'; | |
| 104 | 103 | ||
| @@ -107,6 +106,8 @@ export async function handleRateLimit(event: RequestEvent): Promise<Response | n | |||
| 107 | 106 | return null; | |
| 108 | 107 | } | |
| 109 | 108 | ||
| 109 | + | const ip = event.getClientAddress(); | |
| 110 | + | ||
| 110 | 111 | const endpointType = getEndpointType(url.pathname, isPublic); | |
| 111 | 112 | const { limited, retryAfter } = isRateLimited(ip, endpointType); | |
| 112 | 113 | ||
| @@ -98,7 +98,6 @@ function isRateLimited( | |||||
| 98 | } | 98 | } | ||
| 99 | 99 | ||||
| 100 | export async function handleRateLimit(event: RequestEvent): Promise<Response | null> { | 100 | export async function handleRateLimit(event: RequestEvent): Promise<Response | null> { | ||
| 101 | - | const ip = event.getClientAddress(); | |||
| 102 | const url = new URL(event.request.url); | 101 | const url = new URL(event.request.url); | ||
| 103 | const isPublic = url.searchParams.get('public') === 'true'; | 102 | const isPublic = url.searchParams.get('public') === 'true'; | ||
| 104 | 103 | ||||
| @@ -107,6 +106,8 @@ export async function handleRateLimit(event: RequestEvent): Promise<Response | n | |||||
| 107 | return null; | 106 | return null; | ||
| 108 | } | 107 | } | ||
| 109 | 108 | ||||
| 109 | + | const ip = event.getClientAddress(); | |||
| 110 | + | ||||
| 110 | const endpointType = getEndpointType(url.pathname, isPublic); | 111 | const endpointType = getEndpointType(url.pathname, isPublic); | ||
| 111 | const { limited, retryAfter } = isRateLimited(ip, endpointType); | 112 | const { limited, retryAfter } = isRateLimited(ip, endpointType); | ||
| 112 | 113 | ||||
M src/lib/utils/logger.server.ts +2 -2
| @@ -1,9 +1,9 @@ | |||
| 1 | 1 | import pino from 'pino'; | |
| 2 | + | import { dev } from '$app/environment'; | |
| 2 | 3 | ||
| 3 | 4 | // JSON logs on stdout, collected by the container runtime | |
| 4 | - | // Use 'debug' level - filtering happens at log time based on env | |
| 5 | 5 | const logger = pino({ | |
| 6 | - | level: 'debug', | |
| 6 | + | level: process.env.LOG_LEVEL ?? (dev ? 'debug' : 'info'), | |
| 7 | 7 | formatters: { | |
| 8 | 8 | level: (label) => ({ level: label }) | |
| 9 | 9 | } | |
| @@ -1,9 +1,9 @@ | |||||
| 1 | import pino from 'pino'; | 1 | import pino from 'pino'; | ||
| 2 | + | import { dev } from '$app/environment'; | |||
| 2 | 3 | ||||
| 3 | // JSON logs on stdout, collected by the container runtime | 4 | // JSON logs on stdout, collected by the container runtime | ||
| 4 | - | // Use 'debug' level - filtering happens at log time based on env | |||
| 5 | const logger = pino({ | 5 | const logger = pino({ | ||
| 6 | - | level: 'debug', | 6 | + | level: process.env.LOG_LEVEL ?? (dev ? 'debug' : 'info'), |
| 7 | formatters: { | 7 | formatters: { | ||
| 8 | level: (label) => ({ level: label }) | 8 | level: (label) => ({ level: label }) | ||
| 9 | } | 9 | } | ||
A src/routes/api/health/+server.ts +3 -0
| @@ -0,0 +1,3 @@ | |||
| 1 | + | import type { RequestHandler } from './$types'; | |
| 2 | + | ||
| 3 | + | export const GET: RequestHandler = () => new Response('ok'); | |
| @@ -0,0 +1,3 @@ | |||||
| 1 | + | import type { RequestHandler } from './$types'; | |||
| 2 | + | ||||
| 3 | + | export const GET: RequestHandler = () => new Response('ok'); | |||