From 0d6a43ce41a5ac97a68e512527b9b790bb853e55 Mon Sep 17 00:00:00 2001 From: Hendrik Belitz Date: Thu, 4 Jun 2026 18:23:33 +0200 Subject: [PATCH 1/2] Always use tenant domain for base url when resending confirmation emails in prod. --- src/lib/server/services/user-service.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/lib/server/services/user-service.ts b/src/lib/server/services/user-service.ts index 2f34b1f..ef042cc 100644 --- a/src/lib/server/services/user-service.ts +++ b/src/lib/server/services/user-service.ts @@ -20,6 +20,7 @@ import { InviteService } from "./invite-service"; import type { PgTransaction } from "drizzle-orm/pg-core"; import type { PostgresJsQueryResultHKT } from "drizzle-orm/postgres-js"; import { AppointmentService } from "./appointment-service"; +import { dev } from "$app/environment"; export type InsertUser = InferInsertModel; export type InsertUserInvite = InferInsertModel; @@ -227,29 +228,27 @@ export class UserService { const tokenValidUntil = addMinutes(new Date(), 10); try { - const result = await centralDb + const [user] = await centralDb .update(centralSchema.userInvite) .set({ inviteCode: token, expiresAt: tokenValidUntil }) .where(eq(centralSchema.userInvite.email, email)) .returning(); - if (result.length !== 1) { + if (!user) { log.warn("Failed to resend confirmation email: User not found", { email }); throw new NotFoundError(`Could not resend confirmation mail for unknown user ${email}`); } - const user = result[0]; - log.debug("Confirmation email resent successfully", { email, tokenValidUntil }); - // Send confirmation email with new token - use tenant-specific branding if available try { const tenant = await getTenantForUser(user); + const linkUrl = dev ? requestUrl : new URL(`https://${tenant.domain}`); await sendConfirmationEmail( user, tenant, token, 10, // 10 minutes expiration to match tokenValidUntil - requestUrl, + linkUrl, ); log.debug("Confirmation email sent successfully", { userId: user.id, From 9b253bd04f19af9846a35b34894176f04f0b15c4 Mon Sep 17 00:00:00 2001 From: Karl Ludwig Weise Date: Tue, 16 Jun 2026 21:08:13 +0200 Subject: [PATCH 2/2] Introduced MANAGEMENT_DOMAIN env var to fix hostname security issue --- .env.example | 1 + .github/workflows/main-deploy.yml | 3 +++ .github/workflows/pr-checks.yml | 2 ++ .github/workflows/release.yml | 2 ++ docker-compose.prod.yml | 2 ++ .../__tests__/registration-bootstrap.test.ts | 1 + src/lib/server/auth/booking-access-token.ts | 4 ++++ src/lib/server/services/user-service.ts | 18 ++++++++++++++++-- .../__tests__/challenge-bootstrap.test.ts | 1 + .../__tests__/register-bootstrap.test.ts | 1 + 10 files changed, 33 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 8ae2ba8..b7fa742 100644 --- a/.env.example +++ b/.env.example @@ -21,5 +21,6 @@ NODE_ENV=development APP_PORT=5173 JWT_SECRET=devsecretforjwtencryptionchangeforproduction +MANAGEMENT_DOMAIN=management.example.com DATABASE_URL="postgres://$POSTGRES_USER:$POSTGRES_PASSWORD@localhost:$POSTGRES_PORT/$POSTGRES_DB" diff --git a/.github/workflows/main-deploy.yml b/.github/workflows/main-deploy.yml index 28fa644..ca8e0eb 100644 --- a/.github/workflows/main-deploy.yml +++ b/.github/workflows/main-deploy.yml @@ -42,6 +42,7 @@ jobs: SMTP_FROM_NAME: "Open Reception" SMTP_FROM_EMAIL: "noreply@example.com" JWT_SECRET: "test" + MANAGEMENT_DOMAIN: "example.com" - name: Build application run: npm run build @@ -55,6 +56,7 @@ jobs: SMTP_FROM_NAME: "Open Reception" SMTP_FROM_EMAIL: "noreply@example.com" JWT_SECRET: "test" + MANAGEMENT_DOMAIN: "example.com" # - name: Install Playwright browsers # run: npx playwright install --with-deps @@ -70,3 +72,4 @@ jobs: # SMTP_FROM_NAME: "Open Reception" # SMTP_FROM_EMAIL: "noreply@example.com" # JWT_SECRET: "test" + # MANAGEMENT_DOMAIN: "example.com" diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index d899ebe..a0ac18d 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -70,6 +70,7 @@ jobs: SMTP_FROM_NAME: "Open Reception" SMTP_FROM_EMAIL: "noreply@example.com" JWT_SECRET: "test" + MANAGEMENT_DOMAIN: "example.com" build: runs-on: ubuntu-latest @@ -99,3 +100,4 @@ jobs: SMTP_FROM_NAME: "Open Reception" SMTP_FROM_EMAIL: "noreply@example.com" JWT_SECRET: "test" + MANAGEMENT_DOMAIN: "example.com" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9601294..4f8486f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,12 +50,14 @@ jobs: SMTP_FROM_NAME: "Open Reception" SMTP_FROM_EMAIL: "noreply@example.com" JWT_SECRET: "test" + MANAGEMENT_DOMAIN: "example.com" - name: Build application run: npm run build env: DATABASE_URL: "postgresql://test:test@localhost:5432/test" JWT_SECRET: "build" + MANAGEMENT_DOMAIN: "example.com" - name: Set up QEMU uses: docker/setup-qemu-action@v3 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index db303b1..dff263c 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -61,6 +61,7 @@ services: - smtp_from_name - smtp_from_email - jwt_secret + - management_domain depends_on: postgres: condition: service_healthy @@ -93,6 +94,7 @@ services: export SMTP_FROM_NAME=$$(cat /run/secrets/smtp_from_name) export SMTP_FROM_EMAIL=$$(cat /run/secrets/smtp_from_email) export JWT_SECRET=$$(cat /run/secrets/jwt_secret) + export MANAGEMENT_DOMAIN=$$(cat /run/secrets/management_domain) exec node build/index.js " read_only: true diff --git a/src/lib/server/auth/__tests__/registration-bootstrap.test.ts b/src/lib/server/auth/__tests__/registration-bootstrap.test.ts index f29467a..fc7a7e3 100644 --- a/src/lib/server/auth/__tests__/registration-bootstrap.test.ts +++ b/src/lib/server/auth/__tests__/registration-bootstrap.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect, vi } from "vitest"; vi.mock("$env/dynamic/private", () => ({ env: { JWT_SECRET: "test-jwt-secret-for-registration-bootstrap-unit-tests-minimum-32-chars", + MANAGEMENT_DOMAIN: "example.com", }, })); diff --git a/src/lib/server/auth/booking-access-token.ts b/src/lib/server/auth/booking-access-token.ts index b5b3823..1d874a5 100644 --- a/src/lib/server/auth/booking-access-token.ts +++ b/src/lib/server/auth/booking-access-token.ts @@ -14,6 +14,10 @@ export type BookingAccessScope = | typeof EXISTING_CLIENT_BOOKING_SCOPE | typeof NEW_CLIENT_BOOTSTRAP_SCOPE; +if (!process.env.BUILDING && !env.MANAGEMENT_DOMAIN) { + throw new Error("Mandatory ENV variable MANAGEMENT_DOMAIN is missing!"); +} + if (!process.env.BUILDING && !env.JWT_SECRET) { throw new Error("Mandatory ENV variable JWT_SECRET is missing!"); } diff --git a/src/lib/server/services/user-service.ts b/src/lib/server/services/user-service.ts index ef042cc..605aa09 100644 --- a/src/lib/server/services/user-service.ts +++ b/src/lib/server/services/user-service.ts @@ -13,6 +13,7 @@ import { hashPassphrase, validatePassphraseStrength, } from "../utils/passphrase"; +import { env } from "$env/dynamic/private"; import { sendConfirmationEmail } from "../email/email-service"; import type { SelectTenant } from "../db/central-schema"; import { TenantAdminService } from "./tenant-admin-service"; @@ -182,16 +183,23 @@ export class UserService { hasRecoveryPassphrase: !!insertedUser.recoveryPassphrase, }); + if (!env.MANAGEMENT_DOMAIN) { + throw new InternalError(`MANAGEMENT_DOMAIN is missing`); + } + // Send confirmation email to user (token is used as confirmation code) try { if (insertedUser?.email && inviteResult?.inviteCode) { const tenant = await getTenantForUser(insertedUser); + const linkUrl = dev + ? requestUrl + : new URL(`https://${tenant.id === "system" ? env.MANAGEMENT_DOMAIN : tenant.domain}`); await sendConfirmationEmail( insertedUser, tenant, inviteResult.inviteCode, 10, // 10 minutes expiration to match tokenValidUntil - requestUrl, + linkUrl, ); log.debug("Confirmation email sent successfully", { userId: insertedUser.id, @@ -239,10 +247,16 @@ export class UserService { throw new NotFoundError(`Could not resend confirmation mail for unknown user ${email}`); } + if (!env.MANAGEMENT_DOMAIN) { + throw new InternalError(`MANAGEMENT_DOMAIN is missing`); + } + // Send confirmation email with new token - use tenant-specific branding if available try { const tenant = await getTenantForUser(user); - const linkUrl = dev ? requestUrl : new URL(`https://${tenant.domain}`); + const linkUrl = dev + ? requestUrl + : new URL(`https://${tenant.id === "system" ? env.MANAGEMENT_DOMAIN : tenant.domain}`); await sendConfirmationEmail( user, tenant, diff --git a/src/routes/api/auth/challenge/__tests__/challenge-bootstrap.test.ts b/src/routes/api/auth/challenge/__tests__/challenge-bootstrap.test.ts index 491ca39..1b07281 100644 --- a/src/routes/api/auth/challenge/__tests__/challenge-bootstrap.test.ts +++ b/src/routes/api/auth/challenge/__tests__/challenge-bootstrap.test.ts @@ -6,6 +6,7 @@ vi.mock("$env/dynamic/private", () => ({ env: { JWT_SECRET: "test-jwt-secret-for-registration-bootstrap-unit-tests-minimum-32-chars", NODE_ENV: "test", + MANAGEMENT_DOMAIN: "example.com", }, })); diff --git a/src/routes/api/auth/register/__tests__/register-bootstrap.test.ts b/src/routes/api/auth/register/__tests__/register-bootstrap.test.ts index 783fc67..3bff0e6 100644 --- a/src/routes/api/auth/register/__tests__/register-bootstrap.test.ts +++ b/src/routes/api/auth/register/__tests__/register-bootstrap.test.ts @@ -6,6 +6,7 @@ vi.mock("$env/dynamic/private", () => ({ env: { JWT_SECRET: "test-jwt-secret-for-registration-bootstrap-unit-tests-minimum-32-chars", NODE_ENV: "test", + MANAGEMENT_DOMAIN: "example.com", }, }));