From 1a6fc30b746729f9817997c963c0e9c7bd400341 Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 12 Aug 2026 03:07:07 -0400 Subject: [PATCH 1/3] fix: harden public origin URLs --- Dockerfile | 2 +- .../(dashboard)/settings/developers/page.tsx | 5 +- .../settings/email/replies/page.tsx | 2 + .../app/(dashboard)/settings/portal/page.tsx | 3 +- .../dashboard/jobs/[jobId]/page.tsx | 3 +- .../api/integrations/google/callback/route.ts | 5 +- .../integrations/outlook/callback/route.ts | 9 ++-- .../api/integrations/outlook/install/route.ts | 5 +- .../api/integrations/slack/callback/route.ts | 9 ++-- .../api/integrations/slack/install/route.ts | 5 +- .../api/integrations/zoom/callback/route.ts | 9 ++-- .../api/integrations/zoom/install/route.ts | 9 ++-- .../api/portal/auth/callback/github/route.ts | 3 +- .../api/portal/auth/callback/google/route.ts | 3 +- .../portal/auth/callback/linkedin/route.ts | 3 +- apps/web/src/app/api/portal/auth/route.ts | 3 +- .../app/api/public/v1/image/presign/route.ts | 3 +- .../public/v1/resume/presign/route.test.ts | 48 +++++++++++++++++++ .../app/api/public/v1/resume/presign/route.ts | 3 +- apps/web/src/app/api/setup/claim/route.ts | 8 +--- apps/web/src/app/api/setup/complete/route.ts | 8 +--- apps/web/src/app/api/v1/openapi.json/route.ts | 6 +-- apps/web/src/app/layout.tsx | 7 +-- apps/web/src/app/robots.ts | 3 +- apps/web/src/app/sitemap.ts | 3 +- apps/web/src/features/auth/actions.ts | 7 +-- apps/web/src/features/jobs/service.ts | 6 +-- apps/web/src/features/portal/actions.ts | 3 +- .../workspaces/ReplyHandlingSettingsCard.tsx | 7 ++- apps/web/src/features/workspaces/actions.ts | 17 ++----- .../workspaces/cal-settings-actions.ts | 8 +--- apps/web/src/lib/email/branding.ts | 7 +-- apps/web/src/lib/email/outbox-processor.ts | 7 +-- apps/web/src/lib/gcal/config.ts | 5 +- apps/web/src/lib/passkey.ts | 8 +--- apps/web/src/lib/public-origin.test.ts | 22 +++++++++ apps/web/src/lib/public-origin.ts | 32 +++++++++++++ apps/web/src/proxy.ts | 16 ++++--- apps/web/src/server/notify/dispatch.ts | 5 +- apps/web/src/server/notify/slack.ts | 3 +- apps/web/src/server/scim/http.ts | 3 +- packages/config/README.md | 3 +- packages/config/src/index.test.ts | 12 +++++ packages/config/src/index.ts | 9 +++- 44 files changed, 215 insertions(+), 132 deletions(-) diff --git a/Dockerfile b/Dockerfile index 737fdcb3..c2459c24 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,7 +20,7 @@ COPY . . ENV NEXT_TELEMETRY_DISABLED=1 \ NODE_OPTIONS=--max-old-space-size=4096 \ HARLY_DISABLE_WEBPACK_BUILD_WORKER=1 \ - HARLY_URL=http://localhost:3000 \ + HARLY_URL=https://build.invalid \ DATABASE_URL=postgresql://build:build@127.0.0.1:5432/build \ BETTER_AUTH_SECRET=build-only-better-auth-secret-000000000000 \ AI_ENCRYPTION_KEY=build-only-ai-encryption-key-0000000000000 \ diff --git a/apps/web/src/app/(dashboard)/settings/developers/page.tsx b/apps/web/src/app/(dashboard)/settings/developers/page.tsx index 5ee60b4a..c1bc9fe8 100644 --- a/apps/web/src/app/(dashboard)/settings/developers/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/developers/page.tsx @@ -17,6 +17,7 @@ import { WEBHOOK_EVENTS, WEBHOOK_EVENT_LABELS, } from "@/server/webhooks/events"; +import { getHarlyPublicOrigin } from "@/lib/public-origin"; export const dynamic = "force-dynamic"; @@ -41,9 +42,7 @@ export default async function DevelopersSettingsPage() { })), ); - const appUrl = ( - process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000" - ).replace(/\/$/, ""); + const appUrl = getHarlyPublicOrigin(); return ( ); diff --git a/apps/web/src/app/(dashboard)/settings/portal/page.tsx b/apps/web/src/app/(dashboard)/settings/portal/page.tsx index dfbb3010..3cc97966 100644 --- a/apps/web/src/app/(dashboard)/settings/portal/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/portal/page.tsx @@ -5,6 +5,7 @@ import { getWorkspaceContext } from "@/features/workspaces/context"; import { requirePagePermission } from "@/features/workspaces/permissions-server"; import { CandidatePortalCard } from "@/features/workspaces/CandidatePortalCard"; import { getWorkspaceEmailStatus } from "@/lib/email/config"; +import { getHarlyPublicOrigin } from "@/lib/public-origin"; export const dynamic = "force-dynamic"; @@ -46,7 +47,7 @@ export default async function PortalSettingsPage() { row?.hasLinkedinClientId && row?.hasLinkedinSecret, )} linkedinClientId={row?.hasLinkedinClientId ?? ""} - appUrl={process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"} + appUrl={getHarlyPublicOrigin()} showApplicationStatus={row?.portalShowApplicationStatus ?? true} showHiringTeam={row?.portalShowHiringTeam ?? false} /> diff --git a/apps/web/src/app/(fullscreen)/dashboard/jobs/[jobId]/page.tsx b/apps/web/src/app/(fullscreen)/dashboard/jobs/[jobId]/page.tsx index 59dc817e..63cb7765 100644 --- a/apps/web/src/app/(fullscreen)/dashboard/jobs/[jobId]/page.tsx +++ b/apps/web/src/app/(fullscreen)/dashboard/jobs/[jobId]/page.tsx @@ -18,6 +18,7 @@ import { JobActionsMenu } from "@/features/jobs/JobActionsMenu"; import { JobShareButton } from "@/features/jobs/JobShareButton"; import { JobStatusActions } from "@/features/jobs/JobStatusActions"; import { getWorkspaceAiStatus } from "@/lib/ai/config"; +import { getHarlyPublicOrigin } from "@/lib/public-origin"; import { countCandidatePool } from "@/features/matching/data"; import { getWorkspaceContext } from "@/features/workspaces/context"; @@ -50,7 +51,7 @@ export default async function DashboardJobPage({ } const { job } = result; - const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"; + const appUrl = getHarlyPublicOrigin(); const publicUrl = `${appUrl}/jobs/${job.slug}`; return ( diff --git a/apps/web/src/app/api/integrations/google/callback/route.ts b/apps/web/src/app/api/integrations/google/callback/route.ts index 4de31539..ccc13c97 100644 --- a/apps/web/src/app/api/integrations/google/callback/route.ts +++ b/apps/web/src/app/api/integrations/google/callback/route.ts @@ -6,6 +6,7 @@ import { auth } from "@/lib/auth"; import { encryptSecret } from "@/lib/crypto"; import { createOAuth2Client } from "@/lib/gcal/config"; import { createLogger } from "@/lib/logger"; +import { getHarlyPublicOrigin } from "@/lib/public-origin"; import { requirePermission } from "@/features/workspaces/permissions-server"; import { verifyAndConsumeOauthStateNonce, @@ -121,9 +122,7 @@ export async function GET(req: NextRequest) { } function getAppUrl(): string { - return ( - process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000" - ).replace(/\/$/, ""); + return getHarlyPublicOrigin(); } function redirectWithError(msg: string) { diff --git a/apps/web/src/app/api/integrations/outlook/callback/route.ts b/apps/web/src/app/api/integrations/outlook/callback/route.ts index 7592c11e..f9fcefc6 100644 --- a/apps/web/src/app/api/integrations/outlook/callback/route.ts +++ b/apps/web/src/app/api/integrations/outlook/callback/route.ts @@ -7,6 +7,7 @@ import { encryptSecret } from "@/lib/crypto"; import { createLogger } from "@/lib/logger"; import { getWorkspaceOutlookCredentials } from "@/lib/outlook/config"; import { getMe } from "@/lib/outlook/client"; +import { getHarlyPublicOrigin } from "@/lib/public-origin"; import { requirePermission } from "@/features/workspaces/permissions-server"; import { verifyAndConsumeOauthStateNonce } from "@/server/oauth-state"; @@ -70,9 +71,7 @@ export async function GET(req: NextRequest) { ); } - const appUrl = ( - process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000" - ).replace(/\/$/, ""); + const appUrl = getHarlyPublicOrigin(); const redirectUri = `${appUrl}/api/integrations/outlook/callback`; // Exchange code for tokens @@ -146,9 +145,7 @@ export async function GET(req: NextRequest) { } function redirectWithError(msg: string) { - const appUrl = ( - process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000" - ).replace(/\/$/, ""); + const appUrl = getHarlyPublicOrigin(); const url = new URL(`${appUrl}/settings/integrations`); url.searchParams.set("outlook_error", msg); return NextResponse.redirect(url.toString()); diff --git a/apps/web/src/app/api/integrations/outlook/install/route.ts b/apps/web/src/app/api/integrations/outlook/install/route.ts index 348c5791..95ab8dbb 100644 --- a/apps/web/src/app/api/integrations/outlook/install/route.ts +++ b/apps/web/src/app/api/integrations/outlook/install/route.ts @@ -2,6 +2,7 @@ import { NextResponse, type NextRequest } from "next/server"; import { auth } from "@/lib/auth"; import { getWorkspaceOutlookCredentials } from "@/lib/outlook/config"; +import { getHarlyPublicOrigin } from "@/lib/public-origin"; import { requirePermission } from "@/features/workspaces/permissions-server"; import { createInstallState } from "@/server/oauth-state"; @@ -48,9 +49,7 @@ export async function GET(req: NextRequest) { ); } - const appUrl = ( - process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000" - ).replace(/\/$/, ""); + const appUrl = getHarlyPublicOrigin(); const redirectUri = `${appUrl}/api/integrations/outlook/callback`; const state = await createInstallState({ diff --git a/apps/web/src/app/api/integrations/slack/callback/route.ts b/apps/web/src/app/api/integrations/slack/callback/route.ts index 8289f829..c74ec18c 100644 --- a/apps/web/src/app/api/integrations/slack/callback/route.ts +++ b/apps/web/src/app/api/integrations/slack/callback/route.ts @@ -8,6 +8,7 @@ import { getWorkspaceSlackCredentials } from "@/lib/slack/config"; import { requirePermission } from "@/features/workspaces/permissions-server"; import { verifyAndConsumeOauthStateNonce } from "@/server/oauth-state"; import { logAuditEvent } from "@/lib/audit-log"; +import { getHarlyPublicOrigin } from "@/lib/public-origin"; export const runtime = "nodejs"; @@ -65,9 +66,7 @@ export async function GET(req: NextRequest) { return redirectWithError("Slack credentials not found for this workspace."); } - const appUrl = ( - process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000" - ).replace(/\/$/, ""); + const appUrl = getHarlyPublicOrigin(); const redirectUri = `${appUrl}/api/integrations/slack/callback`; let tokenRes: Response; @@ -160,9 +159,7 @@ export async function GET(req: NextRequest) { } function redirectWithError(msg: string) { - const appUrl = ( - process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000" - ).replace(/\/$/, ""); + const appUrl = getHarlyPublicOrigin(); const url = new URL(`${appUrl}/settings/integrations`); url.searchParams.set("slack_error", msg); return NextResponse.redirect(url.toString()); diff --git a/apps/web/src/app/api/integrations/slack/install/route.ts b/apps/web/src/app/api/integrations/slack/install/route.ts index 15ea9df5..894bba58 100644 --- a/apps/web/src/app/api/integrations/slack/install/route.ts +++ b/apps/web/src/app/api/integrations/slack/install/route.ts @@ -2,6 +2,7 @@ import { NextResponse, type NextRequest } from "next/server"; import { auth } from "@/lib/auth"; import { getWorkspaceSlackCredentials } from "@/lib/slack/config"; +import { getHarlyPublicOrigin } from "@/lib/public-origin"; import { requirePermission } from "@/features/workspaces/permissions-server"; import { createInstallState } from "@/server/oauth-state"; @@ -38,9 +39,7 @@ export async function GET(req: NextRequest) { ); } - const appUrl = ( - process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000" - ).replace(/\/$/, ""); + const appUrl = getHarlyPublicOrigin(); const redirectUri = `${appUrl}/api/integrations/slack/callback`; const state = await createInstallState({ diff --git a/apps/web/src/app/api/integrations/zoom/callback/route.ts b/apps/web/src/app/api/integrations/zoom/callback/route.ts index 67823bf4..27f4abac 100644 --- a/apps/web/src/app/api/integrations/zoom/callback/route.ts +++ b/apps/web/src/app/api/integrations/zoom/callback/route.ts @@ -6,6 +6,7 @@ import { auth } from "@/lib/auth"; import { encryptSecret } from "@/lib/crypto"; import { createLogger } from "@/lib/logger"; import { getZoomCredentials } from "@/lib/zoom/config"; +import { getHarlyPublicOrigin } from "@/lib/public-origin"; import { requirePermission } from "@/features/workspaces/permissions-server"; import { verifyAndConsumeOauthStateNonce } from "@/server/oauth-state"; @@ -84,9 +85,7 @@ export async function GET(req: NextRequest) { return redirectWithError("Zoom credentials not found for this workspace."); } - const appUrl = ( - process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000" - ).replace(/\/$/, ""); + const appUrl = getHarlyPublicOrigin(); const redirectUri = `${appUrl}/api/integrations/zoom/callback`; // Exchange code for token @@ -151,9 +150,7 @@ export async function GET(req: NextRequest) { } function redirectWithError(msg: string) { - const appUrl = ( - process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000" - ).replace(/\/$/, ""); + const appUrl = getHarlyPublicOrigin(); const url = new URL(`${appUrl}/settings/integrations`); url.searchParams.set("zoom_error", msg); return NextResponse.redirect(url.toString()); diff --git a/apps/web/src/app/api/integrations/zoom/install/route.ts b/apps/web/src/app/api/integrations/zoom/install/route.ts index eadf4467..d9ba7c1f 100644 --- a/apps/web/src/app/api/integrations/zoom/install/route.ts +++ b/apps/web/src/app/api/integrations/zoom/install/route.ts @@ -2,6 +2,7 @@ import { NextResponse, type NextRequest } from "next/server"; import { auth } from "@/lib/auth"; import { getZoomCredentials } from "@/lib/zoom/config"; +import { getHarlyPublicOrigin } from "@/lib/public-origin"; import { requirePermission } from "@/features/workspaces/permissions-server"; import { createInstallState } from "@/server/oauth-state"; @@ -37,9 +38,7 @@ export async function GET(req: NextRequest) { provider: "zoom", }); - const appUrl = ( - process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000" - ).replace(/\/$/, ""); + const appUrl = getHarlyPublicOrigin(); const authUrl = new URL("https://zoom.us/oauth/authorize"); authUrl.searchParams.set("response_type", "code"); @@ -52,9 +51,7 @@ export async function GET(req: NextRequest) { } function redirectWithError(msg: string) { - const appUrl = ( - process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000" - ).replace(/\/$/, ""); + const appUrl = getHarlyPublicOrigin(); const url = new URL(`${appUrl}/settings/integrations`); url.searchParams.set("zoom_error", msg); return NextResponse.redirect(url.toString()); diff --git a/apps/web/src/app/api/portal/auth/callback/github/route.ts b/apps/web/src/app/api/portal/auth/callback/github/route.ts index 1dfb9ef4..f54db7d1 100644 --- a/apps/web/src/app/api/portal/auth/callback/github/route.ts +++ b/apps/web/src/app/api/portal/auth/callback/github/route.ts @@ -11,6 +11,7 @@ import { isPortalEnabled, } from "@/lib/portal-auth"; import { PORTAL_OAUTH_STATE_COOKIE, verifyPortalOAuthState } from "@/lib/portal-oauth-state"; +import { getHarlyPublicOrigin } from "@/lib/public-origin"; export const runtime = "nodejs"; @@ -27,7 +28,7 @@ export async function GET(request: NextRequest) { } try { - const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"; + const appUrl = getHarlyPublicOrigin(); const redirectUri = `${appUrl}/api/portal/auth/callback/github`; const workspaceId = oauthState!.workspaceId; if (!(await isPortalEnabled(workspaceId))) redirect("/portal/login?error=no_workspace" as Route); diff --git a/apps/web/src/app/api/portal/auth/callback/google/route.ts b/apps/web/src/app/api/portal/auth/callback/google/route.ts index 10b39488..fcac7232 100644 --- a/apps/web/src/app/api/portal/auth/callback/google/route.ts +++ b/apps/web/src/app/api/portal/auth/callback/google/route.ts @@ -11,6 +11,7 @@ import { isPortalEnabled, } from "@/lib/portal-auth"; import { PORTAL_OAUTH_STATE_COOKIE, verifyPortalOAuthState } from "@/lib/portal-oauth-state"; +import { getHarlyPublicOrigin } from "@/lib/public-origin"; export const runtime = "nodejs"; @@ -24,7 +25,7 @@ export async function GET(request: NextRequest) { if (!code) redirect("/portal/login?error=oauth_denied" as Route); - const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"; + const appUrl = getHarlyPublicOrigin(); const redirectUri = `${appUrl}/api/portal/auth/callback/google`; try { diff --git a/apps/web/src/app/api/portal/auth/callback/linkedin/route.ts b/apps/web/src/app/api/portal/auth/callback/linkedin/route.ts index afff946e..ace05313 100644 --- a/apps/web/src/app/api/portal/auth/callback/linkedin/route.ts +++ b/apps/web/src/app/api/portal/auth/callback/linkedin/route.ts @@ -11,6 +11,7 @@ import { isPortalEnabled, } from "@/lib/portal-auth"; import { PORTAL_OAUTH_STATE_COOKIE, verifyPortalOAuthState } from "@/lib/portal-oauth-state"; +import { getHarlyPublicOrigin } from "@/lib/public-origin"; export const runtime = "nodejs"; @@ -24,7 +25,7 @@ export async function GET(request: NextRequest) { if (!code) redirect("/portal/login?error=oauth_denied" as Route); - const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"; + const appUrl = getHarlyPublicOrigin(); const redirectUri = `${appUrl}/api/portal/auth/callback/linkedin`; try { diff --git a/apps/web/src/app/api/portal/auth/route.ts b/apps/web/src/app/api/portal/auth/route.ts index da2df9ff..a66c615a 100644 --- a/apps/web/src/app/api/portal/auth/route.ts +++ b/apps/web/src/app/api/portal/auth/route.ts @@ -7,6 +7,7 @@ import { getSinglePortalWorkspace, } from "@/lib/portal-auth"; import { createLogger } from "@/lib/logger"; +import { getHarlyPublicOrigin } from "@/lib/public-origin"; import { createPortalOAuthState, PORTAL_OAUTH_STATE_COOKIE } from "@/lib/portal-oauth-state"; const log = createLogger("api-portal-auth"); @@ -25,7 +26,7 @@ export async function GET(request: NextRequest) { const state = createPortalOAuthState(next, workspace.id); - const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"; + const appUrl = getHarlyPublicOrigin(); try { if (provider === "google") { diff --git a/apps/web/src/app/api/public/v1/image/presign/route.ts b/apps/web/src/app/api/public/v1/image/presign/route.ts index e9c86308..270ed381 100644 --- a/apps/web/src/app/api/public/v1/image/presign/route.ts +++ b/apps/web/src/app/api/public/v1/image/presign/route.ts @@ -6,6 +6,7 @@ import { } from "@/lib/storage-validation"; import { storage, storageProvider } from "@/lib/storage"; import { appendStorageUploadIntent, createStorageUploadIntent } from "@/lib/storage-upload-intent"; +import { toHarlyPublicUrl } from "@/lib/public-origin"; import { resolvePublicWorkspace } from "@/server/api/public"; import { clientIp, enforceRateLimit } from "@/server/api/ratelimit"; import { apiOk, corsPreflight, withApi } from "@/server/api/respond"; @@ -39,7 +40,7 @@ export const POST = withApi(async (request) => { }); const intent = createStorageUploadIntent({ workspaceId: workspace.workspaceId, key, contentType: parsed.data.contentType, contentLength: parsed.data.contentLength, expiresAt: Date.now() + 10 * 60_000 }); - return apiOk({ ...result, uploadUrl: storageProvider === "local" ? new URL(appendStorageUploadIntent(result.uploadUrl, intent), request.url).toString() : result.uploadUrl, key }, { cors: true }); + return apiOk({ ...result, uploadUrl: storageProvider === "local" ? toHarlyPublicUrl(appendStorageUploadIntent(result.uploadUrl, intent)) : result.uploadUrl, key }, { cors: true }); }, { cors: true }); export function OPTIONS() { diff --git a/apps/web/src/app/api/public/v1/resume/presign/route.test.ts b/apps/web/src/app/api/public/v1/resume/presign/route.test.ts index 984997bb..4160a104 100644 --- a/apps/web/src/app/api/public/v1/resume/presign/route.test.ts +++ b/apps/web/src/app/api/public/v1/resume/presign/route.test.ts @@ -49,6 +49,8 @@ import { POST } from "./route"; describe("POST /api/public/v1/resume/presign", () => { beforeEach(() => { vi.clearAllMocks(); + vi.stubEnv("NODE_ENV", "test"); + vi.stubEnv("HARLY_URL", "https://harly.example.com"); mocks.enforceRateLimit.mockResolvedValue({ limit: 20, remaining: 19, @@ -72,4 +74,50 @@ describe("POST /api/public/v1/resume/presign", () => { }); expect(mocks.resolvePublicWorkspace).not.toHaveBeenCalled(); }); + + it("uses the configured public origin instead of the proxy request origin", async () => { + mocks.resolvePublicWorkspace.mockResolvedValue({ workspaceId: "workspace-1" }); + mocks.getPresignedUploadUrl.mockResolvedValue({ + uploadUrl: "/api/storage/upload?key=workspaces%2Fworkspace-1%2Fresumes%2Fresume.pdf", + fileUrl: "/uploads/workspaces/workspace-1/resumes/resume.pdf", + }); + const storageValidation = await import("@/lib/storage-validation"); + vi.mocked(storageValidation.createResumeStorageKey).mockReturnValue( + "workspaces/workspace-1/resumes/resume.pdf", + ); + const intent = await import("@/lib/storage-upload-intent"); + vi.mocked(intent.createStorageUploadIntent).mockReturnValue("signed-intent"); + vi.mocked(intent.appendStorageUploadIntent).mockReturnValue( + "/api/storage/upload?key=workspaces%2Fworkspace-1%2Fresumes%2Fresume.pdf&intent=signed-intent", + ); + const resumeStorageKey = await import("@/lib/resume/storage-key"); + vi.mocked(resumeStorageKey.privateResumeFileUrl).mockReturnValue( + "/api/storage/private/resume.pdf", + ); + vi.mocked(storageValidation.resumeUploadRequestSchema.safeParse).mockReturnValue({ + success: true, + data: { + filename: "resume.pdf", + contentType: "application/pdf", + contentLength: 4, + }, + } as never); + + const response = await POST( + new Request("https://0.0.0.0:3000/api/public/v1/resume/presign", { + method: "POST", + body: JSON.stringify({ + filename: "resume.pdf", + contentType: "application/pdf", + contentLength: 4, + }), + }), + ); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.data.uploadUrl).toMatch(/^https:\/\/harly\.example\.com\//); + expect(body.data.fileUrl).toBe("https://harly.example.com/api/storage/private/resume.pdf"); + expect(body.data.uploadUrl).not.toContain("0.0.0.0"); + }); }); diff --git a/apps/web/src/app/api/public/v1/resume/presign/route.ts b/apps/web/src/app/api/public/v1/resume/presign/route.ts index 7229a2ed..d5632510 100644 --- a/apps/web/src/app/api/public/v1/resume/presign/route.ts +++ b/apps/web/src/app/api/public/v1/resume/presign/route.ts @@ -7,6 +7,7 @@ import { import { storage, storageProvider } from "@/lib/storage"; import { privateResumeFileUrl } from "@/lib/resume/storage-key"; import { appendStorageUploadIntent, createStorageUploadIntent } from "@/lib/storage-upload-intent"; +import { toHarlyPublicUrl } from "@/lib/public-origin"; import { resolvePublicWorkspace } from "@/server/api/public"; import { clientIp, enforceRateLimit } from "@/server/api/ratelimit"; import { apiOk, corsPreflight, withApi } from "@/server/api/respond"; @@ -42,7 +43,7 @@ export const POST = withApi(async (request) => { }); const intent = createStorageUploadIntent({ workspaceId: workspace.workspaceId, key, contentType: parsed.data.contentType, contentLength: parsed.data.contentLength, expiresAt: Date.now() + 10 * 60_000 }); - return apiOk({ ...result, fileUrl: new URL(privateResumeFileUrl(key), request.url).toString(), uploadUrl: storageProvider === "local" ? new URL(appendStorageUploadIntent(result.uploadUrl, intent), request.url).toString() : result.uploadUrl, key }, { cors: true }); + return apiOk({ ...result, fileUrl: toHarlyPublicUrl(privateResumeFileUrl(key)), uploadUrl: storageProvider === "local" ? toHarlyPublicUrl(appendStorageUploadIntent(result.uploadUrl, intent)) : result.uploadUrl, key }, { cors: true }); }, { cors: true }); export function OPTIONS() { diff --git a/apps/web/src/app/api/setup/claim/route.ts b/apps/web/src/app/api/setup/claim/route.ts index 7c359b4b..5d86a6cf 100644 --- a/apps/web/src/app/api/setup/claim/route.ts +++ b/apps/web/src/app/api/setup/claim/route.ts @@ -6,17 +6,13 @@ import { setupClaimCookieName, } from "@harly/auth/setup"; import { clientIp, enforceRateLimit } from "@/server/api/ratelimit"; +import { getHarlyPublicOrigin } from "@/lib/public-origin"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; function publicUrl(): string { - return ( - process.env.HARLY_URL ?? - process.env.NEXT_PUBLIC_APP_URL ?? - process.env.BETTER_AUTH_URL ?? - "http://localhost:3000" - ); + return getHarlyPublicOrigin(); } export async function POST(request: NextRequest): Promise { diff --git a/apps/web/src/app/api/setup/complete/route.ts b/apps/web/src/app/api/setup/complete/route.ts index fbc9841f..e6816865 100644 --- a/apps/web/src/app/api/setup/complete/route.ts +++ b/apps/web/src/app/api/setup/complete/route.ts @@ -7,6 +7,7 @@ import { SetupError, setupClaimCookieName, } from "@harly/auth/setup"; +import { getHarlyPublicOrigin } from "@/lib/public-origin"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -22,12 +23,7 @@ const bodySchema = z.object({ }); function publicUrl(): string { - return ( - process.env.HARLY_URL ?? - process.env.NEXT_PUBLIC_APP_URL ?? - process.env.BETTER_AUTH_URL ?? - "http://localhost:3000" - ); + return getHarlyPublicOrigin(); } export async function POST(request: NextRequest): Promise { diff --git a/apps/web/src/app/api/v1/openapi.json/route.ts b/apps/web/src/app/api/v1/openapi.json/route.ts index d55840cd..a1e5eb10 100644 --- a/apps/web/src/app/api/v1/openapi.json/route.ts +++ b/apps/web/src/app/api/v1/openapi.json/route.ts @@ -7,14 +7,12 @@ import { apiContracts } from "@/server/api/contracts/registry"; import { jsonSchema } from "@/server/api/contracts"; import { clientIp, enforceRateLimit } from "@/server/api/ratelimit"; import { withApi } from "@/server/api/respond"; +import { getHarlyPublicOrigin } from "@/lib/public-origin"; export const runtime = "nodejs"; function baseUrl(): string { - return (process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000").replace( - /\/$/, - "", - ); + return getHarlyPublicOrigin(); } function parameterList(contract: (typeof apiContracts)[number]) { diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index a2173370..3d4d44ff 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -6,6 +6,7 @@ import { ThemeProvider } from "@/components/ThemeProvider"; import { Toaster } from "@/components/ui/sonner"; import { TooltipProvider } from "@/components/ui/tooltip"; import { CookiePanel } from "@/components/CookieConsentBanner"; +import { getHarlyPublicOrigin } from "@/lib/public-origin"; import "./globals.css"; @@ -35,11 +36,7 @@ const onestVariable = localFont({ }); export const metadata: Metadata = { - metadataBase: new URL( - process.env.HARLY_URL ?? - process.env.NEXT_PUBLIC_APP_URL ?? - "http://localhost:3000", - ), + metadataBase: new URL(getHarlyPublicOrigin()), title: "Harly", description: "Open-source applicant tracking system for modern teams.", icons: { diff --git a/apps/web/src/app/robots.ts b/apps/web/src/app/robots.ts index f9ca4504..49cff6c1 100644 --- a/apps/web/src/app/robots.ts +++ b/apps/web/src/app/robots.ts @@ -1,6 +1,7 @@ import type { MetadataRoute } from "next"; +import { getHarlyPublicOrigin } from "@/lib/public-origin"; -const origin = (process.env.HARLY_URL ?? process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000").replace(/\/$/, ""); +const origin = getHarlyPublicOrigin(); export default function robots(): MetadataRoute.Robots { return { diff --git a/apps/web/src/app/sitemap.ts b/apps/web/src/app/sitemap.ts index 71d67901..096b1393 100644 --- a/apps/web/src/app/sitemap.ts +++ b/apps/web/src/app/sitemap.ts @@ -3,8 +3,9 @@ import { and, eq, isNull } from "drizzle-orm"; import { db, jobs, organization, workspaceSettings } from "@harly/db"; import { normalizeCareerPageConfig } from "@/features/career-page/config"; +import { getHarlyPublicOrigin } from "@/lib/public-origin"; -const origin = (process.env.HARLY_URL ?? process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000").replace(/\/$/, ""); +const origin = getHarlyPublicOrigin(); // Queries the DB at request time; must never be prerendered at build (no DB in // the image) — otherwise `next build` fails with ECONNREFUSED on :5432. diff --git a/apps/web/src/features/auth/actions.ts b/apps/web/src/features/auth/actions.ts index 3c0900bd..cb1c3d8f 100644 --- a/apps/web/src/features/auth/actions.ts +++ b/apps/web/src/features/auth/actions.ts @@ -4,13 +4,10 @@ import { createElement } from "react"; import { WelcomeEmail, welcomeEmailSubject } from "@harly/emails"; import { sendEmail } from "@/lib/email"; +import { getHarlyPublicOrigin } from "@/lib/public-origin"; export async function sendWelcomeEmailAction(email: string, name: string) { - const appUrl = - process.env.HARLY_URL ?? - process.env.NEXT_PUBLIC_APP_URL ?? - process.env.BETTER_AUTH_URL ?? - "http://localhost:3000"; + const appUrl = getHarlyPublicOrigin(); await sendEmail({ to: email, diff --git a/apps/web/src/features/jobs/service.ts b/apps/web/src/features/jobs/service.ts index 67de4e13..11ff3ae0 100644 --- a/apps/web/src/features/jobs/service.ts +++ b/apps/web/src/features/jobs/service.ts @@ -6,6 +6,7 @@ import { ApiError, type Cursor } from "@harly/api"; import { db, jobApprovalRequests, jobHiringTeam, jobs, jobStages, type Job } from "@harly/db"; import { emitWebhookEvent } from "@/server/webhooks/emit"; +import { getHarlyPublicOrigin } from "@/lib/public-origin"; import { persistDomainEvent, publishPersistedDomainEvents, @@ -73,10 +74,7 @@ export function serializeJob(job: Job) { } function appBaseUrl(): string { - return (process.env.HARLY_URL ?? process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000").replace( - /\/$/, - "", - ); + return getHarlyPublicOrigin(); } /** Public (unauthenticated) job shape for the embed widget / board API. */ diff --git a/apps/web/src/features/portal/actions.ts b/apps/web/src/features/portal/actions.ts index 00fdf474..65d2334e 100644 --- a/apps/web/src/features/portal/actions.ts +++ b/apps/web/src/features/portal/actions.ts @@ -52,6 +52,7 @@ import { clientIp, enforceRateLimit } from "@/server/api/ratelimit"; import { offerHasExpired } from "@/features/offers/core"; import { freshEsignContext, getSubmission } from "@/lib/esign/client"; import { signerSigningUrl } from "@/lib/esign/offer-signing"; +import { getHarlyPublicOrigin } from "@/lib/public-origin"; const log = createLogger("portal-actions"); @@ -125,7 +126,7 @@ export async function sendPortalMagicLinkAction( try { const token = await createMagicLinkToken(workspaceId, parsed.data); - const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"; + const appUrl = getHarlyPublicOrigin(); const url = `${appUrl}/api/portal/auth/magic?token=${token}`; const sender = await getWorkspaceEmailSender(workspaceId); diff --git a/apps/web/src/features/workspaces/ReplyHandlingSettingsCard.tsx b/apps/web/src/features/workspaces/ReplyHandlingSettingsCard.tsx index 6cfff6bd..b0cd87cc 100644 --- a/apps/web/src/features/workspaces/ReplyHandlingSettingsCard.tsx +++ b/apps/web/src/features/workspaces/ReplyHandlingSettingsCard.tsx @@ -189,11 +189,13 @@ export function ReplyHandlingSettingsForm({ inboundStatus, workspaceId, initialMode, + appUrl, }: { mailboxStatus: MailboxStatus; inboundStatus: WorkspaceInboundEmailStatus; workspaceId: string; initialMode: ReplyMode; + appUrl: string; }) { const router = useRouter(); const [mode, setMode] = useState(initialMode); @@ -233,10 +235,7 @@ export function ReplyHandlingSettingsForm({ const setMailboxField = (key: keyof typeof form, value: string | boolean) => setForm((current) => ({ ...current, [key]: value })); - const appUrl = process.env.NEXT_PUBLIC_APP_URL?.replace(/\/$/, ""); - const webhookUrl = appUrl - ? `${appUrl}/api/webhooks/email/${provider}?ws=${workspaceId}` - : null; + const webhookUrl = `${appUrl}/api/webhooks/email/${provider}?ws=${workspaceId}`; function save() { startSave(async () => { diff --git a/apps/web/src/features/workspaces/actions.ts b/apps/web/src/features/workspaces/actions.ts index ff455c7b..f126a0f1 100644 --- a/apps/web/src/features/workspaces/actions.ts +++ b/apps/web/src/features/workspaces/actions.ts @@ -13,6 +13,7 @@ import { logAuditEvent } from "@/lib/audit-log"; import { sendWorkspaceEmail } from "@/lib/email"; import { getWorkspaceEmailBranding } from "@/lib/email/branding"; import { createLogger } from "@/lib/logger"; +import { getHarlyPublicOrigin } from "@/lib/public-origin"; import { db } from "@harly/db"; import { convertAndStoreLogo } from "@/lib/logo-convert"; import { @@ -424,9 +425,7 @@ async function inviteOneMember( ); if (!existingMembership) { - const requestHeaders = await headers(); - const host = requestHeaders.get("host") ?? "localhost:3000"; - const protocol = host.startsWith("localhost") ? "http" : "https"; + const appUrl = getHarlyPublicOrigin(); const branding = await getWorkspaceEmailBranding(context.organization.id); void sendWorkspaceEmail(context.organization.id, { to: email, @@ -434,7 +433,7 @@ async function inviteOneMember( react: createElement(WelcomeEmail, { userName: authUser.name, workspaceName: context.organization.name, - dashboardUrl: `${protocol}://${host}/login`, + dashboardUrl: `${appUrl}/login`, branding, }), }); @@ -478,10 +477,7 @@ async function inviteOneMember( inviterId: context.user.id, }); - const requestHeaders = await headers(); - const host = requestHeaders.get("host") ?? "localhost:3000"; - const protocol = host.startsWith("localhost") ? "http" : "https"; - const acceptUrl = `${protocol}://${host}/invite/${invitationId}`; + const acceptUrl = `${getHarlyPublicOrigin()}/invite/${invitationId}`; const branding = await getWorkspaceEmailBranding(context.organization.id); void sendWorkspaceEmail(context.organization.id, { @@ -991,10 +987,7 @@ export async function resendWorkspaceInvitationAction( .set({ expiresAt: addDays(new Date(), 7) }) .where(eq(invitation.id, invite.id)); - const requestHeaders = await headers(); - const host = requestHeaders.get("host") ?? "localhost:3000"; - const protocol = host.startsWith("localhost") ? "http" : "https"; - const acceptUrl = `${protocol}://${host}/invite/${invite.id}`; + const acceptUrl = `${getHarlyPublicOrigin()}/invite/${invite.id}`; const branding = await getWorkspaceEmailBranding(context.organization.id); void sendWorkspaceEmail(context.organization.id, { diff --git a/apps/web/src/features/workspaces/cal-settings-actions.ts b/apps/web/src/features/workspaces/cal-settings-actions.ts index 27be192c..0aa0485f 100644 --- a/apps/web/src/features/workspaces/cal-settings-actions.ts +++ b/apps/web/src/features/workspaces/cal-settings-actions.ts @@ -17,6 +17,7 @@ import { import { registerCalWebhook, verifyCalConnection } from "@/lib/cal/client"; import { encryptSecret, isEncryptionConfigured } from "@/lib/crypto"; import { createLogger } from "@/lib/logger"; +import { getHarlyPublicOrigin } from "@/lib/public-origin"; import { resolveSafeAddress } from "@/lib/ssrf"; const log = createLogger("workspace-cal-settings"); @@ -280,14 +281,9 @@ export async function registerCalWebhookAction(): Promise { } function appBaseUrl(): string { - return ( - process.env.HARLY_URL ?? - process.env.NEXT_PUBLIC_APP_URL ?? - "http://localhost:3000" - ); + return getHarlyPublicOrigin(); } function splitName(full: string): { first: string; last: string } { diff --git a/apps/web/src/lib/gcal/config.ts b/apps/web/src/lib/gcal/config.ts index 9ba85db6..360023ae 100644 --- a/apps/web/src/lib/gcal/config.ts +++ b/apps/web/src/lib/gcal/config.ts @@ -6,6 +6,7 @@ import { OAuth2Client } from "google-auth-library"; import { db, workspaceSettings } from "@harly/db"; import { decryptSecret, isEncryptionConfigured } from "@/lib/crypto"; +import { getHarlyPublicOrigin } from "@/lib/public-origin"; export type WorkspaceGCalStatus = { enabled: boolean; @@ -48,9 +49,7 @@ function getGoogleCredentials(): { } export function getRedirectUri(): string { - const appUrl = ( - process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000" - ).replace(/\/$/, ""); + const appUrl = getHarlyPublicOrigin(); return `${appUrl}/api/integrations/google/callback`; } diff --git a/apps/web/src/lib/passkey.ts b/apps/web/src/lib/passkey.ts index df2fab72..e24e8c8c 100644 --- a/apps/web/src/lib/passkey.ts +++ b/apps/web/src/lib/passkey.ts @@ -2,13 +2,9 @@ import "server-only"; import { eq, and, lt } from "drizzle-orm"; import { db, passkeys, passkeyChallenge } from "@harly/db"; +import { getHarlyPublicOrigin } from "@/lib/public-origin"; -const publicUrl = new URL( - process.env.HARLY_URL ?? - process.env.NEXT_PUBLIC_APP_URL ?? - process.env.BETTER_AUTH_URL ?? - "http://localhost:3000", -); +const publicUrl = new URL(getHarlyPublicOrigin()); const RP_ID = publicUrl.hostname; const RP_NAME = "Harly"; const ORIGIN = publicUrl.origin; diff --git a/apps/web/src/lib/public-origin.test.ts b/apps/web/src/lib/public-origin.test.ts index 3365067a..9953d179 100644 --- a/apps/web/src/lib/public-origin.test.ts +++ b/apps/web/src/lib/public-origin.test.ts @@ -20,4 +20,26 @@ describe("public provider origin", () => { expect(() => getHarlyPublicOrigin()).toThrow(/HTTPS/); }); + + it("rejects an unspecified production bind address", () => { + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("HARLY_URL", "https://0.0.0.0:3000"); + + expect(() => getHarlyPublicOrigin()).toThrow(/reachable public hostname/); + }); + + it("rejects localhost in production", () => { + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("HARLY_URL", "https://localhost:3000"); + + expect(() => getHarlyPublicOrigin()).toThrow(/reachable public hostname/); + }); + + it("uses a non-routable placeholder only during the production build", () => { + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("NEXT_PHASE", "phase-production-build"); + vi.stubEnv("HARLY_URL", "http://localhost:3000"); + + expect(getHarlyPublicOrigin()).toBe("https://build.invalid"); + }); }); diff --git a/apps/web/src/lib/public-origin.ts b/apps/web/src/lib/public-origin.ts index 5ee01451..718ccaf0 100644 --- a/apps/web/src/lib/public-origin.ts +++ b/apps/web/src/lib/public-origin.ts @@ -1,6 +1,21 @@ import "server-only"; const DEFAULT_PUBLIC_ORIGIN = "http://localhost:3000"; +const BUILD_PUBLIC_ORIGIN = "https://build.invalid"; + +function isUnsafeProductionHost(hostname: string) { + const normalized = hostname.toLowerCase(); + return ( + normalized === "localhost" || + normalized.endsWith(".localhost") || + normalized === "127.0.0.1" || + normalized === "::1" || + normalized === "[::1]" || + normalized === "0.0.0.0" || + normalized === "::" || + normalized === "[::]" + ); +} /** * Return the canonical origin that external providers must redirect to. @@ -24,6 +39,15 @@ export function getHarlyPublicOrigin(): string { if (url.protocol !== "http:" && url.protocol !== "https:") { throw new Error("HARLY_URL must use HTTP or HTTPS."); } + if ( + process.env.NODE_ENV === "production" && + isUnsafeProductionHost(url.hostname) + ) { + if (process.env.NEXT_PHASE === "phase-production-build") { + return BUILD_PUBLIC_ORIGIN; + } + throw new Error("HARLY_URL must use a reachable public hostname, not a local or bind address."); + } if (url.username || url.password || url.search || url.hash) { throw new Error("HARLY_URL must be a public origin without credentials or query parameters."); } @@ -34,6 +58,14 @@ export function getHarlyPublicOrigin(): string { return url.origin; } +/** Resolve an app-relative URL against the configured public origin. + * Never use request.url here: behind a reverse proxy it can contain the + * container's listen address (for example 0.0.0.0:3000). + */ +export function toHarlyPublicUrl(pathname: string): string { + return new URL(pathname, `${getHarlyPublicOrigin()}/`).toString(); +} + /** Inbound DocuSeal webhook base. Only `?ws=` is appended per workspace; the * shared secret travels in X-DocuSeal-Secret (or an HMAC-capable proxy). */ export function getEsignWebhookBaseUrl(): string { diff --git a/apps/web/src/proxy.ts b/apps/web/src/proxy.ts index 9fd30d15..9aad1cfd 100644 --- a/apps/web/src/proxy.ts +++ b/apps/web/src/proxy.ts @@ -65,6 +65,10 @@ function isPortalProtected(pathname: string): boolean { return PORTAL_PROTECTED.some((p) => pathname === p || pathname.startsWith(`${p}/`)); } +function publicRedirectUrl(request: NextRequest, pathname: string): URL { + return new URL(pathname, process.env.HARLY_URL ?? request.nextUrl.origin); +} + export async function proxy(request: NextRequest) { const { pathname } = request.nextUrl; @@ -72,7 +76,7 @@ export async function proxy(request: NextRequest) { if (isPortalProtected(pathname)) { const token = request.cookies.get(PORTAL_SESSION_COOKIE)?.value; if (!token) { - const loginUrl = new URL("/portal/login", request.url); + const loginUrl = publicRedirectUrl(request, "/portal/login"); loginUrl.searchParams.set("next", pathname); return NextResponse.redirect(loginUrl); } @@ -91,7 +95,7 @@ export async function proxy(request: NextRequest) { const sessionCookie = getSessionCookie(request); if (!sessionCookie) { - return NextResponse.redirect(new URL("/login", request.url)); + return NextResponse.redirect(publicRedirectUrl(request, "/login")); } // 2FA + org enforcement for protected paths @@ -100,7 +104,7 @@ export async function proxy(request: NextRequest) { const session = await auth.api.getSession({ headers: request.headers }); if (!session?.user) { - const loginUrl = new URL("/login", request.url); + const loginUrl = publicRedirectUrl(request, "/login"); loginUrl.searchParams.set("redirect", pathname); return NextResponse.redirect(loginUrl); } @@ -198,7 +202,7 @@ export async function proxy(request: NextRequest) { !CHANGE_PASSWORD_EXEMPT.some((p) => pathname.startsWith(p)) ) { return NextResponse.redirect( - new URL("/change-password", request.url), + publicRedirectUrl(request, "/change-password"), ); } @@ -210,11 +214,11 @@ export async function proxy(request: NextRequest) { roleKey: memberRow?.role, }) ) { - return NextResponse.redirect(new URL("/setup-2fa", request.url)); + return NextResponse.redirect(publicRedirectUrl(request, "/setup-2fa")); } if (wsRow?.requirePasskey && !existingPasskey) { - return NextResponse.redirect(new URL("/setup-2fa", request.url)); + return NextResponse.redirect(publicRedirectUrl(request, "/setup-2fa")); } } } catch { diff --git a/apps/web/src/server/notify/dispatch.ts b/apps/web/src/server/notify/dispatch.ts index 5a135053..af705b7a 100644 --- a/apps/web/src/server/notify/dispatch.ts +++ b/apps/web/src/server/notify/dispatch.ts @@ -7,6 +7,7 @@ import { getWorkspaceChatConfig, type ChatConfig } from "@/lib/notify/config"; import { getWorkspaceSlackConfig } from "@/lib/slack/config"; import { getWorkspaceTelegramConfig } from "@/lib/telegram/config"; import { sendTelegramMessage } from "@/lib/telegram/client"; +import { getHarlyPublicOrigin } from "@/lib/public-origin"; import { WEBHOOK_EVENT_LABELS, type WebhookEvent } from "@/server/webhooks/events"; /** @@ -31,9 +32,7 @@ const EVENT_EMOJI: Record = { "job.published": "📣", }; -const APP_URL = ( - process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000" -).replace(/\/$/, ""); +const APP_URL = getHarlyPublicOrigin(); type ChatField = { name: string; value: string; inline?: boolean }; diff --git a/apps/web/src/server/notify/slack.ts b/apps/web/src/server/notify/slack.ts index 5d8f88aa..e2184c3f 100644 --- a/apps/web/src/server/notify/slack.ts +++ b/apps/web/src/server/notify/slack.ts @@ -15,12 +15,13 @@ import { } from "@harly/db"; import { getWorkspaceSlackConfig } from "@/lib/slack/config"; +import { getHarlyPublicOrigin } from "@/lib/public-origin"; import { createLogger } from "@/lib/logger"; import { recordSlackDelivery } from "@/server/observability/metrics"; import { WEBHOOK_EVENT_LABELS, type WebhookEvent } from "@/server/webhooks/events"; const log = createLogger("slack-notifications"); -const APP_URL = (process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000").replace(/\/$/, ""); +const APP_URL = getHarlyPublicOrigin(); const MAX_ATTEMPTS = 6; const RETRY_BACKOFF_MS = [60_000, 5 * 60_000, 30 * 60_000, 2 * 60 * 60_000, 6 * 60 * 60_000, 24 * 60 * 60_000]; diff --git a/apps/web/src/server/scim/http.ts b/apps/web/src/server/scim/http.ts index ee2a69f8..cbe6c69c 100644 --- a/apps/web/src/server/scim/http.ts +++ b/apps/web/src/server/scim/http.ts @@ -1,6 +1,7 @@ import { NextResponse, type NextRequest } from "next/server"; import { enforceRateLimit, clientIp } from "@/server/api/ratelimit"; import { resolveScimToken } from "@/server/scim/service"; +import { getHarlyPublicOrigin } from "@/lib/public-origin"; export async function authenticateScimRequest(request: NextRequest, workspaceId: string) { try { @@ -22,7 +23,7 @@ export function scimError(detail: string, status: number, scimType?: string) { } export function publicBaseUrl(request: NextRequest) { - return process.env.HARLY_URL ?? request.nextUrl.origin; + return process.env.HARLY_URL ? getHarlyPublicOrigin() : request.nextUrl.origin; } export function scimBaseUrl(request: NextRequest, workspaceId: string) { diff --git a/packages/config/README.md b/packages/config/README.md index 4231aaed..73157c9e 100644 --- a/packages/config/README.md +++ b/packages/config/README.md @@ -29,7 +29,8 @@ deprecated fallbacks, resolved in that order when `HARLY_URL` is absent. The resolved value must be an absolute HTTP(S) origin with no path, credentials, query, or fragment. It is normalized without a trailing slash. -Production requires HTTPS, except for `localhost` and `127.0.0.1`. +Production requires HTTPS and a reachable public origin. Localhost, loopback, +and unspecified bind addresses are development-only values. ## Environment variables diff --git a/packages/config/src/index.test.ts b/packages/config/src/index.test.ts index 077c9ede..92762193 100644 --- a/packages/config/src/index.test.ts +++ b/packages/config/src/index.test.ts @@ -25,6 +25,18 @@ describe("loadHarlyConfig", () => { ); }); + it("rejects an unspecified production public origin", () => { + expect(() => loadHarlyConfig({ ...production, HARLY_URL: "https://0.0.0.0:3000" })).toThrow( + /HARLY_URL/, + ); + }); + + it("rejects localhost in production", () => { + expect(() => loadHarlyConfig({ ...production, HARLY_URL: "https://localhost:3000" })).toThrow( + /HARLY_URL/, + ); + }); + it("requires OAuth credentials in pairs", () => { expect(() => loadHarlyConfig({ ...production, GOOGLE_CLIENT_ID: "id" })).toThrow( /GOOGLE/, diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 4ed2872c..2f4a02ce 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -72,7 +72,14 @@ const envSchema = z if (!['http:', 'https:'].includes(parsed.protocol) || parsed.pathname !== "/") { throw new Error("invalid origin"); } - if (env.NODE_ENV === "production" && parsed.protocol !== "https:" && !["localhost", "127.0.0.1"].includes(parsed.hostname)) { + const normalizedHostname = parsed.hostname.toLowerCase(); + if ( + env.NODE_ENV === "production" && + ["localhost", "127.0.0.1", "::1", "[::1]", "0.0.0.0", "::", "[::]"].includes(normalizedHostname) + ) { + throw new Error("local or unspecified bind address"); + } + if (env.NODE_ENV === "production" && parsed.protocol !== "https:") { ctx.addIssue({ code: "custom", path: ["HARLY_URL"], message: "must use HTTPS in production" }); } } catch { From 9c3ef45a7d54db9ef3051047efa5ddc68a8da71b Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 12 Aug 2026 03:17:45 -0400 Subject: [PATCH 2/3] fix: keep production CI origins non-local --- .github/workflows/ci.yml | 14 ++++++++------ compose.yaml | 4 +++- tooling/harly/src/index.ts | 2 +- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 241dfcc5..7c3fec99 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,8 +14,10 @@ env: # Used by packages/db to build the client; tests that touch DB logic mock the # module, so this never needs a real Postgres instance in CI. DATABASE_URL: postgresql://harly:harly@localhost:5432/harly - NEXT_PUBLIC_APP_URL: http://localhost:3000 - HARLY_URL: http://localhost:3000 + # Keep CI's public-origin variables routable-looking and non-local. The + # production config intentionally rejects localhost/loopback origins. + NEXT_PUBLIC_APP_URL: https://ci.example.invalid + HARLY_URL: https://ci.example.invalid DOCUSEAL_SECRET_KEY_BASE: ci-docuseal-secret-key-base-do-not-use-in-production BETTER_AUTH_SECRET: ci-test-secret-do-not-use-in-production NODE_ENV: test @@ -146,8 +148,8 @@ jobs: env: NODE_ENV: production DATABASE_URL: postgresql://harly:harly@localhost:5432/harly - NEXT_PUBLIC_APP_URL: http://localhost:3000 - HARLY_URL: http://localhost:3000 + NEXT_PUBLIC_APP_URL: https://ci.example.invalid + HARLY_URL: https://ci.example.invalid BETTER_AUTH_SECRET: ci-test-secret-do-not-use-in-production AI_ENCRYPTION_KEY: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb STORAGE_UPLOAD_SECRET: cccccccccccccccccccccccccccccccc @@ -359,7 +361,7 @@ jobs: - name: Validate Compose profiles run: | export HARLY_IMAGE="$(node -p 'const r=require("./release-manifest.json"); r.image + "@" + r.digest')" - export HARLY_VERSION="$(node -p 'require("./release-manifest.json").version')" HARLY_URL=http://localhost:3000 + export HARLY_VERSION="$(node -p 'require("./release-manifest.json").version')" HARLY_URL=https://ci.example.invalid export POSTGRES_USER=harly POSTGRES_PASSWORD=harly POSTGRES_DB=harly export BETTER_AUTH_SECRET=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa AI_ENCRYPTION_KEY=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb export STORAGE_UPLOAD_SECRET=cccccccccccccccccccccccccccccccc CRON_SECRET=dddddddddddddddddddddddddddddddd @@ -392,7 +394,7 @@ jobs: COMPOSE_PROJECT_NAME: harly-ci HARLY_IMAGE: harly:ci HARLY_VERSION: ci - HARLY_URL: http://127.0.0.1:3099 + HARLY_URL: https://ci.example.invalid HARLY_PORT: 3099 POSTGRES_USER: harly POSTGRES_PASSWORD: harly-ci-postgres-password diff --git a/compose.yaml b/compose.yaml index 45051312..28e727d0 100644 --- a/compose.yaml +++ b/compose.yaml @@ -134,7 +134,9 @@ services: cpus: ${HARLY_CADDY_CPUS:-0.5} logging: *harly-logging environment: - HARLY_DOMAIN: ${HARLY_DOMAIN:-localhost} + # Never default the public proxy hostname to localhost. Deployments + # should set HARLY_DOMAIN explicitly; this placeholder is non-routable. + HARLY_DOMAIN: ${HARLY_DOMAIN:-example.invalid} ports: - "80:80" - "443:443" diff --git a/tooling/harly/src/index.ts b/tooling/harly/src/index.ts index 2522246f..ff30fcd7 100644 --- a/tooling/harly/src/index.ts +++ b/tooling/harly/src/index.ts @@ -1285,7 +1285,7 @@ services: mem_limit: \${HARLY_CADDY_MEMORY:-256m} cpus: \${HARLY_CADDY_CPUS:-0.5} logging: *logging - environment: { HARLY_DOMAIN: "\${HARLY_DOMAIN:-localhost}" } + environment: { HARLY_DOMAIN: "\${HARLY_DOMAIN:-example.invalid}" } ports: ["80:80", "443:443", "443:443/udp"] volumes: ["./Caddyfile:/etc/caddy/Caddyfile:ro", caddy-data:/data, caddy-config:/config] depends_on: { app: { condition: service_healthy } } From 8f4ce9c1a86a146ba3904081fff2571a704e3c05 Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 12 Aug 2026 03:32:33 -0400 Subject: [PATCH 3/3] fix: address PR origin review feedback --- apps/web/src/app/layout.tsx | 20 ++++++++++++-------- apps/web/src/app/robots.ts | 3 ++- apps/web/src/features/career-page/seo.ts | 5 ++++- apps/web/src/features/workspaces/actions.ts | 7 ++++--- apps/web/src/lib/public-origin.test.ts | 16 ++++++++++++++++ apps/web/src/lib/public-origin.ts | 8 +++++++- apps/web/src/proxy.ts | 6 +++++- apps/web/src/server/scim/http.ts | 6 +++++- packages/config/src/index.test.ts | 12 ++++++++++++ packages/config/src/index.ts | 10 +++++++++- 10 files changed, 76 insertions(+), 17 deletions(-) diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 3d4d44ff..000e5280 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -35,14 +35,18 @@ const onestVariable = localFont({ fallback: ["ui-sans-serif", "system-ui", "sans-serif"], }); -export const metadata: Metadata = { - metadataBase: new URL(getHarlyPublicOrigin()), - title: "Harly", - description: "Open-source applicant tracking system for modern teams.", - icons: { - icon: "/favicon.svg", - }, -}; +export const dynamic = "force-dynamic"; + +export async function generateMetadata(): Promise { + return { + metadataBase: new URL(getHarlyPublicOrigin()), + title: "Harly", + description: "Open-source applicant tracking system for modern teams.", + icons: { + icon: "/favicon.svg", + }, + }; +} export default function RootLayout({ children, diff --git a/apps/web/src/app/robots.ts b/apps/web/src/app/robots.ts index 49cff6c1..eecb8b40 100644 --- a/apps/web/src/app/robots.ts +++ b/apps/web/src/app/robots.ts @@ -1,9 +1,10 @@ import type { MetadataRoute } from "next"; import { getHarlyPublicOrigin } from "@/lib/public-origin"; -const origin = getHarlyPublicOrigin(); +export const dynamic = "force-dynamic"; export default function robots(): MetadataRoute.Robots { + const origin = getHarlyPublicOrigin(); return { rules: [{ userAgent: "*", allow: ["/board/"], disallow: ["/api/", "/dashboard/", "/settings/", "/portal/", "/login", "/signup", "/apply/"] }], sitemap: `${origin}/sitemap.xml`, diff --git a/apps/web/src/features/career-page/seo.ts b/apps/web/src/features/career-page/seo.ts index c34fcd7a..1e307f19 100644 --- a/apps/web/src/features/career-page/seo.ts +++ b/apps/web/src/features/career-page/seo.ts @@ -7,7 +7,10 @@ import type { WorkspaceBoardBranding } from "@/features/workspaces/board"; import type { Job } from "@harly/db"; function origin() { - const configured = process.env.HARLY_URL ?? process.env.NEXT_PUBLIC_APP_URL; + const configured = + process.env.HARLY_URL ?? + process.env.NEXT_PUBLIC_APP_URL ?? + process.env.BETTER_AUTH_URL; // A production deployment without a public URL should never publish // localhost as its canonical origin. Relative URLs remain valid metadata // until the deployment is configured correctly. diff --git a/apps/web/src/features/workspaces/actions.ts b/apps/web/src/features/workspaces/actions.ts index f126a0f1..9b9f0660 100644 --- a/apps/web/src/features/workspaces/actions.ts +++ b/apps/web/src/features/workspaces/actions.ts @@ -366,6 +366,7 @@ async function inviteOneMember( email: string, role: string, ): Promise { + const appUrl = getHarlyPublicOrigin(); const authUser = await getAuthUserByEmail(email); if (authUser) { @@ -425,7 +426,6 @@ async function inviteOneMember( ); if (!existingMembership) { - const appUrl = getHarlyPublicOrigin(); const branding = await getWorkspaceEmailBranding(context.organization.id); void sendWorkspaceEmail(context.organization.id, { to: email, @@ -477,7 +477,7 @@ async function inviteOneMember( inviterId: context.user.id, }); - const acceptUrl = `${getHarlyPublicOrigin()}/invite/${invitationId}`; + const acceptUrl = `${appUrl}/invite/${invitationId}`; const branding = await getWorkspaceEmailBranding(context.organization.id); void sendWorkspaceEmail(context.organization.id, { @@ -961,6 +961,7 @@ export async function resendWorkspaceInvitationAction( ): Promise { try { const context = await requirePermission("members:invite"); + const appUrl = getHarlyPublicOrigin(); const [invite] = await db .select({ @@ -987,7 +988,7 @@ export async function resendWorkspaceInvitationAction( .set({ expiresAt: addDays(new Date(), 7) }) .where(eq(invitation.id, invite.id)); - const acceptUrl = `${getHarlyPublicOrigin()}/invite/${invite.id}`; + const acceptUrl = `${appUrl}/invite/${invite.id}`; const branding = await getWorkspaceEmailBranding(context.organization.id); void sendWorkspaceEmail(context.organization.id, { diff --git a/apps/web/src/lib/public-origin.test.ts b/apps/web/src/lib/public-origin.test.ts index 9953d179..6909ec50 100644 --- a/apps/web/src/lib/public-origin.test.ts +++ b/apps/web/src/lib/public-origin.test.ts @@ -35,6 +35,22 @@ describe("public provider origin", () => { expect(() => getHarlyPublicOrigin()).toThrow(/reachable public hostname/); }); + it("rejects the full IPv4 loopback range in production", () => { + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("HARLY_URL", "https://127.0.0.2"); + + expect(() => getHarlyPublicOrigin()).toThrow(/reachable public hostname/); + }); + + it("uses BETTER_AUTH_URL as the final legacy fallback", () => { + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("HARLY_URL", undefined); + vi.stubEnv("NEXT_PUBLIC_APP_URL", undefined); + vi.stubEnv("BETTER_AUTH_URL", "https://legacy.example.com"); + + expect(getHarlyPublicOrigin()).toBe("https://legacy.example.com"); + }); + it("uses a non-routable placeholder only during the production build", () => { vi.stubEnv("NODE_ENV", "production"); vi.stubEnv("NEXT_PHASE", "phase-production-build"); diff --git a/apps/web/src/lib/public-origin.ts b/apps/web/src/lib/public-origin.ts index 718ccaf0..a66d710e 100644 --- a/apps/web/src/lib/public-origin.ts +++ b/apps/web/src/lib/public-origin.ts @@ -5,10 +5,15 @@ const BUILD_PUBLIC_ORIGIN = "https://build.invalid"; function isUnsafeProductionHost(hostname: string) { const normalized = hostname.toLowerCase(); + const ipv4Parts = normalized.split("."); + const isIpv4Loopback = + ipv4Parts.length === 4 && + ipv4Parts[0] === "127" && + ipv4Parts.slice(1).every((part) => /^(?:0|[1-9]\d{0,2})$/.test(part) && Number(part) <= 255); return ( normalized === "localhost" || normalized.endsWith(".localhost") || - normalized === "127.0.0.1" || + isIpv4Loopback || normalized === "::1" || normalized === "[::1]" || normalized === "0.0.0.0" || @@ -27,6 +32,7 @@ export function getHarlyPublicOrigin(): string { const configured = process.env.HARLY_URL ?? process.env.NEXT_PUBLIC_APP_URL ?? + process.env.BETTER_AUTH_URL ?? DEFAULT_PUBLIC_ORIGIN; let url: URL; diff --git a/apps/web/src/proxy.ts b/apps/web/src/proxy.ts index 9aad1cfd..398dcbc8 100644 --- a/apps/web/src/proxy.ts +++ b/apps/web/src/proxy.ts @@ -66,7 +66,11 @@ function isPortalProtected(pathname: string): boolean { } function publicRedirectUrl(request: NextRequest, pathname: string): URL { - return new URL(pathname, process.env.HARLY_URL ?? request.nextUrl.origin); + const configuredOrigin = + process.env.HARLY_URL ?? + process.env.NEXT_PUBLIC_APP_URL ?? + process.env.BETTER_AUTH_URL; + return new URL(pathname, configuredOrigin ?? request.nextUrl.origin); } export async function proxy(request: NextRequest) { diff --git a/apps/web/src/server/scim/http.ts b/apps/web/src/server/scim/http.ts index cbe6c69c..b4db13fb 100644 --- a/apps/web/src/server/scim/http.ts +++ b/apps/web/src/server/scim/http.ts @@ -23,7 +23,11 @@ export function scimError(detail: string, status: number, scimType?: string) { } export function publicBaseUrl(request: NextRequest) { - return process.env.HARLY_URL ? getHarlyPublicOrigin() : request.nextUrl.origin; + const configuredOrigin = + process.env.HARLY_URL ?? + process.env.NEXT_PUBLIC_APP_URL ?? + process.env.BETTER_AUTH_URL; + return configuredOrigin ? getHarlyPublicOrigin() : request.nextUrl.origin; } export function scimBaseUrl(request: NextRequest, workspaceId: string) { diff --git a/packages/config/src/index.test.ts b/packages/config/src/index.test.ts index 92762193..7190f74a 100644 --- a/packages/config/src/index.test.ts +++ b/packages/config/src/index.test.ts @@ -37,6 +37,18 @@ describe("loadHarlyConfig", () => { ); }); + it("rejects the full IPv4 loopback range in production", () => { + expect(() => loadHarlyConfig({ ...production, HARLY_URL: "https://127.0.0.2" })).toThrow( + /HARLY_URL/, + ); + }); + + it("rejects localhost subdomains in production", () => { + expect(() => loadHarlyConfig({ ...production, HARLY_URL: "https://tenant.localhost" })).toThrow( + /HARLY_URL/, + ); + }); + it("requires OAuth credentials in pairs", () => { expect(() => loadHarlyConfig({ ...production, GOOGLE_CLIENT_ID: "id" })).toThrow( /GOOGLE/, diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 2f4a02ce..d2017dac 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -73,9 +73,17 @@ const envSchema = z throw new Error("invalid origin"); } const normalizedHostname = parsed.hostname.toLowerCase(); + const ipv4Parts = normalizedHostname.split("."); + const isIpv4Loopback = + ipv4Parts.length === 4 && + ipv4Parts[0] === "127" && + ipv4Parts.slice(1).every((part) => /^(?:0|[1-9]\d{0,2})$/.test(part) && Number(part) <= 255); if ( env.NODE_ENV === "production" && - ["localhost", "127.0.0.1", "::1", "[::1]", "0.0.0.0", "::", "[::]"].includes(normalizedHostname) + (normalizedHostname === "localhost" || + normalizedHostname.endsWith(".localhost") || + isIpv4Loopback || + ["::1", "[::1]", "0.0.0.0", "::", "[::]"].includes(normalizedHostname)) ) { throw new Error("local or unspecified bind address"); }