From a42dbcd452c2a0987d744dd52be44fb2c1a0679b Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Sat, 25 Apr 2026 15:31:06 +0200 Subject: [PATCH] feat(security): harden auth, oauth, and printer endpoints Add stricter URL and redirect validation, endpoint rate limiting, safer defaults for printer and compose config, and CSP protections across server and API surfaces. Made-with: Cursor --- .env.example | 7 +- compose.dev.yml | 26 +- compose.yml | 40 ++- package.json | 5 +- pnpm-lock.yaml | 71 ++-- src/integrations/auth/config.ts | 77 +++- src/integrations/orpc/client.ts | 3 - src/integrations/orpc/context.ts | 24 -- src/integrations/orpc/rate-limit.ts | 44 +++ src/integrations/orpc/router/ai.ts | 87 +++-- src/integrations/orpc/router/jobs.ts | 6 +- src/integrations/orpc/router/printer.ts | 43 ++- src/integrations/orpc/router/resume.ts | 59 +-- src/integrations/orpc/services/ai.ts | 67 +++- src/integrations/orpc/services/printer.ts | 420 ++++++++++++++++------ src/integrations/orpc/services/resume.ts | 100 +++--- src/routeTree.gen.ts | 21 ++ src/routes/[.]well-known/$.ts | 12 + src/routes/api/auth.$.ts | 76 +++- src/routes/printer/$resumeId.tsx | 31 +- src/schema/resume/data.ts | 2 +- src/schema/resume/sample.ts | 2 +- src/server.ts | 39 +- src/utils/env.ts | 4 + src/utils/error-message.ts | 36 ++ src/utils/url-security.ts | 99 +++++ vite.config.ts | 16 +- 27 files changed, 1041 insertions(+), 376 deletions(-) create mode 100644 src/integrations/orpc/rate-limit.ts create mode 100644 src/routes/[.]well-known/$.ts create mode 100644 src/utils/error-message.ts create mode 100644 src/utils/url-security.ts diff --git a/.env.example b/.env.example index f875e3e34..f27227377 100644 --- a/.env.example +++ b/.env.example @@ -10,7 +10,7 @@ APP_URL="http://localhost:3000" PRINTER_APP_URL="http://host.docker.internal:3000" # --- Printer --- -PRINTER_ENDPOINT="ws://localhost:4000?token=1234567890" +PRINTER_ENDPOINT="ws://localhost:4000?token=change-me" # --- Database (PostgreSQL) --- DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres" @@ -40,6 +40,11 @@ OAUTH_CLIENT_ID="" OAUTH_CLIENT_SECRET="" OAUTH_DISCOVERY_URL="" OAUTH_AUTHORIZATION_URL="" +OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS="" + +# AI provider base URL allowlist (optional, comma-separated hosts/origins) +# Example: api.openai.com,https://gateway.ai.vercel.com +AI_ALLOWED_BASE_URLS="" # --- Email (optional) --- # If all keys are disabled, the app logs the email to be sent to the console instead. diff --git a/compose.dev.yml b/compose.dev.yml index 67fa6a416..59a1ce1dd 100644 --- a/compose.dev.yml +++ b/compose.dev.yml @@ -35,7 +35,7 @@ services: retries: 3 browserless: - image: ghcr.io/browserless/chromium:latest + image: ${BROWSERLESS_IMAGE:-ghcr.io/browserless/chromium:latest} restart: unless-stopped ports: - "4000:3000" @@ -43,9 +43,11 @@ services: QUEUED: 10 HEALTH: "true" CONCURRENT: 5 - TOKEN: "1234567890" + TOKEN: ${BROWSERLESS_TOKEN:-change-me} + extra_hosts: + - "host.docker.internal:host-gateway" healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:3000/pressure?token=1234567890"] + test: ["CMD-SHELL", 'curl -f "http://localhost:3000/pressure?token=${BROWSERLESS_TOKEN:-change-me}"'] interval: 10s timeout: 5s retries: 10 @@ -57,6 +59,8 @@ services: restart: unless-stopped ports: - "9222:9222" + extra_hosts: + - "host.docker.internal:host-gateway" seaweedfs: image: chrislusf/seaweedfs:latest @@ -118,15 +122,13 @@ services: - .env environment: PRINTER_APP_URL: http://web_app:3000 - PRINTER_ENDPOINT: ws://browserless:3000?token=1234567890 + PRINTER_ENDPOINT: ws://browserless:3000?token=${BROWSERLESS_TOKEN:-change-me} DATABASE_URL: postgresql://postgres:postgres@postgres:5432/postgres SMTP_HOST: mailpit S3_ENDPOINT: http://seaweedfs:8333 CHOKIDAR_INTERVAL: 100 WATCHPACK_POLLING: "true" CHOKIDAR_USEPOLLING: "true" - extra_hosts: - - "host.docker.internal:host-gateway" volumes: - ./:/app - web_app_data:/app/data @@ -143,13 +145,21 @@ services: condition: service_healthy seaweedfs_create_bucket: condition: service_completed_successfully + extra_hosts: + - "host.docker.internal:host-gateway" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"] + start_period: 10s + interval: 30s + timeout: 10s + retries: 3 volumes: mailpit_data: postgres_data: seaweedfs_data: web_app_data: - web_app_node_modules: web_app_pnpm_store: - web_app_tanstack_cache: web_app_nitro_cache: + web_app_node_modules: + web_app_tanstack_cache: diff --git a/compose.yml b/compose.yml index 049beb15d..3632efa81 100644 --- a/compose.yml +++ b/compose.yml @@ -4,6 +4,8 @@ services: postgres: image: postgres:latest restart: unless-stopped + networks: + - data_network environment: - POSTGRES_DB=postgres - POSTGRES_USER=postgres @@ -18,15 +20,17 @@ services: retries: 3 browserless: - image: ghcr.io/browserless/chromium:latest + image: ${BROWSERLESS_IMAGE:-ghcr.io/browserless/chromium:latest} restart: unless-stopped + networks: + - printer_network environment: - QUEUED=10 - HEALTH=true - CONCURRENT=5 - - TOKEN=1234567890 + - TOKEN=${BROWSERLESS_TOKEN:-change-me} healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:3000/pressure?token=1234567890"] + test: ["CMD-SHELL", 'curl -f "http://localhost:3000/pressure?token=${BROWSERLESS_TOKEN:-change-me}"'] interval: 10s timeout: 5s retries: 10 @@ -36,6 +40,8 @@ services: # chrome: # image: chromedp/headless-shell:latest # restart: unless-stopped + # networks: + # - printer_network # ports: # - "9222:9222" @@ -43,6 +49,8 @@ services: image: chrislusf/seaweedfs:latest restart: unless-stopped command: server -s3 -filer -dir=/data -ip=0.0.0.0 + networks: + - storage_network environment: - AWS_ACCESS_KEY_ID=seaweedfs - AWS_SECRET_ACCESS_KEY=seaweedfs @@ -65,21 +73,32 @@ services: mc mb seaweedfs/reactive-resume; exit 0; " + networks: + - storage_network depends_on: seaweedfs: condition: service_healthy reactive_resume: - image: amruthpillai/reactive-resume:latest + # image: amruthpillai/reactive-resume:latest # image: ghcr.io/amruthpillai/reactive-resume:latest + build: + context: . + dockerfile: Dockerfile + networks: + - data_network + - printer_network + - storage_network + ports: + - "3000:3000" environment: # Server - TZ=Etc/UTC - NODE_ENV=production - APP_URL=http://localhost:3000 - - PRINTER_APP_URL=http://reactive_resume:3000 + - PRINTER_APP_URL=http://host.docker.internal:3000 # Printer - - PRINTER_ENDPOINT=ws://browserless:3000?token=1234567890 + - PRINTER_ENDPOINT=ws://browserless:3000?token=${BROWSERLESS_TOKEN:-change-me} # - PRINTER_ENDPOINT=http://chrome:9222 # Or, if you're using chromedp/headless-shell # Database - DATABASE_URL=postgresql://postgres:postgres@postgres:5432/postgres @@ -93,8 +112,6 @@ services: - S3_FORCE_PATH_STYLE=true volumes: - reactive_resume_data:/app/data - ports: - - "3000:3000" depends_on: postgres: condition: service_healthy @@ -102,6 +119,8 @@ services: condition: service_healthy seaweedfs_create_bucket: condition: service_completed_successfully + extra_hosts: + - "host.docker.internal:host-gateway" healthcheck: test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"] start_period: 10s @@ -113,3 +132,8 @@ volumes: postgres_data: seaweedfs_data: reactive_resume_data: + +networks: + data_network: + printer_network: + storage_network: diff --git a/package.json b/package.json index c22c00ff8..7b0a903ae 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "@modelcontextprotocol/sdk": "^1.29.0", "@monaco-editor/react": "4.8.0-rc.3", "@orpc/client": "^1.14.0", + "@orpc/experimental-ratelimit": "^1.14.0", "@orpc/json-schema": "^1.14.0", "@orpc/openapi": "^1.14.0", "@orpc/server": "^1.14.0", @@ -64,7 +65,7 @@ "@phosphor-icons/web": "^2.1.2", "@sindresorhus/slugify": "^3.0.0", "@t3-oss/env-core": "^0.13.11", - "@tanstack/react-query": "^5.100.1", + "@tanstack/react-query": "^5.100.2", "@tanstack/react-router": "^1.168.24", "@tanstack/react-router-ssr-query": "^1.166.11", "@tanstack/react-start": "^1.167.48", @@ -108,7 +109,7 @@ "react-resizable-panels": "^4.10.0", "react-window": "^2.2.7", "react-zoom-pan-pinch": "^4.0.3", - "shadcn": "^4.4.0", + "shadcn": "^4.5.0", "sharp": "^0.34.5", "sonner": "^2.0.7", "srvx": "^0.11.15", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8baa56c7d..15cc3d042 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,6 +80,9 @@ importers: '@orpc/client': specifier: ^1.14.0 version: 1.14.0(@opentelemetry/api@1.9.0) + '@orpc/experimental-ratelimit': + specifier: ^1.14.0 + version: 1.14.0(@opentelemetry/api@1.9.0)(crossws@0.4.5(srvx@0.11.15))(ws@8.20.0) '@orpc/json-schema': specifier: ^1.14.0 version: 1.14.0(@opentelemetry/api@1.9.0)(crossws@0.4.5(srvx@0.11.15))(ws@8.20.0) @@ -91,7 +94,7 @@ importers: version: 1.14.0(@opentelemetry/api@1.9.0)(crossws@0.4.5(srvx@0.11.15))(ws@8.20.0) '@orpc/tanstack-query': specifier: ^1.14.0 - version: 1.14.0(@opentelemetry/api@1.9.0)(@orpc/client@1.14.0(@opentelemetry/api@1.9.0))(@tanstack/query-core@5.100.1) + version: 1.14.0(@opentelemetry/api@1.9.0)(@orpc/client@1.14.0(@opentelemetry/api@1.9.0))(@tanstack/query-core@5.100.2) '@orpc/zod': specifier: ^1.14.0 version: 1.14.0(@opentelemetry/api@1.9.0)(@orpc/contract@1.14.0(@opentelemetry/api@1.9.0))(@orpc/server@1.14.0(@opentelemetry/api@1.9.0)(crossws@0.4.5(srvx@0.11.15))(ws@8.20.0))(crossws@0.4.5(srvx@0.11.15))(ws@8.20.0)(zod@4.3.6) @@ -108,14 +111,14 @@ importers: specifier: ^0.13.11 version: 0.13.11(zod@4.3.6) '@tanstack/react-query': - specifier: ^5.100.1 - version: 5.100.1(react@19.2.5) + specifier: ^5.100.2 + version: 5.100.2(react@19.2.5) '@tanstack/react-router': specifier: ^1.168.24 version: 1.168.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@tanstack/react-router-ssr-query': specifier: ^1.166.11 - version: 1.166.11(@tanstack/query-core@5.100.1)(@tanstack/react-query@5.100.1(react@19.2.5))(@tanstack/react-router@1.168.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@tanstack/router-core@1.168.16)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 1.166.11(@tanstack/query-core@5.100.2)(@tanstack/react-query@5.100.2(react@19.2.5))(@tanstack/react-router@1.168.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@tanstack/router-core@1.168.16)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@tanstack/react-start': specifier: ^1.167.48 version: 1.167.48(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.3))(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -240,8 +243,8 @@ importers: specifier: ^4.0.3 version: 4.0.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5) shadcn: - specifier: ^4.4.0 - version: 4.4.0(@types/node@25.6.0)(babel-plugin-macros@3.1.0) + specifier: ^4.5.0 + version: 4.5.0(@types/node@25.6.0)(babel-plugin-macros@3.1.0) sharp: specifier: ^0.34.5 version: 0.34.5 @@ -2080,6 +2083,14 @@ packages: '@orpc/contract@1.14.0': resolution: {integrity: sha512-FUxBNqWr6mOjI+w1JPzO/iHmR3M+GA53ivaxp+eOnQu7g3ZGKB0RS5gJ/oz3cGF1gvuIcCw9FVYKK/5tkB8I1Q==} + '@orpc/experimental-ratelimit@1.14.0': + resolution: {integrity: sha512-9RuGjwcyP8IYKEzjpYYTA7vbDNfrOdEIKBkkgB3MZoRoMajA/xv+CApqbkwpGbJa+I8UeZdnPjZ42PKZA/qVag==} + peerDependencies: + '@upstash/ratelimit': '>=2.0.7' + peerDependenciesMeta: + '@upstash/ratelimit': + optional: true + '@orpc/interop@1.14.0': resolution: {integrity: sha512-T4z8ehjks4rP8Z4XDObHBQjFiH61Wz8HlDaW4SR+GK0+u3h4l3+Fdwwxvm7ZWtY2zJEraqhaJhEHf90W7w50fA==} @@ -3445,11 +3456,11 @@ packages: resolution: {integrity: sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg==} engines: {node: '>=20.19'} - '@tanstack/query-core@5.100.1': - resolution: {integrity: sha512-awvQhOO/2TrSCHE5LKKsXcvvj6WSBncwEcMFCB/ez0Qs0b17iyyivoGArNV3HFfXryZwCpnb/olsaBBKrIbtSw==} + '@tanstack/query-core@5.100.2': + resolution: {integrity: sha512-HzzOC7xgSfGGzZ1gTsFZqYz6rxGg3tYF77nTPctin+wEYYLNMP7LjwPVFALEGNdjxkHvcewh1EM5ywixeukS4w==} - '@tanstack/react-query@5.100.1': - resolution: {integrity: sha512-UgWRLhQKprC37SsO6y1zRabOqDmM2gsdTNPbqTT35yl7kOOhwXU4nyfOiGHXPwoEFJV1IpSk85hjIFjNFWVpzw==} + '@tanstack/react-query@5.100.2': + resolution: {integrity: sha512-MvvzPcurtzVh4EcbsTfI1BL5GOfdi1S0dk/qhigEghW07MvcHUl/dhfc1FT8hPEquuMtUC+IIAxC0bdmSp/7kA==} peerDependencies: react: ^18 || ^19 @@ -6851,8 +6862,8 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - shadcn@4.4.0: - resolution: {integrity: sha512-0V1AjVktKwhK1e0ONXb9SeBoyJePH04iTSJeMjl9eROqjjyb8OoWYtWDI39UdBU3GzpUlFBJFohhB9c6fGOkQA==} + shadcn@4.5.0: + resolution: {integrity: sha512-ZpNOz7IMI5aezbMEWNxBvl2aJ1ek6NuAMqpL/FUnk5IuRxERl8ohYEnqqAmhPOcur8RbGuCoqTZLQ3Oi4Xkf8A==} hasBin: true sharp@0.34.5: @@ -9694,6 +9705,18 @@ snapshots: transitivePeerDependencies: - '@opentelemetry/api' + '@orpc/experimental-ratelimit@1.14.0(@opentelemetry/api@1.9.0)(crossws@0.4.5(srvx@0.11.15))(ws@8.20.0)': + dependencies: + '@orpc/client': 1.14.0(@opentelemetry/api@1.9.0) + '@orpc/server': 1.14.0(@opentelemetry/api@1.9.0)(crossws@0.4.5(srvx@0.11.15))(ws@8.20.0) + '@orpc/shared': 1.14.0(@opentelemetry/api@1.9.0) + '@orpc/standard-server': 1.14.0(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - '@opentelemetry/api' + - crossws + - fastify + - ws + '@orpc/interop@1.14.0': {} '@orpc/json-schema@1.14.0(@opentelemetry/api@1.9.0)(crossws@0.4.5(srvx@0.11.15))(ws@8.20.0)': @@ -9808,11 +9831,11 @@ snapshots: transitivePeerDependencies: - '@opentelemetry/api' - '@orpc/tanstack-query@1.14.0(@opentelemetry/api@1.9.0)(@orpc/client@1.14.0(@opentelemetry/api@1.9.0))(@tanstack/query-core@5.100.1)': + '@orpc/tanstack-query@1.14.0(@opentelemetry/api@1.9.0)(@orpc/client@1.14.0(@opentelemetry/api@1.9.0))(@tanstack/query-core@5.100.2)': dependencies: '@orpc/client': 1.14.0(@opentelemetry/api@1.9.0) '@orpc/shared': 1.14.0(@opentelemetry/api@1.9.0) - '@tanstack/query-core': 5.100.1 + '@tanstack/query-core': 5.100.2 transitivePeerDependencies: - '@opentelemetry/api' @@ -10934,19 +10957,19 @@ snapshots: '@tanstack/history@1.161.6': {} - '@tanstack/query-core@5.100.1': {} + '@tanstack/query-core@5.100.2': {} - '@tanstack/react-query@5.100.1(react@19.2.5)': + '@tanstack/react-query@5.100.2(react@19.2.5)': dependencies: - '@tanstack/query-core': 5.100.1 + '@tanstack/query-core': 5.100.2 react: 19.2.5 - '@tanstack/react-router-ssr-query@1.166.11(@tanstack/query-core@5.100.1)(@tanstack/react-query@5.100.1(react@19.2.5))(@tanstack/react-router@1.168.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@tanstack/router-core@1.168.16)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@tanstack/react-router-ssr-query@1.166.11(@tanstack/query-core@5.100.2)(@tanstack/react-query@5.100.2(react@19.2.5))(@tanstack/react-router@1.168.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@tanstack/router-core@1.168.16)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@tanstack/query-core': 5.100.1 - '@tanstack/react-query': 5.100.1(react@19.2.5) + '@tanstack/query-core': 5.100.2 + '@tanstack/react-query': 5.100.2(react@19.2.5) '@tanstack/react-router': 1.168.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@tanstack/router-ssr-query-core': 1.167.1(@tanstack/query-core@5.100.1)(@tanstack/router-core@1.168.16) + '@tanstack/router-ssr-query-core': 1.167.1(@tanstack/query-core@5.100.2)(@tanstack/router-core@1.168.16) react: 19.2.5 react-dom: 19.2.5(react@19.2.5) transitivePeerDependencies: @@ -11074,9 +11097,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-ssr-query-core@1.167.1(@tanstack/query-core@5.100.1)(@tanstack/router-core@1.168.16)': + '@tanstack/router-ssr-query-core@1.167.1(@tanstack/query-core@5.100.2)(@tanstack/router-core@1.168.16)': dependencies: - '@tanstack/query-core': 5.100.1 + '@tanstack/query-core': 5.100.2 '@tanstack/router-core': 1.168.16 '@tanstack/router-utils@1.161.7': @@ -14401,7 +14424,7 @@ snapshots: setprototypeof@1.2.0: {} - shadcn@4.4.0(@types/node@25.6.0)(babel-plugin-macros@3.1.0): + shadcn@4.5.0(@types/node@25.6.0)(babel-plugin-macros@3.1.0): dependencies: '@babel/core': 7.29.0 '@babel/parser': 7.29.2 diff --git a/src/integrations/auth/config.ts b/src/integrations/auth/config.ts index fe5e9c02c..114bf2376 100644 --- a/src/integrations/auth/config.ts +++ b/src/integrations/auth/config.ts @@ -5,9 +5,10 @@ import { drizzleAdapter } from "@better-auth/drizzle-adapter"; import { dash } from "@better-auth/infra"; import { oauthProvider } from "@better-auth/oauth-provider"; import { passkey } from "@better-auth/passkey"; -import { BetterAuthError, betterAuth } from "better-auth"; +import { APIError, BetterAuthError, betterAuth } from "better-auth"; +import { createAuthMiddleware } from "better-auth/api"; import { verifyAccessToken } from "better-auth/oauth2"; -import { admin, jwt, openAPI, type GenericOAuthConfig } from "better-auth/plugins"; +import { admin, jwt, type GenericOAuthConfig } from "better-auth/plugins"; import { genericOAuth } from "better-auth/plugins/generic-oauth"; import { twoFactor } from "better-auth/plugins/two-factor"; import { username } from "better-auth/plugins/username"; @@ -16,6 +17,7 @@ import { eq, or } from "drizzle-orm"; import { env } from "@/utils/env"; import { hashPassword, verifyPassword } from "@/utils/password"; import { generateId, toUsername } from "@/utils/string"; +import { isPrivateOrLoopbackHost, parseAllowedHostList, parseUrl } from "@/utils/url-security"; import { schema } from "../drizzle"; import { db } from "../drizzle/client"; @@ -51,8 +53,9 @@ function isCustomOAuthProviderEnabled() { } function getTrustedOrigins(): string[] { + const normalizeOrigin = (origin: string): string => origin.replace(/\/$/, ""); const appUrl = new URL(env.APP_URL); - const trustedOrigins = new Set([appUrl.origin.replace(/\/$/, "")]); + const trustedOrigins = new Set([normalizeOrigin(appUrl.origin)]); const LOCAL_ORIGINS = ["localhost", "127.0.0.1"]; if (LOCAL_ORIGINS.includes(appUrl.hostname)) { @@ -60,7 +63,7 @@ function getTrustedOrigins(): string[] { if (hostname !== appUrl.hostname) { const altUrl = new URL(env.APP_URL); altUrl.hostname = hostname; - trustedOrigins.add(altUrl.origin.replace(/\/$/, "")); + trustedOrigins.add(normalizeOrigin(altUrl.origin)); } } } @@ -69,6 +72,26 @@ function getTrustedOrigins(): string[] { } const TRUSTED_ORIGINS = getTrustedOrigins(); +const OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS = parseAllowedHostList(env.OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS); + +function isAllowedDynamicClientRedirectHost(origin: string, hostname: string): boolean { + if (TRUSTED_ORIGINS.includes(origin)) return true; + if (OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS.has(origin)) return true; + return OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS.has(hostname); +} + +function isAllowedDynamicClientRedirectUri(value: string) { + const parsed = parseUrl(value); + if (!parsed) return false; + if (parsed.username || parsed.password) return false; + if (parsed.hash) return false; + if (parsed.protocol !== "https:") return false; + if (isPrivateOrLoopbackHost(parsed.hostname)) return false; + + const origin = parsed.origin.toLowerCase(); + const hostname = parsed.hostname.toLowerCase(); + return isAllowedDynamicClientRedirectHost(origin, hostname); +} async function findExistingUserByEmail(email: string) { const normalizedEmail = email.trim().toLowerCase(); @@ -234,6 +257,41 @@ const getAuthConfig = () => { telemetry: { enabled: false }, trustedOrigins: TRUSTED_ORIGINS, + rateLimit: { + enabled: true, + window: 60, + max: 60, + customRules: { + "/sign-in/email": { window: 60, max: 5 }, + "/sign-up/email": { window: 60, max: 3 }, + "/request-password-reset": { window: 600, max: 3 }, + "/send-verification-email": { window: 600, max: 3 }, + "/two-factor/verify-otp": { window: 600, max: 5 }, + "/two-factor/verify-totp": { window: 600, max: 5 }, + "/two-factor/verify-backup-code": { window: 600, max: 5 }, + "/is-username-available": { window: 60, max: 20 }, + }, + }, + + hooks: { + before: createAuthMiddleware(async (ctx) => { + if (!ctx.path.includes("/oauth2/register")) return; + + const body = ctx.body as { redirect_uris?: unknown } | undefined; + const redirectUris = Array.isArray(body?.redirect_uris) ? body.redirect_uris : []; + + for (const uri of redirectUris) { + if (typeof uri !== "string") { + throw new APIError("BAD_REQUEST", { message: "redirect_uris entries must be strings" }); + } + if (!isAllowedDynamicClientRedirectUri(uri)) { + throw new APIError("BAD_REQUEST", { + message: "redirect_uri is not allowed for dynamic client registration", + }); + } + } + }), + }, advanced: { database: { generateId }, @@ -341,11 +399,10 @@ const getAuthConfig = () => { plugins: [ jwt(), admin(), - openAPI(), passkey(), genericOAuth({ config: authConfigs }), twoFactor({ issuer: "Reactive Resume" }), - apiKey({ enableSessionForAPIKeys: true, rateLimit: { enabled: false } }), + apiKey({ enableSessionForAPIKeys: true, rateLimit: { enabled: true } }), dash({ apiKey: env.BETTER_AUTH_API_KEY, activityTracking: { enabled: true } }), oauthProvider({ loginPage: "/auth/oauth", @@ -353,6 +410,14 @@ const getAuthConfig = () => { validAudiences: OAUTH_AUDIENCES, allowDynamicClientRegistration: true, allowUnauthenticatedClientRegistration: true, + rateLimit: { + register: { window: 60, max: 5 }, + authorize: { window: 60, max: 30 }, + token: { window: 60, max: 20 }, + introspect: { window: 60, max: 60 }, + revoke: { window: 60, max: 30 }, + userinfo: { window: 60, max: 60 }, + }, silenceWarnings: { oauthAuthServerConfig: true }, }), username({ diff --git a/src/integrations/orpc/client.ts b/src/integrations/orpc/client.ts index 2b1260e38..bdb997391 100644 --- a/src/integrations/orpc/client.ts +++ b/src/integrations/orpc/client.ts @@ -21,9 +21,6 @@ export const getORPCClient = createIsomorphicFn() const locale = await getLocale(); const reqHeaders = getRequestHeaders(); - // Add a custom header to identify server-side calls - reqHeaders.set("x-server-side-call", "true"); - return { locale, reqHeaders }; }, }); diff --git a/src/integrations/orpc/context.ts b/src/integrations/orpc/context.ts index f5bd1df10..0008face5 100644 --- a/src/integrations/orpc/context.ts +++ b/src/integrations/orpc/context.ts @@ -5,8 +5,6 @@ import { eq } from "drizzle-orm"; import type { Locale } from "@/utils/locale"; -import { env } from "@/utils/env"; - import { auth, verifyOAuthToken } from "../auth/config"; import { db } from "../drizzle/client"; import { user } from "../drizzle/schema"; @@ -108,25 +106,3 @@ export const protectedProcedure = publicProcedure.use(async ({ context, next }) }, }); }); - -/** - * Server-only procedure that can only be called from server-side code (e.g., loaders). - * Rejects requests from the browser with a 401 UNAUTHORIZED error. - */ -export const serverOnlyProcedure = publicProcedure.use(async ({ context, next }) => { - const headers = context.reqHeaders ?? new Headers(); - const isDebugBypassEnabled = env.FLAG_DEBUG_PRINTER && process.env.NODE_ENV === "development"; - - // Check for the custom header that indicates this is a server-side call - // Server-side calls using createRouterClient have this header set - const isServerSideCall = isDebugBypassEnabled || headers.get("x-server-side-call") === "true"; - - // If the header is not present, this is a client-side HTTP request - reject it - if (!isServerSideCall) { - throw new ORPCError("UNAUTHORIZED", { - message: "This endpoint can only be called from server-side code", - }); - } - - return next({ context }); -}); diff --git a/src/integrations/orpc/rate-limit.ts b/src/integrations/orpc/rate-limit.ts new file mode 100644 index 000000000..3ad884154 --- /dev/null +++ b/src/integrations/orpc/rate-limit.ts @@ -0,0 +1,44 @@ +import { createRatelimitMiddleware } from "@orpc/experimental-ratelimit"; +import { MemoryRatelimiter } from "@orpc/experimental-ratelimit/memory"; + +type ContextWithHeaders = { + reqHeaders?: Headers; + user?: { id: string } | null; +}; + +function getClientKey(headers?: Headers): string { + const cfIp = headers?.get("cf-connecting-ip")?.trim(); + const cfRay = headers?.get("cf-ray"); + if (cfIp && cfRay) return `cf:${cfIp}`; + + const userAgent = headers?.get("user-agent")?.trim() ?? "unknown"; + const language = headers?.get("accept-language")?.split(",")[0]?.trim() ?? "none"; + + return `fp:${userAgent.slice(0, 64)}:${language.slice(0, 16)}`; +} + +function getUserKey(context: ContextWithHeaders): string { + return context.user?.id ?? "anon"; +} + +const resumePasswordLimiter = new MemoryRatelimiter({ maxRequests: 5, window: 10 * 60 * 1000 }); +const pdfLimiter = new MemoryRatelimiter({ maxRequests: 5, window: 60 * 1000 }); +const aiLimiter = new MemoryRatelimiter({ maxRequests: 20, window: 60 * 1000 }); + +export const resumePasswordRateLimit = createRatelimitMiddleware< + ContextWithHeaders, + { username: string; slug: string } +>({ + limiter: resumePasswordLimiter, + key: ({ context }, input) => `resume-password:${input.username}:${input.slug}:${getClientKey(context.reqHeaders)}`, +}); + +export const pdfExportRateLimit = createRatelimitMiddleware({ + limiter: pdfLimiter, + key: ({ context }, input) => `pdf-export:${getUserKey(context)}:${input.id}`, +}); + +export const aiRequestRateLimit = createRatelimitMiddleware({ + limiter: aiLimiter, + key: ({ context }, input) => `ai-request:${getUserKey(context)}:${input.provider}`, +}); diff --git a/src/integrations/orpc/router/ai.ts b/src/integrations/orpc/router/ai.ts index 089296a42..4409246a0 100644 --- a/src/integrations/orpc/router/ai.ts +++ b/src/integrations/orpc/router/ai.ts @@ -10,10 +10,34 @@ import { type ResumeData, resumeDataSchema } from "@/schema/resume/data"; import { tailorOutputSchema } from "@/schema/tailor"; import { protectedProcedure } from "../context"; -import { aiCredentialsSchema, aiProviderSchema, aiService, fileInputSchema } from "../services/ai"; +import { aiRequestRateLimit } from "../rate-limit"; +import { aiCredentialsSchema, aiService, fileInputSchema } from "../services/ai"; import { resumeService } from "../services/resume"; -type AIProvider = z.infer; +type AIProvider = z.infer; + +function isInvalidAiBaseUrlError(error: unknown): boolean { + return error instanceof Error && error.message === "INVALID_AI_BASE_URL"; +} + +function isAiProviderGatewayError(error: unknown): boolean { + return error instanceof AISDKError || error instanceof OllamaError; +} + +function throwAiProviderGatewayError(): never { + throw new ORPCError("BAD_GATEWAY", { message: "Could not reach the AI provider." }); +} + +function throwAiProviderConfigError(): never { + throw new ORPCError("BAD_REQUEST", { message: "Invalid AI provider configuration." }); +} + +function throwResumeStructureError(error: ZodError): never { + throw new ORPCError("BAD_REQUEST", { + message: "Invalid resume data structure", + cause: flattenError(error), + }); +} export const aiRouter = { testConnection: protectedProcedure @@ -29,25 +53,26 @@ export const aiRouter = { }) .input( z.object({ - provider: aiProviderSchema, - model: z.string(), - apiKey: z.string(), - baseURL: z.string(), + ...aiCredentialsSchema.shape, }), ) + .use(aiRequestRateLimit) .errors({ BAD_GATEWAY: { message: "The AI provider returned an error or is unreachable.", status: 502, }, + BAD_REQUEST: { + message: "Invalid AI provider configuration.", + status: 400, + }, }) .handler(async ({ input }) => { try { return await aiService.testConnection(input); } catch (error) { - if (error instanceof AISDKError || error instanceof OllamaError) { - throw new ORPCError("BAD_GATEWAY", { message: error.message }); - } + if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError(); + if (isAiProviderGatewayError(error)) throwAiProviderGatewayError(); throw error; } @@ -70,6 +95,7 @@ export const aiRouter = { file: fileInputSchema, }), ) + .use(aiRequestRateLimit) .errors({ BAD_GATEWAY: { message: "The AI provider returned an error or is unreachable.", @@ -84,16 +110,10 @@ export const aiRouter = { try { return await aiService.parsePdf(input); } catch (error) { - if (error instanceof AISDKError) { - throw new ORPCError("BAD_GATEWAY", { message: error.message }); - } + if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError(); + if (error instanceof AISDKError) throwAiProviderGatewayError(); - if (error instanceof ZodError) { - throw new ORPCError("BAD_REQUEST", { - message: "Invalid resume data structure", - cause: flattenError(error), - }); - } + if (error instanceof ZodError) throwResumeStructureError(error); throw error; } }), @@ -119,6 +139,7 @@ export const aiRouter = { ]), }), ) + .use(aiRequestRateLimit) .errors({ BAD_GATEWAY: { message: "The AI provider returned an error or is unreachable.", @@ -133,16 +154,10 @@ export const aiRouter = { try { return await aiService.parseDocx(input); } catch (error) { - if (error instanceof AISDKError) { - throw new ORPCError("BAD_GATEWAY", { message: error.message }); - } + if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError(); + if (error instanceof AISDKError) throwAiProviderGatewayError(); - if (error instanceof ZodError) { - throw new ORPCError("BAD_REQUEST", { - message: "Invalid resume data structure", - cause: flattenError(error), - }); - } + if (error instanceof ZodError) throwResumeStructureError(error); throw error; } @@ -168,13 +183,13 @@ export const aiRouter = { resumeData: ResumeData; }>(), ) + .use(aiRequestRateLimit) .handler(async ({ input }) => { try { return await aiService.chat(input); } catch (error) { - if (error instanceof AISDKError || error instanceof OllamaError) { - throw new ORPCError("BAD_GATEWAY", { message: error.message }); - } + if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError(); + if (isAiProviderGatewayError(error)) throwAiProviderGatewayError(); throw error; } @@ -198,6 +213,7 @@ export const aiRouter = { job: jobResultSchema, }), ) + .use(aiRequestRateLimit) .output(tailorOutputSchema) .errors({ BAD_GATEWAY: { @@ -213,9 +229,8 @@ export const aiRouter = { try { return await aiService.tailorResume(input); } catch (error) { - if (error instanceof AISDKError || error instanceof OllamaError) { - throw new ORPCError("BAD_GATEWAY", { message: error.message }); - } + if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError(); + if (isAiProviderGatewayError(error)) throwAiProviderGatewayError(); if (error instanceof ZodError) { throw new ORPCError("BAD_REQUEST", { @@ -246,6 +261,7 @@ export const aiRouter = { resumeData: resumeDataSchema, }), ) + .use(aiRequestRateLimit) .output(storedResumeAnalysisSchema) .errors({ BAD_GATEWAY: { @@ -282,9 +298,8 @@ export const aiRouter = { }, }); } catch (error) { - if (error instanceof AISDKError || error instanceof OllamaError) { - throw new ORPCError("BAD_GATEWAY", { message: error.message }); - } + if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError(); + if (isAiProviderGatewayError(error)) throwAiProviderGatewayError(); if (error instanceof ZodError) { throw new ORPCError("BAD_REQUEST", { diff --git a/src/integrations/orpc/router/jobs.ts b/src/integrations/orpc/router/jobs.ts index 3de995fe0..fce3c80a1 100644 --- a/src/integrations/orpc/router/jobs.ts +++ b/src/integrations/orpc/router/jobs.ts @@ -29,8 +29,9 @@ export const jobsRouter = { try { return await jobsService.testConnection(input.apiKey); } catch (error) { + console.error("[jobs.testConnection] Failed to test JSearch connection:", error); throw new ORPCError("BAD_GATEWAY", { - message: error instanceof Error ? error.message : "Connection test failed", + message: "The JSearch API returned an error or is unreachable.", }); } }), @@ -71,8 +72,9 @@ export const jobsRouter = { return { data: jobs, rapidApiQuota: response.rapidApiQuota }; } catch (error) { + console.error("[jobs.search] Failed to search jobs via JSearch:", error); throw new ORPCError("BAD_GATEWAY", { - message: error instanceof Error ? error.message : "Search failed", + message: "The JSearch API returned an error or is unreachable.", }); } }), diff --git a/src/integrations/orpc/router/printer.ts b/src/integrations/orpc/router/printer.ts index b4265f5a0..52b57fecd 100644 --- a/src/integrations/orpc/router/printer.ts +++ b/src/integrations/orpc/router/printer.ts @@ -1,11 +1,22 @@ import z from "zod"; -import { protectedProcedure, publicProcedure } from "../context"; +import { protectedProcedure } from "../context"; +import { pdfExportRateLimit } from "../rate-limit"; import { printerService } from "../services/printer"; import { resumeService } from "../services/resume"; +async function getResumeScreenshotUrl(input: { id: string; currentUserId: string }): Promise { + try { + const { id, data, userId, updatedAt } = await resumeService.getByIdForPrinter(input); + return await printerService.getResumeScreenshot({ id, data, userId, updatedAt }); + } catch { + // ignore errors, as the screenshot is not critical + return null; + } +} + export const printerRouter = { - printResumeAsPDF: publicProcedure + printResumeAsPDF: protectedProcedure .route({ method: "GET", path: "/resumes/{id}/pdf", @@ -13,19 +24,19 @@ export const printerRouter = { operationId: "exportResumePdf", summary: "Export resume as PDF", description: - "Generates a PDF from the specified resume and uploads it to storage. Returns a URL to download the generated PDF file. If the request is made by an unauthenticated user (e.g. via a public share link), the resume's download count is incremented. Authentication is optional.", + "Generates a PDF from the specified resume and uploads it to storage. Returns a URL to download the generated PDF file. Requires authentication.", successDescription: "The PDF was generated successfully. Returns a URL to download the file.", }) .input(z.object({ id: z.string().describe("The unique identifier of the resume to export.") })) + .use(pdfExportRateLimit) .output(z.object({ url: z.string().describe("The URL to download the generated PDF file.") })) .handler(async ({ input, context }) => { - const { id, data, userId } = await resumeService.getByIdForPrinter({ id: input.id, userId: context.user?.id }); + const { id, data, userId } = await resumeService.getByIdForPrinter({ + id: input.id, + currentUserId: context.user.id, + }); const url = await printerService.printResumeAsPDF({ id, data, userId }); - if (!context.user) { - await resumeService.statistics.increment({ id: input.id, downloads: true }); - } - return { url }; }), @@ -43,19 +54,7 @@ export const printerRouter = { .input(z.object({ id: z.string().describe("The unique identifier of the resume.") })) .output(z.object({ url: z.string().nullable().describe("The URL to the screenshot image, or null.") })) .handler(async ({ context, input }) => { - try { - const { id, data, userId, updatedAt } = await resumeService.getByIdForPrinter({ - id: input.id, - userId: context.user.id, - }); - - const url = await printerService.getResumeScreenshot({ id, data, userId, updatedAt }); - - return { url }; - } catch { - // ignore errors, as the screenshot is not critical - } - - return { url: null }; + const url = await getResumeScreenshotUrl({ id: input.id, currentUserId: context.user.id }); + return { url }; }), }; diff --git a/src/integrations/orpc/router/resume.ts b/src/integrations/orpc/router/resume.ts index 70f14e9c4..ffd255e02 100644 --- a/src/integrations/orpc/router/resume.ts +++ b/src/integrations/orpc/router/resume.ts @@ -4,8 +4,9 @@ import { storedResumeAnalysisSchema } from "@/schema/resume/analysis"; import { sampleResumeData } from "@/schema/resume/sample"; import { generateRandomName, slugify } from "@/utils/string"; -import { protectedProcedure, publicProcedure, serverOnlyProcedure } from "../context"; +import { protectedProcedure, publicProcedure } from "../context"; import { resumeDto } from "../dto/resume"; +import { resumePasswordRateLimit } from "../rate-limit"; import { resumeService } from "../services/resume"; const tagsRouter = { @@ -22,7 +23,7 @@ const tagsRouter = { }) .output(z.array(z.string())) .handler(async ({ context }) => { - return await resumeService.tags.list({ userId: context.user.id }); + return resumeService.tags.list({ userId: context.user.id }); }), }; @@ -49,14 +50,7 @@ const statisticsRouter = { }), ) .handler(async ({ context, input }) => { - return await resumeService.statistics.getById({ id: input.id, userId: context.user.id }); - }), - - increment: publicProcedure - .route({ tags: ["Internal"], operationId: "incrementResumeStatistics", summary: "Increment resume statistics" }) - .input(z.object({ id: z.string(), views: z.boolean().default(false), downloads: z.boolean().default(false) })) - .handler(async ({ input }) => { - return await resumeService.statistics.increment(input); + return resumeService.statistics.getById({ id: input.id, userId: context.user.id }); }), }; @@ -75,7 +69,7 @@ const analysisRouter = { .input(z.object({ id: z.string().describe("The unique identifier of the resume.") })) .output(storedResumeAnalysisSchema.nullable()) .handler(async ({ context, input }) => { - return await resumeService.analysis.getById({ id: input.id, userId: context.user.id }); + return resumeService.analysis.getById({ id: input.id, userId: context.user.id }); }), }; @@ -98,7 +92,7 @@ export const resumeRouter = { .input(resumeDto.list.input.optional().default({ tags: [], sort: "lastUpdatedAt" })) .output(resumeDto.list.output) .handler(async ({ input, context }) => { - return await resumeService.list({ + return resumeService.list({ userId: context.user.id, tags: input.tags, sort: input.sort, @@ -119,14 +113,22 @@ export const resumeRouter = { .input(resumeDto.getById.input) .output(resumeDto.getById.output) .handler(async ({ context, input }) => { - return await resumeService.getById({ id: input.id, userId: context.user.id }); + return resumeService.getById({ id: input.id, userId: context.user.id }); }), - getByIdForPrinter: serverOnlyProcedure + getByIdForPrinter: publicProcedure .route({ tags: ["Internal"], operationId: "getResumeForPrinter", summary: "Get resume by ID for printer" }) - .input(resumeDto.getById.input) - .handler(async ({ input }) => { - return await resumeService.getByIdForPrinter({ id: input.id }); + .input( + resumeDto.getById.input.extend({ + token: z.string().optional(), + }), + ) + .handler(async ({ input, context }) => { + return resumeService.getByIdForPrinter({ + id: input.id, + currentUserId: context.user?.id, + printerToken: input.token, + }); }), getBySlug: publicProcedure @@ -143,7 +145,7 @@ export const resumeRouter = { .input(resumeDto.getBySlug.input) .output(resumeDto.getBySlug.output) .handler(async ({ input, context }) => { - return await resumeService.getBySlug({ ...input, currentUserId: context.user?.id }); + return resumeService.getBySlug({ ...input, currentUserId: context.user?.id }); }), create: protectedProcedure @@ -166,7 +168,7 @@ export const resumeRouter = { }, }) .handler(async ({ context, input }) => { - return await resumeService.create({ + return resumeService.create({ name: input.name, slug: input.slug, tags: input.tags, @@ -199,7 +201,7 @@ export const resumeRouter = { const name = generateRandomName(); const slug = slugify(name); - return await resumeService.create({ + return resumeService.create({ name, slug, tags: [], @@ -229,7 +231,7 @@ export const resumeRouter = { }, }) .handler(async ({ context, input }) => { - return await resumeService.update({ + return resumeService.update({ id: input.id, userId: context.user.id, name: input.name, @@ -260,7 +262,7 @@ export const resumeRouter = { }, }) .handler(async ({ context, input }) => { - return await resumeService.patch({ + return resumeService.patch({ id: input.id, userId: context.user.id, operations: input.operations, @@ -281,7 +283,7 @@ export const resumeRouter = { .input(resumeDto.setLocked.input) .output(resumeDto.setLocked.output) .handler(async ({ context, input }) => { - return await resumeService.setLocked({ + return resumeService.setLocked({ id: input.id, userId: context.user.id, isLocked: input.isLocked, @@ -302,7 +304,7 @@ export const resumeRouter = { .input(resumeDto.setPassword.input) .output(resumeDto.setPassword.output) .handler(async ({ context, input }) => { - return await resumeService.setPassword({ + return resumeService.setPassword({ id: input.id, userId: context.user.id, password: input.password, @@ -327,9 +329,10 @@ export const resumeRouter = { password: z.string().min(1).describe("The password to verify."), }), ) + .use(resumePasswordRateLimit) .output(z.boolean()) .handler(async ({ input }): Promise => { - return await resumeService.verifyPassword({ + return resumeService.verifyPassword({ username: input.username, slug: input.slug, password: input.password, @@ -350,7 +353,7 @@ export const resumeRouter = { .input(resumeDto.removePassword.input) .output(resumeDto.removePassword.output) .handler(async ({ context, input }) => { - return await resumeService.removePassword({ + return resumeService.removePassword({ id: input.id, userId: context.user.id, }); @@ -372,7 +375,7 @@ export const resumeRouter = { .handler(async ({ context, input }) => { const original = await resumeService.getById({ id: input.id, userId: context.user.id }); - return await resumeService.create({ + return resumeService.create({ userId: context.user.id, name: input.name ?? original.name, slug: input.slug ?? original.slug, @@ -396,6 +399,6 @@ export const resumeRouter = { .input(resumeDto.delete.input) .output(resumeDto.delete.output) .handler(async ({ context, input }) => { - return await resumeService.delete({ id: input.id, userId: context.user.id }); + return resumeService.delete({ id: input.id, userId: context.user.id }); }), }; diff --git a/src/integrations/orpc/services/ai.ts b/src/integrations/orpc/services/ai.ts index 323f39145..9ed41bb6c 100644 --- a/src/integrations/orpc/services/ai.ts +++ b/src/integrations/orpc/services/ai.ts @@ -38,7 +38,9 @@ import { resumeAnalysisSchema, type ResumeAnalysis } from "@/schema/resume/analy import { defaultResumeData, resumeDataSchema } from "@/schema/resume/data"; import { type TailorOutput, tailorOutputSchema } from "@/schema/tailor"; import { buildAiExtractionTemplate } from "@/utils/ai-template"; +import { env } from "@/utils/env"; import { isObject } from "@/utils/sanitize"; +import { isAllowedExternalUrl, parseAllowedHostList } from "@/utils/url-security"; const aiExtractionTemplate = buildAiExtractionTemplate(); @@ -97,23 +99,27 @@ function logAndRethrow(context: string, error: unknown): never { throw new Error(`An unknown error occurred during ${context}.`); } +function getJsonBoundaryIndices(value: string): { first: number; last: number } { + const firstCurly = value.indexOf("{"); + const firstSquare = value.indexOf("["); + const lastCurly = value.lastIndexOf("}"); + const lastSquare = value.lastIndexOf("]"); + + let first = -1; + if (firstCurly !== -1 && firstSquare !== -1) { + first = Math.min(firstCurly, firstSquare); + } else { + first = Math.max(firstCurly, firstSquare); + } + + return { first, last: Math.max(lastCurly, lastSquare) }; +} + function parseAndValidateResumeJson(resultText: string): ResumeData { let jsonString = resultText; - const firstCurly = jsonString.indexOf("{"); - const firstSquare = jsonString.indexOf("["); - const lastCurly = jsonString.lastIndexOf("}"); - const lastSquare = jsonString.lastIndexOf("]"); - - let firstIndex = -1; - if (firstCurly !== -1 && firstSquare !== -1) { - firstIndex = Math.min(firstCurly, firstSquare); - } else { - firstIndex = Math.max(firstCurly, firstSquare); - } - const lastIndex = Math.max(lastCurly, lastSquare); - - if (firstIndex !== -1 && lastIndex !== -1 && lastIndex >= firstIndex) { - jsonString = jsonString.substring(firstIndex, lastIndex + 1); + const { first, last } = getJsonBoundaryIndices(jsonString); + if (first !== -1 && last !== -1 && last >= first) { + jsonString = jsonString.substring(first, last + 1); } try { @@ -203,15 +209,40 @@ type GetModelInput = { provider: AIProvider; model: string; apiKey: string; - baseURL: string; + baseURL?: string; }; const MAX_AI_FILE_BYTES = 10 * 1024 * 1024; // 10MB const MAX_AI_FILE_BASE64_CHARS = Math.ceil((MAX_AI_FILE_BYTES * 4) / 3) + 4; +const adminAllowedBaseUrls = parseAllowedHostList(env.AI_ALLOWED_BASE_URLS); +const defaultProviderHosts: Record, string[]> = { + openai: ["api.openai.com"], + anthropic: ["api.anthropic.com"], + gemini: ["generativelanguage.googleapis.com"], + "vercel-ai-gateway": ["gateway.ai.vercel.com"], +}; + +function resolveBaseUrl(input: GetModelInput) { + const baseURL = input.baseURL?.trim(); + if (!baseURL) { + if (input.provider === "ollama") { + throw new Error("INVALID_AI_BASE_URL"); + } + return undefined; + } + + const providerHosts = input.provider === "ollama" ? [] : defaultProviderHosts[input.provider]; + const allowedHosts = new Set([...providerHosts, ...adminAllowedBaseUrls]); + if (!isAllowedExternalUrl(baseURL, allowedHosts)) { + throw new Error("INVALID_AI_BASE_URL"); + } + + return baseURL; +} function getModel(input: GetModelInput) { const { provider, model, apiKey } = input; - const baseURL = input.baseURL || undefined; + const baseURL = resolveBaseUrl(input); return match(provider) .with("openai", () => createOpenAI({ apiKey, baseURL }).chat(model)) @@ -226,7 +257,7 @@ export const aiCredentialsSchema = z.object({ provider: aiProviderSchema, model: z.string(), apiKey: z.string(), - baseURL: z.string(), + baseURL: z.string().optional().default(""), }); export const fileInputSchema = z.object({ diff --git a/src/integrations/orpc/services/printer.ts b/src/integrations/orpc/services/printer.ts index fb861e0f2..d53a3b031 100644 --- a/src/integrations/orpc/services/printer.ts +++ b/src/integrations/orpc/services/printer.ts @@ -62,6 +62,20 @@ async function closeBrowser(): Promise { } } +function toErrorMessage(error: unknown): string { + if (error instanceof Error && error.message) return error.message; + return "Unknown error"; +} + +function throwPrinterStepError(step: string, hint: string, error: unknown): never { + const details = toErrorMessage(error); + + throw new ORPCError("INTERNAL_SERVER_ERROR", { + message: `${step}. ${hint}. Details: ${details}`, + cause: error, + }); +} + // Close browser on process termination process.on("exit", async () => { await closeBrowser(); @@ -120,70 +134,126 @@ async function doPrintResumeAsPDF( try { // Step 4: Connect to the browser and navigate to the printer route // Use an isolated browser context so concurrent requests with different locales don't interfere - const browser = await getBrowser(); - context = await browser.createBrowserContext(); + const browser = await getBrowser().catch((error) => + throwPrinterStepError( + "Failed to connect to the printer browser", + "Check PRINTER_ENDPOINT and ensure Browserless/Chrome is reachable", + error, + ), + ); + context = await browser + .createBrowserContext() + .catch((error) => + throwPrinterStepError( + "Failed to create an isolated browser context for PDF generation", + "Retry the request", + error, + ), + ); - await context.setCookie({ name: "locale", value: locale, domain }); + await context + .setCookie({ name: "locale", value: locale, domain }) + .catch((error) => + throwPrinterStepError( + "Failed to set locale cookie in printer browser context", + "Verify APP_URL/PRINTER_APP_URL host is valid and reachable", + error, + ), + ); - page = await context.newPage(); + page = await context + .newPage() + .catch((error) => + throwPrinterStepError("Failed to open a new browser page for PDF generation", "Retry the request", error), + ); - // Wait for the page to fully load (network idle + custom loaded attribute) - await page.emulateMediaType("print"); - await page.setViewport(pageDimensionsAsPixels[format]); - await page.goto(url, { waitUntil: "networkidle0" }); - await page.waitForFunction(() => document.body.getAttribute("data-wf-loaded") === "true", { timeout: 5_000 }); + // Wait for the page to become ready without relying on strict network idle. + await page + .emulateMediaType("print") + .catch((error) => + throwPrinterStepError("Failed to switch browser page to print media mode", "Retry the request", error), + ); + await page + .setViewport(pageDimensionsAsPixels[format]) + .catch((error) => throwPrinterStepError("Failed to apply PDF viewport dimensions", "Retry the request", error)); + await page + .goto(url, { waitUntil: "domcontentloaded" }) + .catch((error) => + throwPrinterStepError( + "Failed to open the internal printer route", + "Check PRINTER_APP_URL/APP_URL reachability from the browser container and verify printer token configuration", + error, + ), + ); + await page + .waitForFunction(() => document.body.getAttribute("data-wf-loaded") === "true", { timeout: 5_000 }) + .catch((error) => + throwPrinterStepError( + "Resume print page did not finish loading", + "Check frontend runtime errors and ensure the /printer route can render successfully", + error, + ), + ); // Step 5a: Prepare the DOM for PDF rendering (background colors, reset margins, print padding) - await page.evaluate( - (pagePaddingX: number, pagePaddingY: number, backgroundColor: string) => { - const root = document.documentElement; - const body = document.body; - const pageElements = document.querySelectorAll("[data-page-index]"); - const pageContentElements = document.querySelectorAll(".page-content"); + await page + .evaluate( + (pagePaddingX: number, pagePaddingY: number, backgroundColor: string) => { + const root = document.documentElement; + const body = document.body; + const pageElements = document.querySelectorAll("[data-page-index]"); + const pageContentElements = document.querySelectorAll(".page-content"); - // Ensure PDF margins inherit the resume background color instead of defaulting to white. - root.style.backgroundColor = backgroundColor; - body.style.backgroundColor = backgroundColor; - root.style.margin = "0"; - body.style.margin = "0"; - root.style.padding = "0"; - body.style.padding = "0"; + // Ensure PDF margins inherit the resume background color instead of defaulting to white. + root.style.backgroundColor = backgroundColor; + body.style.backgroundColor = backgroundColor; + root.style.margin = "0"; + body.style.margin = "0"; + root.style.padding = "0"; + body.style.padding = "0"; - for (const el of pageElements) { - const pageWrapper = el as HTMLElement; - const pageSurface = pageWrapper.querySelector(".page") as HTMLElement | null; + for (const el of pageElements) { + const pageWrapper = el as HTMLElement; + const pageSurface = pageWrapper.querySelector(".page") as HTMLElement | null; - pageWrapper.style.backgroundColor = backgroundColor; - pageWrapper.style.breakInside = "auto"; + pageWrapper.style.backgroundColor = backgroundColor; + pageWrapper.style.breakInside = "auto"; - if (pageSurface) pageSurface.style.backgroundColor = backgroundColor; - } + if (pageSurface) pageSurface.style.backgroundColor = backgroundColor; + } - // Apply print-only margins as padding inside each page's content surface. - if (pagePaddingX > 0 || pagePaddingY > 0) { - for (const el of pageContentElements) { - const pageContent = el as HTMLElement; + // Apply print-only margins as padding inside each page's content surface. + if (pagePaddingX > 0 || pagePaddingY > 0) { + for (const el of pageContentElements) { + const pageContent = el as HTMLElement; - pageContent.style.boxSizing = "border-box"; - // Ensure padding is repeated on every printed fragment when content - // flows across physical PDF pages (not just the first fragment). - pageContent.style.boxDecorationBreak = "clone"; - pageContent.style.setProperty("-webkit-box-decoration-break", "clone"); - if (pagePaddingX > 0) { - pageContent.style.paddingLeft = `${pagePaddingX}pt`; - pageContent.style.paddingRight = `${pagePaddingX}pt`; - } - if (pagePaddingY > 0) { - pageContent.style.paddingTop = `${pagePaddingY}pt`; - pageContent.style.paddingBottom = `${pagePaddingY}pt`; + pageContent.style.boxSizing = "border-box"; + // Ensure padding is repeated on every printed fragment when content + // flows across physical PDF pages (not just the first fragment). + pageContent.style.boxDecorationBreak = "clone"; + pageContent.style.setProperty("-webkit-box-decoration-break", "clone"); + if (pagePaddingX > 0) { + pageContent.style.paddingLeft = `${pagePaddingX}pt`; + pageContent.style.paddingRight = `${pagePaddingX}pt`; + } + if (pagePaddingY > 0) { + pageContent.style.paddingTop = `${pagePaddingY}pt`; + pageContent.style.paddingBottom = `${pagePaddingY}pt`; + } } } - } - }, - pagePaddingX, - pagePaddingY, - data.metadata.design.colors.background, - ); + }, + pagePaddingX, + pagePaddingY, + data.metadata.design.colors.background, + ) + .catch((error) => + throwPrinterStepError( + "Failed to apply PDF print styles", + "Check that resume template styles can be evaluated in headless browser mode", + error, + ), + ); // Step 5b: Format-specific layout adjustments const isFreeForm = format === "free-form"; @@ -191,57 +261,73 @@ async function doPrintResumeAsPDF( if (isFreeForm) { // Free-form: measure actual content height after adding inter-page margins - contentHeight = await page.evaluate( - (pagePaddingY: number, minPageHeight: number) => { - const pageElements = document.querySelectorAll("[data-page-index]"); - const numberOfPages = pageElements.length; + contentHeight = await page + .evaluate( + (pagePaddingY: number, minPageHeight: number) => { + const pageElements = document.querySelectorAll("[data-page-index]"); + const numberOfPages = pageElements.length; - // Add margin between pages (except the last one) - for (let i = 0; i < numberOfPages - 1; i++) { - const pageEl = pageElements[i] as HTMLElement; - if (pagePaddingY > 0) pageEl.style.marginBottom = `${pagePaddingY}pt`; - } + // Add margin between pages (except the last one) + for (let i = 0; i < numberOfPages - 1; i++) { + const pageEl = pageElements[i] as HTMLElement; + if (pagePaddingY > 0) pageEl.style.marginBottom = `${pagePaddingY}pt`; + } - // Measure the total height (margins are now part of the DOM) - let totalHeight = 0; + // Measure the total height (margins are now part of the DOM) + let totalHeight = 0; - for (const el of pageElements) { - const pageEl = el as HTMLElement; - const style = getComputedStyle(pageEl); - const marginBottom = Number.parseFloat(style.marginBottom) || 0; - totalHeight += pageEl.offsetHeight + marginBottom; - } + for (const el of pageElements) { + const pageEl = el as HTMLElement; + const style = getComputedStyle(pageEl); + const marginBottom = Number.parseFloat(style.marginBottom) || 0; + totalHeight += pageEl.offsetHeight + marginBottom; + } - return Math.max(totalHeight, minPageHeight); - }, - pagePaddingY, - pageDimensionsAsPixels[format].height, - ); + return Math.max(totalHeight, minPageHeight); + }, + pagePaddingY, + pageDimensionsAsPixels[format].height, + ) + .catch((error) => + throwPrinterStepError( + "Failed to compute free-form PDF content height", + "Check resume layout and page metadata values", + error, + ), + ); } else { // A4/Letter: set fixed page height and add page breaks between pages - await page.evaluate((pageHeight: number) => { - const root = document.documentElement; - const pageElements = document.querySelectorAll("[data-page-index]"); - const container = document.querySelector(".resume-preview-container") as HTMLElement | null; + await page + .evaluate((pageHeight: number) => { + const root = document.documentElement; + const pageElements = document.querySelectorAll("[data-page-index]"); + const container = document.querySelector(".resume-preview-container") as HTMLElement | null; - const newHeight = `${pageHeight}px`; - if (container) container.style.setProperty("--page-height", newHeight); - root.style.setProperty("--page-height", newHeight); + const newHeight = `${pageHeight}px`; + if (container) container.style.setProperty("--page-height", newHeight); + root.style.setProperty("--page-height", newHeight); - for (const el of pageElements) { - const element = el as HTMLElement; - const index = Number.parseInt(element.getAttribute("data-page-index") ?? "0", 10); + for (const el of pageElements) { + const element = el as HTMLElement; + const index = Number.parseInt(element.getAttribute("data-page-index") ?? "0", 10); - // Force a page break before each page except the first - if (index > 0) { - element.style.breakBefore = "page"; - element.style.pageBreakBefore = "always"; + // Force a page break before each page except the first + if (index > 0) { + element.style.breakBefore = "page"; + element.style.pageBreakBefore = "always"; + } + + // Allow content within a page to break naturally if it overflows + element.style.breakInside = "auto"; } - - // Allow content within a page to break naturally if it overflows - element.style.breakInside = "auto"; - } - }, pageDimensionsAsPixels[format].height); + }, pageDimensionsAsPixels[format].height) + .catch((error) => + throwPrinterStepError( + "Failed to prepare fixed-page PDF layout", + "Check template/page format configuration", + error, + ), + ); } // Step 6: Generate the PDF with the specified dimensions and margins @@ -249,19 +335,27 @@ async function doPrintResumeAsPDF( // For A4/Letter: use fixed dimensions from pageDimensionsAsPixels const pdfHeight = isFreeForm && contentHeight ? contentHeight : pageDimensionsAsPixels[format].height; - const pdfBuffer = await page.pdf({ - width: `${pageDimensionsAsPixels[format].width}px`, - height: `${pdfHeight}px`, - tagged: true, // Adds accessibility tags to the PDF - waitForFonts: true, // Ensures all fonts are loaded before rendering - printBackground: true, // Includes background colors and images - margin: { - bottom: 0, - top: 0, - right: 0, - left: 0, - }, - }); + const pdfBuffer = await page + .pdf({ + width: `${pageDimensionsAsPixels[format].width}px`, + height: `${pdfHeight}px`, + tagged: true, // Adds accessibility tags to the PDF + waitForFonts: true, // Ensures all fonts are loaded before rendering + printBackground: true, // Includes background colors and images + margin: { + bottom: 0, + top: 0, + right: 0, + left: 0, + }, + }) + .catch((error) => + throwPrinterStepError( + "Failed to render PDF document", + "Check font loading, page dimensions, and Browserless resource limits", + error, + ), + ); // Step 7: Upload the generated PDF to storage const result = await uploadFile({ @@ -270,11 +364,21 @@ async function doPrintResumeAsPDF( data: new Uint8Array(pdfBuffer), contentType: "application/pdf", type: "pdf", - }); + }).catch((error) => + throwPrinterStepError( + "Failed to upload generated PDF", + "Check storage configuration (S3/local disk permissions and related environment variables)", + error, + ), + ); return result.url; } catch (error) { - throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Failed to generate PDF", cause: error }); + if (error instanceof ORPCError) throw error; + throw new ORPCError("INTERNAL_SERVER_ERROR", { + message: `Failed to generate PDF. Details: ${toErrorMessage(error)}`, + cause: error, + }); } finally { if (page) await page.close().catch(() => null); if (context) await context.close().catch(() => null); @@ -295,7 +399,15 @@ async function doGetResumeScreenshot( const storageService = getStorageService(); const screenshotPrefix = `uploads/${userId}/screenshots/${id}`; - const existingScreenshots = await storageService.list(screenshotPrefix); + const existingScreenshots = await storageService + .list(screenshotPrefix) + .catch((error) => + throwPrinterStepError( + "Failed to list existing screenshots", + "Check storage configuration (S3/local disk permissions and related environment variables)", + error, + ), + ); const now = Date.now(); const resumeUpdatedAt = updatedAt.getTime(); @@ -324,7 +436,13 @@ async function doGetResumeScreenshot( } // Resume was updated after the screenshot - delete old ones and regenerate - await Promise.all(sortedFiles.map((file) => storageService.delete(file.path))); + await Promise.all(sortedFiles.map((file) => storageService.delete(file.path))).catch((error) => + throwPrinterStepError( + "Failed to clean up stale screenshots", + "Check storage delete permissions and connectivity", + error, + ), + ); } } @@ -340,18 +458,72 @@ async function doGetResumeScreenshot( let page: Page | null = null; try { - const browser = await getBrowser(); - context = await browser.createBrowserContext(); + const browser = await getBrowser().catch((error) => + throwPrinterStepError( + "Failed to connect to the printer browser", + "Check PRINTER_ENDPOINT and ensure Browserless/Chrome is reachable", + error, + ), + ); + context = await browser + .createBrowserContext() + .catch((error) => + throwPrinterStepError( + "Failed to create an isolated browser context for screenshot capture", + "Retry the request", + error, + ), + ); - await context.setCookie({ name: "locale", value: locale, domain }); + await context + .setCookie({ name: "locale", value: locale, domain }) + .catch((error) => + throwPrinterStepError( + "Failed to set locale cookie in screenshot browser context", + "Verify APP_URL/PRINTER_APP_URL host is valid and reachable", + error, + ), + ); - page = await context.newPage(); + page = await context + .newPage() + .catch((error) => + throwPrinterStepError("Failed to open a new browser page for screenshot capture", "Retry the request", error), + ); - await page.setViewport(pageDimensionsAsPixels.a4); - await page.goto(url, { waitUntil: "networkidle0" }); - await page.waitForFunction(() => document.body.getAttribute("data-wf-loaded") === "true", { timeout: 5_000 }); + await page + .setViewport(pageDimensionsAsPixels.a4) + .catch((error) => + throwPrinterStepError("Failed to apply screenshot viewport dimensions", "Retry the request", error), + ); + await page + .goto(url, { waitUntil: "domcontentloaded" }) + .catch((error) => + throwPrinterStepError( + "Failed to open the internal printer route for screenshot capture", + "Check PRINTER_APP_URL/APP_URL reachability from the browser container and verify printer token configuration", + error, + ), + ); + await page + .waitForFunction(() => document.body.getAttribute("data-wf-loaded") === "true", { timeout: 5_000 }) + .catch((error) => + throwPrinterStepError( + "Resume screenshot page did not finish loading", + "Check frontend runtime errors and ensure the /printer route can render successfully", + error, + ), + ); - const screenshotBuffer = await page.screenshot({ type: "webp", quality: 80 }); + const screenshotBuffer = await page + .screenshot({ type: "webp", quality: 80 }) + .catch((error) => + throwPrinterStepError( + "Failed to capture screenshot image", + "Check renderer stability and Browserless resource limits", + error, + ), + ); const result = await uploadFile({ userId, @@ -359,11 +531,21 @@ async function doGetResumeScreenshot( data: new Uint8Array(screenshotBuffer), contentType: "image/webp", type: "screenshot", - }); + }).catch((error) => + throwPrinterStepError( + "Failed to upload generated screenshot", + "Check storage configuration (S3/local disk permissions and related environment variables)", + error, + ), + ); return result.url; } catch (error) { - throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Failed to capture screenshot", cause: error }); + if (error instanceof ORPCError) throw error; + throw new ORPCError("INTERNAL_SERVER_ERROR", { + message: `Failed to capture screenshot. Details: ${toErrorMessage(error)}`, + cause: error, + }); } finally { if (page) await page.close().catch(() => null); if (context) await context.close().catch(() => null); diff --git a/src/integrations/orpc/services/resume.ts b/src/integrations/orpc/services/resume.ts index a366ba802..20ea04e7f 100644 --- a/src/integrations/orpc/services/resume.ts +++ b/src/integrations/orpc/services/resume.ts @@ -14,8 +14,10 @@ import { type StoredResumeAnalysis } from "@/schema/resume/analysis"; import { defaultResumeData } from "@/schema/resume/data"; import { env } from "@/utils/env"; import { hashPassword, verifyPassword } from "@/utils/password"; +import { verifyPrinterToken } from "@/utils/printer-token"; import { applyResumePatches, ResumePatchError } from "@/utils/resume/patch"; import { generateId } from "@/utils/string"; +import { sanitizeResumePictureUrl } from "@/utils/url-security"; import { grantResumeAccess, hasResumeAccess } from "../helpers/resume-access"; import { getStorageService } from "./storage"; @@ -121,6 +123,50 @@ const analysis = { }, }; +function hasValidPrinterToken(printerToken: string | undefined, resumeId: string): boolean { + if (!printerToken) return false; + + try { + return verifyPrinterToken(printerToken) === resumeId; + } catch { + return false; + } +} + +function toSharedResumeResponse( + resume: { + id: string; + name: string; + slug: string; + tags: string[]; + data: ResumeData; + isPublic: boolean; + isLocked: boolean; + }, + hasPassword: TPassword, +) { + return { + id: resume.id, + name: resume.name, + slug: resume.slug, + tags: resume.tags, + data: resume.data, + isPublic: resume.isPublic, + isLocked: resume.isLocked, + hasPassword, + }; +} + +function sanitizeResumeDataPictureUrl(data: ResumeData): ResumeData { + return { + ...data, + picture: { + ...data.picture, + url: sanitizeResumePictureUrl(data.picture.url, env.APP_URL), + }, + }; +} + export const resumeService = { tags, statistics, @@ -176,7 +222,7 @@ export const resumeService = { return resume; }, - getByIdForPrinter: async (input: { id: string; userId?: string }) => { + getByIdForPrinter: async (input: { id: string; currentUserId?: string; printerToken?: string }) => { const [resume] = await db .select({ id: schema.resume.id, @@ -189,27 +235,15 @@ export const resumeService = { updatedAt: schema.resume.updatedAt, }) .from(schema.resume) - .where( - input.userId - ? and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)) - : eq(schema.resume.id, input.id), - ); + .where(eq(schema.resume.id, input.id)); if (!resume) throw new ORPCError("NOT_FOUND"); - try { - if (!resume.data.picture.url) throw new Error("Picture is not available"); + const isOwner = !!input.currentUserId && resume.userId === input.currentUserId; - // Convert picture URL to base64 data, so there's no fetching required on the client. - const url = resume.data.picture.url.replace(env.APP_URL, "http://localhost:3000"); - const base64 = await fetch(url) - .then((res) => res.arrayBuffer()) - .then((buffer) => Buffer.from(buffer).toString("base64")); + const hasValidToken = hasValidPrinterToken(input.printerToken, input.id); - resume.data.picture.url = `data:image/jpeg;base64,${base64}`; - } catch { - // Ignore errors, as the picture is not always available - } + if (!isOwner && !hasValidToken) throw new ORPCError("NOT_FOUND"); return resume; }, @@ -243,32 +277,12 @@ export const resumeService = { if (!resume.hasPassword) { await resumeService.statistics.increment({ id: resume.id, views: true }); - - return { - id: resume.id, - name: resume.name, - slug: resume.slug, - tags: resume.tags, - data: resume.data, - isPublic: resume.isPublic, - isLocked: resume.isLocked, - hasPassword: false as const, - }; + return toSharedResumeResponse(resume, false); } if (hasResumeAccess(resume.id, resume.passwordHash)) { await resumeService.statistics.increment({ id: resume.id, views: true }); - - return { - id: resume.id, - name: resume.name, - slug: resume.slug, - tags: resume.tags, - data: resume.data, - isPublic: resume.isPublic, - isLocked: resume.isLocked, - hasPassword: true as const, - }; + return toSharedResumeResponse(resume, true); } throw new ORPCError("NEED_PASSWORD", { @@ -288,6 +302,7 @@ export const resumeService = { const id = generateId(); input.data = input.data ?? defaultResumeData; + input.data = sanitizeResumeDataPictureUrl(input.data); input.data.metadata.page.locale = input.locale; try { @@ -333,7 +348,7 @@ export const resumeService = { name: input.name, slug: input.slug, tags: input.tags, - data: input.data, + data: input.data ? sanitizeResumeDataPictureUrl(input.data) : undefined, isPublic: input.isPublic, }; @@ -383,6 +398,7 @@ export const resumeService = { try { patchedData = applyResumePatches(existing.data, input.operations); + patchedData = sanitizeResumeDataPictureUrl(patchedData); } catch (error) { if (error instanceof ResumePatchError) { throw new ORPCError("INVALID_PATCH_OPERATIONS", { @@ -447,12 +463,12 @@ export const resumeService = { ), ); - if (!resume) throw new ORPCError("NOT_FOUND"); + if (!resume) throw new ORPCError("INVALID_PASSWORD", { status: 401 }); const passwordHash = resume.password as string; const isValid = await verifyPassword(input.password, passwordHash); - if (!isValid) throw new ORPCError("INVALID_PASSWORD"); + if (!isValid) throw new ORPCError("INVALID_PASSWORD", { status: 401 }); grantResumeAccess(resume.id, passwordHash); diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index ebf3df88a..6c8f2c316 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -30,6 +30,7 @@ import { Route as ApiHealthRouteImport } from "./routes/api/health"; import { Route as DotwellKnownOpenidConfigurationRouteImport } from "./routes/[.]well-known/openid-configuration"; import { Route as DotwellKnownOauthProtectedResourceRouteImport } from "./routes/[.]well-known/oauth-protected-resource"; import { Route as DotwellKnownOauthAuthorizationServerRouteImport } from "./routes/[.]well-known/oauth-authorization-server"; +import { Route as DotwellKnownSplatRouteImport } from "./routes/[.]well-known/$"; import { Route as UsernameSlugRouteImport } from "./routes/$username/$slug"; import { Route as BuilderResumeIdRouteRouteImport } from "./routes/builder/$resumeId/route"; import { Route as DashboardResumesIndexRouteImport } from "./routes/dashboard/resumes/index"; @@ -157,6 +158,11 @@ const DotwellKnownOauthAuthorizationServerRoute = path: "/.well-known/oauth-authorization-server", getParentRoute: () => rootRouteImport, } as any); +const DotwellKnownSplatRoute = DotwellKnownSplatRouteImport.update({ + id: "/.well-known/$", + path: "/.well-known/$", + getParentRoute: () => rootRouteImport, +} as any); const UsernameSlugRoute = UsernameSlugRouteImport.update({ id: "/$username/$slug", path: "/$username/$slug", @@ -269,6 +275,7 @@ export interface FileRoutesByFullPath { "/schema.json": typeof SchemaDotjsonRoute; "/builder/$resumeId": typeof BuilderResumeIdRouteRouteWithChildren; "/$username/$slug": typeof UsernameSlugRoute; + "/.well-known/$": typeof DotwellKnownSplatRoute; "/.well-known/oauth-authorization-server": typeof DotwellKnownOauthAuthorizationServerRouteWithChildren; "/.well-known/oauth-protected-resource": typeof DotwellKnownOauthProtectedResourceRouteWithChildren; "/.well-known/openid-configuration": typeof DotwellKnownOpenidConfigurationRoute; @@ -306,6 +313,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { "/schema.json": typeof SchemaDotjsonRoute; "/$username/$slug": typeof UsernameSlugRoute; + "/.well-known/$": typeof DotwellKnownSplatRoute; "/.well-known/oauth-authorization-server": typeof DotwellKnownOauthAuthorizationServerRouteWithChildren; "/.well-known/oauth-protected-resource": typeof DotwellKnownOauthProtectedResourceRouteWithChildren; "/.well-known/openid-configuration": typeof DotwellKnownOpenidConfigurationRoute; @@ -349,6 +357,7 @@ export interface FileRoutesById { "/schema.json": typeof SchemaDotjsonRoute; "/builder/$resumeId": typeof BuilderResumeIdRouteRouteWithChildren; "/$username/$slug": typeof UsernameSlugRoute; + "/.well-known/$": typeof DotwellKnownSplatRoute; "/.well-known/oauth-authorization-server": typeof DotwellKnownOauthAuthorizationServerRouteWithChildren; "/.well-known/oauth-protected-resource": typeof DotwellKnownOauthProtectedResourceRouteWithChildren; "/.well-known/openid-configuration": typeof DotwellKnownOpenidConfigurationRoute; @@ -393,6 +402,7 @@ export interface FileRouteTypes { | "/schema.json" | "/builder/$resumeId" | "/$username/$slug" + | "/.well-known/$" | "/.well-known/oauth-authorization-server" | "/.well-known/oauth-protected-resource" | "/.well-known/openid-configuration" @@ -430,6 +440,7 @@ export interface FileRouteTypes { to: | "/schema.json" | "/$username/$slug" + | "/.well-known/$" | "/.well-known/oauth-authorization-server" | "/.well-known/oauth-protected-resource" | "/.well-known/openid-configuration" @@ -472,6 +483,7 @@ export interface FileRouteTypes { | "/schema.json" | "/builder/$resumeId" | "/$username/$slug" + | "/.well-known/$" | "/.well-known/oauth-authorization-server" | "/.well-known/oauth-protected-resource" | "/.well-known/openid-configuration" @@ -515,6 +527,7 @@ export interface RootRouteChildren { SchemaDotjsonRoute: typeof SchemaDotjsonRoute; BuilderResumeIdRouteRoute: typeof BuilderResumeIdRouteRouteWithChildren; UsernameSlugRoute: typeof UsernameSlugRoute; + DotwellKnownSplatRoute: typeof DotwellKnownSplatRoute; DotwellKnownOauthAuthorizationServerRoute: typeof DotwellKnownOauthAuthorizationServerRouteWithChildren; DotwellKnownOauthProtectedResourceRoute: typeof DotwellKnownOauthProtectedResourceRouteWithChildren; DotwellKnownOpenidConfigurationRoute: typeof DotwellKnownOpenidConfigurationRoute; @@ -677,6 +690,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof DotwellKnownOauthAuthorizationServerRouteImport; parentRoute: typeof rootRouteImport; }; + "/.well-known/$": { + id: "/.well-known/$"; + path: "/.well-known/$"; + fullPath: "/.well-known/$"; + preLoaderRoute: typeof DotwellKnownSplatRouteImport; + parentRoute: typeof rootRouteImport; + }; "/$username/$slug": { id: "/$username/$slug"; path: "/$username/$slug"; @@ -932,6 +952,7 @@ const rootRouteChildren: RootRouteChildren = { SchemaDotjsonRoute: SchemaDotjsonRoute, BuilderResumeIdRouteRoute: BuilderResumeIdRouteRouteWithChildren, UsernameSlugRoute: UsernameSlugRoute, + DotwellKnownSplatRoute: DotwellKnownSplatRoute, DotwellKnownOauthAuthorizationServerRoute: DotwellKnownOauthAuthorizationServerRouteWithChildren, DotwellKnownOauthProtectedResourceRoute: diff --git a/src/routes/[.]well-known/$.ts b/src/routes/[.]well-known/$.ts new file mode 100644 index 000000000..cd8141976 --- /dev/null +++ b/src/routes/[.]well-known/$.ts @@ -0,0 +1,12 @@ +import { createFileRoute } from "@tanstack/react-router"; + +const okResponse = () => new Response("OK", { status: 200 }); + +export const Route = createFileRoute("/.well-known/$")({ + server: { + handlers: { + GET: () => okResponse(), + HEAD: () => okResponse(), + }, + }, +}); diff --git a/src/routes/api/auth.$.ts b/src/routes/api/auth.$.ts index e731b0c1a..7d21992c9 100644 --- a/src/routes/api/auth.$.ts +++ b/src/routes/api/auth.$.ts @@ -1,6 +1,40 @@ import { createFileRoute } from "@tanstack/react-router"; import { auth } from "@/integrations/auth/config"; +import { env } from "@/utils/env"; +import { isPrivateOrLoopbackHost, parseAllowedHostList, parseUrl } from "@/utils/url-security"; + +const oauthDynamicClientRedirectHosts = parseAllowedHostList(env.OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS); +const oauthAuthorizeSanitizedParams = [ + "prompt", + "redirect_uri", + "client_id", + "code_challenge", + "code_challenge_method", + "response_type", + "scope", + "state", + "resource", +] as const; + +function isAllowedDynamicClientRedirectUri(value: string) { + const parsed = parseUrl(value); + if (!parsed) return false; + if (parsed.protocol !== "https:") return false; + if (parsed.username || parsed.password) return false; + if (parsed.hash) return false; + if (isPrivateOrLoopbackHost(parsed.hostname)) return false; + + const appOrigin = new URL(env.APP_URL).origin.toLowerCase(); + const origin = parsed.origin.toLowerCase(); + const hostname = parsed.hostname.toLowerCase(); + + if (origin === appOrigin) return true; + if (oauthDynamicClientRedirectHosts.has(origin)) return true; + if (oauthDynamicClientRedirectHosts.has(hostname)) return true; + + return false; +} function sanitizeOAuthAuthorizeRequest(request: Request): Request { if (request.method !== "GET") return request; @@ -19,15 +53,7 @@ function sanitizeOAuthAuthorizeRequest(request: Request): Request { url.searchParams.set(key, sanitizeValue(value)); }; - sanitizeParam("prompt"); - sanitizeParam("redirect_uri"); - sanitizeParam("client_id"); - sanitizeParam("code_challenge"); - sanitizeParam("code_challenge_method"); - sanitizeParam("response_type"); - sanitizeParam("scope"); - sanitizeParam("state"); - sanitizeParam("resource"); + for (const key of oauthAuthorizeSanitizedParams) sanitizeParam(key); const redirectUri = url.searchParams.get("redirect_uri"); if (redirectUri && !URL.canParse(redirectUri)) { @@ -74,16 +100,36 @@ async function defaultPublicClientRegistration(request: Request): Promise { + if (request.method !== "POST") return; + + const url = new URL(request.url); + if (!url.pathname.endsWith("/oauth2/register")) return; + + const cloned = request.clone(); + let body: Record; + + try { + body = await cloned.json(); + } catch { + return Response.json({ message: "Invalid registration payload" }, { status: 400 }); + } + + const redirectUris = Array.isArray(body.redirect_uris) ? body.redirect_uris : []; + for (const redirectUri of redirectUris) { + if (typeof redirectUri !== "string" || !isAllowedDynamicClientRedirectUri(redirectUri)) { + return Response.json({ message: "redirect_uri is not allowed" }, { status: 400 }); + } + } +} + async function handler({ request }: { request: Request }) { + const registrationValidationError = await validateDynamicClientRegistrationRequest(request); + if (registrationValidationError) return registrationValidationError; + const sanitizedRequest = sanitizeOAuthAuthorizeRequest(request); const finalRequest = await defaultPublicClientRegistration(sanitizedRequest); - if (request.method === "GET" && request.url.endsWith("/spec.json")) { - const spec = await auth.api.generateOpenAPISchema(); - - return Response.json(spec); - } - return auth.handler(finalRequest); } diff --git a/src/routes/printer/$resumeId.tsx b/src/routes/printer/$resumeId.tsx index 10ec8d05a..c9001282d 100644 --- a/src/routes/printer/$resumeId.tsx +++ b/src/routes/printer/$resumeId.tsx @@ -1,3 +1,4 @@ +import { t } from "@lingui/core/macro"; import { createFileRoute, redirect } from "@tanstack/react-router"; import { zodValidator } from "@tanstack/zod-adapter"; import { useEffect } from "react"; @@ -14,6 +15,12 @@ const searchSchema = z.object({ token: z.string().catch(""), }); +function assertValidPrinterToken(token: string, resumeId: string): void { + const tokenResumeId = verifyPrinterToken(token); + if (tokenResumeId === resumeId) return; + throw new Error("Printer token does not match resume ID"); +} + export const Route = createFileRoute("/printer/$resumeId")({ component: RouteComponent, validateSearch: zodValidator(searchSchema), @@ -21,22 +28,32 @@ export const Route = createFileRoute("/printer/$resumeId")({ if (env.FLAG_DEBUG_PRINTER) return; try { - // Verify the token and ensure it matches the resume ID - const tokenResumeId = verifyPrinterToken(search.token); - if (tokenResumeId !== params.resumeId) throw new Error(); + assertValidPrinterToken(search.token, params.resumeId); } catch { - // Invalid or missing token - throw error to be caught by error handler throw redirect({ to: "/", search: {}, throw: true }); } }, - loader: async ({ params }) => { + loaderDeps: ({ search }) => ({ token: search.token }), + loader: async ({ params, deps }) => { const client = getORPCClient(); - const resume = await client.resume.getByIdForPrinter({ id: params.resumeId }); + const resume = await client.resume.getByIdForPrinter({ id: params.resumeId, token: deps.token }); return { resume }; }, head: ({ loaderData }) => ({ - meta: [{ title: loaderData ? `${loaderData.resume.data.basics.name} - Resume` : "Resume" }], + meta: [ + { + title: loaderData + ? `${loaderData.resume.data.basics.name} - ${t({ + comment: "Browser tab suffix for printable resume pages", + message: "Resume", + })}` + : t({ + comment: "Browser tab title before printable resume data finishes loading", + message: "Resume", + }), + }, + ], }), }); diff --git a/src/schema/resume/data.ts b/src/schema/resume/data.ts index 172e2068d..a30171d45 100644 --- a/src/schema/resume/data.ts +++ b/src/schema/resume/data.ts @@ -29,7 +29,7 @@ export const pictureSchema = z.object({ url: z .string() .describe( - "The URL to the picture to display on the resume. Must be a valid URL with a protocol (http:// or https://).", + "The URL to the picture to display on the resume. Prefer local app-served paths (for example /uploads/...) populated via upload.", ), size: z .number() diff --git a/src/schema/resume/sample.ts b/src/schema/resume/sample.ts index 95db997e3..75896cb8a 100644 --- a/src/schema/resume/sample.ts +++ b/src/schema/resume/sample.ts @@ -3,7 +3,7 @@ import type { ResumeData } from "./data"; export const sampleResumeData: ResumeData = { picture: { hidden: false, - url: "https://i.imgur.com/o4Jpt1p.jpeg", + url: "", size: 100, rotation: 0, aspectRatio: 1, diff --git a/src/server.ts b/src/server.ts index e7a282171..928de472c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3,8 +3,43 @@ import { FastResponse } from "srvx"; globalThis.Response = FastResponse; +function setIfMissing(headers: Headers, key: string, value: string) { + if (headers.has(key)) return; + headers.set(key, value); +} + +const allowedWebfontOrigins = ["https://cdn.jsdelivr.net", "https://fonts.gstatic.com"] as const; + +const fontSrcWithWebfonts = ["'self'", ...allowedWebfontOrigins].join(" "); + export default createServerEntry({ - fetch(request) { - return handler.fetch(request); + async fetch(request) { + const response = await handler.fetch(request); + + const headers = new Headers(response.headers); + const contentType = headers.get("content-type") ?? ""; + + if (request.url.includes("/printer/")) { + headers.set( + "Content-Security-Policy", + `default-src 'self'; img-src 'self' data:; font-src ${fontSrcWithWebfonts}; style-src 'self' 'unsafe-inline'; connect-src 'self'; script-src 'self' 'unsafe-inline'; frame-ancestors 'none'; base-uri 'self';`, + ); + } + + if (contentType.includes("text/html")) { + setIfMissing(headers, "Cross-Origin-Opener-Policy", "same-origin"); + setIfMissing(headers, "Cross-Origin-Resource-Policy", "same-site"); + setIfMissing( + headers, + "Content-Security-Policy", + `default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https:; font-src ${fontSrcWithWebfonts} data:; connect-src 'self' https: wss:; frame-ancestors 'none'; base-uri 'self'; form-action 'self';`, + ); + } + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); }, }); diff --git a/src/utils/env.ts b/src/utils/env.ts index ca92f2a14..c6073b52c 100644 --- a/src/utils/env.ts +++ b/src/utils/env.ts @@ -44,12 +44,16 @@ export const env = createEnv({ OAUTH_AUTHORIZATION_URL: z.url({ protocol: /https?/ }).optional(), OAUTH_TOKEN_URL: z.url({ protocol: /https?/ }).optional(), OAUTH_USER_INFO_URL: z.url({ protocol: /https?/ }).optional(), + OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS: z.string().optional(), OAUTH_SCOPES: z .string() .min(1) .transform((value) => value.split(" ")) .default(["openid", "profile", "email"]), + // AI provider URL restrictions + AI_ALLOWED_BASE_URLS: z.string().optional(), + // Email (SMTP) SMTP_HOST: z.string().min(1).optional(), SMTP_PORT: z.coerce.number().int().min(1).max(65535).default(587), diff --git a/src/utils/error-message.ts b/src/utils/error-message.ts new file mode 100644 index 000000000..c3dfb6237 --- /dev/null +++ b/src/utils/error-message.ts @@ -0,0 +1,36 @@ +import { ORPCError } from "@orpc/client"; + +export function getReadableErrorMessage(error: unknown, fallback: string): string { + if (typeof error === "string" && error) return error; + if (error instanceof Error && error.message) return error.message; + return fallback; +} + +type ErrorMessageByCode = Record; + +export function getOrpcErrorMessage( + error: unknown, + options: { + fallback: string; + byCode?: ErrorMessageByCode; + allowServerMessage?: boolean; + }, +): string { + if (!(error instanceof ORPCError)) return getReadableErrorMessage(error, options.fallback); + + const mappedMessage = options.byCode?.[error.code]; + if (mappedMessage) return mappedMessage; + + if (options.allowServerMessage && error.message) return error.message; + return options.fallback; +} + +export function getResumeErrorMessage(error: unknown): string { + return getOrpcErrorMessage(error, { + byCode: { + RESUME_SLUG_ALREADY_EXISTS: "A resume with this slug already exists.", + RESUME_LOCKED: "This resume is locked. Unlock it first to make changes.", + }, + fallback: "Something went wrong. Please try again.", + }); +} diff --git a/src/utils/url-security.ts b/src/utils/url-security.ts new file mode 100644 index 000000000..c0e00c847 --- /dev/null +++ b/src/utils/url-security.ts @@ -0,0 +1,99 @@ +import { isIP } from "node:net"; + +function normalizeHostname(hostname: string) { + return hostname.trim().toLowerCase(); +} + +function stripIpv6Brackets(hostname: string): string { + return hostname.replace(/^\[/, "").replace(/\]$/, ""); +} + +function isLoopbackOrLocalHostname(hostname: string) { + const normalized = normalizeHostname(hostname); + return ( + normalized === "localhost" || normalized === "::1" || normalized === "[::1]" || normalized.endsWith(".localhost") + ); +} + +function isPrivateIPv4(hostname: string) { + const [first = 0, second = 0] = hostname.split(".").map((part) => Number.parseInt(part, 10)); + if (Number.isNaN(first) || Number.isNaN(second)) return false; + + if (first === 10) return true; + if (first === 127) return true; + if (first === 169 && second === 254) return true; + if (first === 172 && second >= 16 && second <= 31) return true; + if (first === 192 && second === 168) return true; + if (first === 0) return true; + + return false; +} + +function isPrivateIPv6(hostname: string) { + const normalized = stripIpv6Brackets(normalizeHostname(hostname)); + return ( + normalized === "::1" || normalized.startsWith("fc") || normalized.startsWith("fd") || normalized.startsWith("fe80:") + ); +} + +export function isPrivateOrLoopbackHost(hostname: string) { + const normalized = stripIpv6Brackets(normalizeHostname(hostname)); + if (isLoopbackOrLocalHostname(normalized)) return true; + + const ipVersion = isIP(normalized); + if (ipVersion === 4) return isPrivateIPv4(normalized); + if (ipVersion === 6) return isPrivateIPv6(normalized); + + return false; +} + +export function parseUrl(input: string) { + try { + return new URL(input); + } catch { + return null; + } +} + +export function parseAllowedHostList(value?: string) { + if (!value) return new Set(); + + const hosts = value + .split(",") + .map((entry) => entry.trim().toLowerCase()) + .filter(Boolean); + + return new Set(hosts); +} + +export function isAllowedExternalUrl(input: string, allowedHosts: Set) { + const parsed = parseUrl(input); + if (!parsed) return false; + if (parsed.protocol !== "https:") return false; + if (parsed.username || parsed.password) return false; + if (isPrivateOrLoopbackHost(parsed.hostname)) return false; + + const hostname = normalizeHostname(parsed.hostname); + if (allowedHosts.has(hostname)) return true; + + const origin = parsed.origin.toLowerCase(); + return allowedHosts.has(origin); +} + +export function sanitizeResumePictureUrl(url: string, appUrl: string) { + if (!url) return ""; + if (url.startsWith("/uploads/")) return url; + + const parsed = parseUrl(url); + if (!parsed) return ""; + if (parsed.username || parsed.password) return ""; + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return ""; + + const app = parseUrl(appUrl); + if (!app) return ""; + + if (parsed.origin !== app.origin) return ""; + if (!parsed.pathname.startsWith("/uploads/")) return ""; + + return `${parsed.pathname}${parsed.search}${parsed.hash}`; +} diff --git a/vite.config.ts b/vite.config.ts index 22910023b..038bd8183 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -221,15 +221,17 @@ const config = defineConfig({ }, plugins: [ "eslint", + "import", + "jest", + "jsdoc", + "jsx-a11y", + "node", + "oxc", + "promise", + "react-perf", + "react", "typescript", "unicorn", - "oxc", - "react", - "react-perf", - "import", - "jsdoc", - "node", - "promise", "vitest", ], rules: {