diff --git a/.env.example b/.env.example index 9372515..d4bfe39 100644 --- a/.env.example +++ b/.env.example @@ -11,7 +11,8 @@ DATABASE_POOL_SIZE=10 # Better Auth email/password administrator and customer login BETTER_AUTH_URL=http://localhost:3000 BETTER_AUTH_SECRET=replace-with-at-least-32-random-bytes -# Separate trusted browser origins with commas for local development or proxies. +# Comma-separated site origins; also used for auth callback hosts and API mutations. +# Production: https://guide.sudloh.com,https://sudloh.com BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000 # Optional for local development; required by the production deployment. GOOGLE_CLIENT_ID=replace-with-google-oauth-client-id diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 2ec2d75..7f7756c 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -7,6 +7,7 @@ on: env: BASE_URL: https://guide.sudloh.com + BETTER_AUTH_TRUSTED_ORIGINS: https://guide.sudloh.com,https://sudloh.com NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID: ca-pub-9687404323559597 REGISTRY_IMAGE: registry.neko-piranha.ts.net/astral/buzz-sheet DEPLOY_NAMESPACE: buzz-sheet @@ -169,7 +170,8 @@ jobs: kubectl --kubeconfig "$KUBECONFIG" --namespace "$DEPLOY_NAMESPACE" patch configmap buzz-sheet-config \ --type=merge \ - --patch "{\"data\":{\"NEXT_DEPLOYMENT_ID\":\"$REVISION\",\"BASE_URL\":\"$BASE_URL\"}}" + --patch "{\"data\":{\"NEXT_DEPLOYMENT_ID\":\"$REVISION\",\"BASE_URL\":\"$BASE_URL\",\"BETTER_AUTH_TRUSTED_ORIGINS\":\"$BETTER_AUTH_TRUSTED_ORIGINS\"}}" + kubectl --kubeconfig "$KUBECONFIG" --namespace "$DEPLOY_NAMESPACE" apply --filename k8s/base/ingress.yaml # Replace one web replica at a time without reserving CPU for a surge pod. kubectl --kubeconfig "$KUBECONFIG" --namespace "$DEPLOY_NAMESPACE" patch deployment buzz-sheet \ --type=merge \ diff --git a/README.md b/README.md index aab0480..9f9806b 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ A Thai-first Genshin Impact guide CMS for publishing character builds, teams, rotations, and comparisons from structured data. -**Live site:** [guide.sudloh.com](https://guide.sudloh.com) +**Live site:** [guide.sudloh.com](https://guide.sudloh.com) and [sudloh.com](https://sudloh.com) Buzz Guide is an independent community project and is not affiliated with or endorsed by HoYoverse. The repository and deployment resources retain the @@ -296,7 +296,7 @@ Kustomize resources live in `k8s/` and define: - Namespace `buzz-sheet`. - Two rolling web replicas, one outbox worker, and one Discord catalog worker. -- ClusterIP port 3000 and Traefik ingress for `guide.sudloh.com`. +- ClusterIP port 3000 and Traefik ingress for `guide.sudloh.com` and `sudloh.com`. - Cloudflare edge TLS with the cluster's standard HTTP Traefik origin route. - Startup, liveness, and dependency-aware readiness probes. - Web requests of 500m CPU/1 GiB and limits of 1 CPU/2 GiB. @@ -352,7 +352,18 @@ ConfigMap or commit them to this repository. Set `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` in the application environment. In Google Cloud, register `{BETTER_AUTH_URL}/api/auth/callback/google` as an authorized redirect URI for each environment (for production, -`https://guide.sudloh.com/api/auth/callback/google`). +`https://guide.sudloh.com/api/auth/callback/google` and +`https://sudloh.com/api/auth/callback/google`). + +Both production domains serve the same application. `BASE_URL` remains +`https://guide.sudloh.com` for canonical metadata and generated links. +`BETTER_AUTH_TRUSTED_ORIGINS` lists both HTTPS origins so sign-in callbacks and +API mutations work on either domain. Sessions use separate host cookies. +Point `sudloh.com` at the same Cloudflare/Traefik origin as `guide.sudloh.com`, +enable edge TLS, and allow both hostnames in the Cloudflare Turnstile widget. +For an existing cluster, apply the updated CI role once before the next release: +`kubectl apply --namespace buzz-sheet --filename k8s/base/ci-rbac.yaml`. +CI updates the existing ingress and trusted origins during each rollout. #### Email verification diff --git a/k8s/base/ci-rbac.yaml b/k8s/base/ci-rbac.yaml index d1d26e0..936fa30 100644 --- a/k8s/base/ci-rbac.yaml +++ b/k8s/base/ci-rbac.yaml @@ -14,6 +14,10 @@ metadata: app.kubernetes.io/name: buzz-sheet app.kubernetes.io/component: ci rules: + - apiGroups: ["networking.k8s.io"] + resources: ["ingresses"] + resourceNames: ["buzz-sheet"] + verbs: ["get", "patch", "update"] - apiGroups: [""] resources: ["configmaps"] resourceNames: ["buzz-sheet-config"] diff --git a/k8s/base/configmap.yaml b/k8s/base/configmap.yaml index 3d7f7ed..af56c03 100644 --- a/k8s/base/configmap.yaml +++ b/k8s/base/configmap.yaml @@ -7,6 +7,7 @@ metadata: data: BASE_URL: https://guide.sudloh.com BETTER_AUTH_URL: https://guide.sudloh.com + BETTER_AUTH_TRUSTED_ORIGINS: https://guide.sudloh.com,https://sudloh.com # Traefik supplies X-Forwarded-For and does not trust incoming forwarded headers. # The network policy allows only Traefik to reach the app. TRUSTED_CLIENT_IP_HEADER: x-forwarded-for diff --git a/k8s/base/ingress.yaml b/k8s/base/ingress.yaml index ce71ad3..ccb93e4 100644 --- a/k8s/base/ingress.yaml +++ b/k8s/base/ingress.yaml @@ -9,6 +9,16 @@ metadata: spec: ingressClassName: traefik rules: + - host: sudloh.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: buzz-sheet + port: + number: 3000 - host: guide.sudloh.com http: paths: diff --git a/lib/auth/server.test.ts b/lib/auth/server.test.ts index a89e7bd..8e14f58 100644 --- a/lib/auth/server.test.ts +++ b/lib/auth/server.test.ts @@ -11,7 +11,7 @@ vi.mock("better-auth/plugins", () => ({ admin: (options: unknown) => options, captcha: (options: unknown) => ({ id: "captcha", options }), emailOTP: (options: unknown) => ({ id: "email-otp", options }) })); -const envNames = ["NODE_ENV", "DATABASE_URL", "BETTER_AUTH_URL", "BETTER_AUTH_SECRET", "GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET", "TURNSTILE_SECRET_KEY", "BUZZ_DEMO_MODE", "RESEND_API_KEY", "TRUSTED_CLIENT_IP_HEADER"] as const; +const envNames = ["NODE_ENV", "DATABASE_URL", "BETTER_AUTH_URL", "BETTER_AUTH_TRUSTED_ORIGINS", "BETTER_AUTH_SECRET", "GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET", "TURNSTILE_SECRET_KEY", "BUZZ_DEMO_MODE", "RESEND_API_KEY", "TRUSTED_CLIENT_IP_HEADER"] as const; const testEnv = process.env as Record; const originalEnv = envNames.map((name) => testEnv[name]); @@ -27,6 +27,7 @@ beforeEach(() => { mocks.auth.mockReturnValue({ api: { getSession: mocks.session } }); mocks.session.mockResolvedValue(null); delete testEnv.TRUSTED_CLIENT_IP_HEADER; + delete testEnv.BETTER_AUTH_TRUSTED_ORIGINS; }); afterEach(() => { envNames.forEach((name, index) => { @@ -37,6 +38,19 @@ afterEach(() => { }); describe("actual administrator session boundary", () => { + it("resolves auth callbacks on either configured production host", async () => { + testEnv.BETTER_AUTH_URL = "https://guide.sudloh.com"; + testEnv.BETTER_AUTH_TRUSTED_ORIGINS = "https://guide.sudloh.com, https://sudloh.com"; + (await import("./server")).getAuth(); + const options = mocks.auth.mock.calls.at(-1)![0]; + expect(options.baseURL).toEqual({ + allowedHosts: ["guide.sudloh.com", "guide.sudloh.com", "sudloh.com"], + fallback: "https://guide.sudloh.com", + protocol: "https", + }); + expect(options.trustedOrigins).toEqual(["https://guide.sudloh.com", "https://sudloh.com"]); + }); + it("does not grant access when the old demo flag is enabled", async () => { testEnv.BUZZ_DEMO_MODE = "true"; const { getAdminSession, requireAdmin } = await import("./server"); diff --git a/lib/auth/server.ts b/lib/auth/server.ts index 92fd21c..b17187d 100644 --- a/lib/auth/server.ts +++ b/lib/auth/server.ts @@ -47,6 +47,9 @@ function hasAuthConfiguration(): boolean { function createAuth() { const google = googleCredentials(); + const baseURL = required("BETTER_AUTH_URL"); + const trustedOrigins = process.env.BETTER_AUTH_TRUSTED_ORIGINS + ?.split(",").map((origin) => origin.trim()).filter(Boolean); return betterAuth({ appName: "Buzz Guide", database: drizzleAdapter(getDb(), { @@ -59,11 +62,12 @@ function createAuth() { }, transaction: true, }), - baseURL: required("BETTER_AUTH_URL"), - trustedOrigins: process.env.BETTER_AUTH_TRUSTED_ORIGINS - ?.split(",") - .map((origin) => origin.trim()) - .filter(Boolean), + baseURL: trustedOrigins?.length ? { + allowedHosts: [baseURL, ...trustedOrigins].map((origin) => new URL(origin).host), + fallback: baseURL, + protocol: new URL(baseURL).protocol === "https:" ? "https" : "http", + } : baseURL, + trustedOrigins, secret: required("BETTER_AUTH_SECRET"), socialProviders: google ? { google } : undefined, account: { diff --git a/lib/security/http.test.ts b/lib/security/http.test.ts index ae8f17e..bab81e8 100644 --- a/lib/security/http.test.ts +++ b/lib/security/http.test.ts @@ -3,10 +3,13 @@ import { boundedBody, errorResponse, HttpError, readJson, requireSameOrigin, wit import { trustedClientAddress } from "./rate-limit"; const originalAuthUrl = process.env.BETTER_AUTH_URL; +const originalTrustedOrigins = process.env.BETTER_AUTH_TRUSTED_ORIGINS; const originalClientIpHeader = process.env.TRUSTED_CLIENT_IP_HEADER; afterEach(() => { vi.restoreAllMocks(); + if (originalTrustedOrigins === undefined) delete process.env.BETTER_AUTH_TRUSTED_ORIGINS; + else process.env.BETTER_AUTH_TRUSTED_ORIGINS = originalTrustedOrigins; if (originalAuthUrl === undefined) delete process.env.BETTER_AUTH_URL; else process.env.BETTER_AUTH_URL = originalAuthUrl; if (originalClientIpHeader === undefined) delete process.env.TRUSTED_CLIENT_IP_HEADER; @@ -14,6 +17,20 @@ afterEach(() => { }); describe("request boundaries", () => { + it("allows both configured domains while rejecting unlisted origins", () => { + process.env.BETTER_AUTH_URL = "https://guide.sudloh.com"; + process.env.BETTER_AUTH_TRUSTED_ORIGINS = "https://guide.sudloh.com, https://sudloh.com"; + for (const origin of ["https://guide.sudloh.com", "https://sudloh.com"]) { + expect(() => requireSameOrigin(new Request("http://internal", { headers: { origin } }))).not.toThrow(); + } + for (const origin of ["http://sudloh.com", "https://evil.sudloh.com", "https://sudloh.com.evil.test"]) { + expect(() => requireSameOrigin(new Request("http://internal", { headers: { origin } }))).toThrow("cross-origin"); + } + expect(() => requireSameOrigin(new Request("http://internal", { + headers: { origin: "https://sudloh.com", "sec-fetch-site": "cross-site" }, + }))).toThrow("cross-origin"); + }); + it("compares origin to configuration, not attacker-controlled Host", () => { process.env.BETTER_AUTH_URL = "https://guide.example.test"; expect(() => requireSameOrigin(new Request("https://evil.test", { headers: { origin: "https://evil.test", host: "evil.test" } }))).toThrow("cross-origin"); diff --git a/lib/security/http.ts b/lib/security/http.ts index 775eff8..3263514 100644 --- a/lib/security/http.ts +++ b/lib/security/http.ts @@ -25,8 +25,11 @@ export function errorResponse(cause: unknown): Response { export function requireSameOrigin(request: Request) { const configured = process.env.BETTER_AUTH_URL; if (!configured) throw new HttpError(503, "origin-not-configured"); + const allowedOrigins = [new URL(configured).origin, ...( + process.env.BETTER_AUTH_TRUSTED_ORIGINS?.split(",").map((value) => value.trim()).filter(Boolean) ?? [] + )]; const origin = request.headers.get("origin"); - if (origin !== new URL(configured).origin || request.headers.get("sec-fetch-site") === "cross-site") { + if (!origin || !allowedOrigins.includes(origin) || request.headers.get("sec-fetch-site") === "cross-site") { throw new HttpError(403, "cross-origin-request"); } } diff --git a/tests/deployment-contract.test.ts b/tests/deployment-contract.test.ts index 42a399f..4ad2e79 100644 --- a/tests/deployment-contract.test.ts +++ b/tests/deployment-contract.test.ts @@ -25,7 +25,7 @@ describe("production deployment contract", () => { expect(deployment).toContain('drop: ["ALL"]'); }); - it("exposes only the app through the requested edge-TLS host", async () => { + it("exposes only the app through both edge-TLS hosts", async () => { const [service, ingress] = await Promise.all([ repositoryFile("k8s/base/service.yaml"), repositoryFile("k8s/base/ingress.yaml"), @@ -36,6 +36,7 @@ describe("production deployment contract", () => { expect(service).toContain("targetPort: http"); expect(ingress).toContain("ingressClassName: traefik"); expect(ingress).toContain("host: guide.sudloh.com"); + expect(ingress).toContain("host: sudloh.com"); expect(ingress).toContain( 'traefik.ingress.kubernetes.io/read-timeout: "200"', ); @@ -129,6 +130,8 @@ describe("production deployment contract", () => { expect(workflow).toContain("bun run typecheck"); expect(workflow).toContain("bun run lint"); expect(workflow).toContain("kubectl kustomize k8s/"); + expect(workflow).toContain("apply --filename k8s/base/ingress.yaml"); + expect(workflow).toContain('\\"BETTER_AUTH_TRUSTED_ORIGINS\\":\\"$BETTER_AUTH_TRUSTED_ORIGINS\\"'); expect(workflow).toContain("needs: verify"); expect(workflow).not.toContain("pull_request:"); expect(workflow).toContain("--insecure-skip-tls-verify=true");