Deploy on DigitalOcean App Platform (GitHub-source build) + consolidate audit-fixes
Deploy config: - .do/app.yaml: build the Dockerfile directly from GitHub (deploy_on_push) instead of a pre-built DOCR image; NEXT_PUBLIC_* set RUN_AND_BUILD_TIME with the propertymanagement.network domain so they bake into the client bundle; add custom domains block (apex + www); wire Sentry DSN (server + browser). Included pending work from the audit-fixes branch: - AI provider abstraction (OpenAI/Anthropic, admin-selectable; Anthropic default) - Per-landlord e-signature (DocuSign OAuth + Dropbox Sign) + migration 0010 - Outbound webhooks / Zapier integration - PayPal removal (Stripe-only billing) - Storage hardening (fail-loud when Spaces unconfigured), security fixes Verified: full production Docker build (same build-args as DO) passes clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
917a06ee85
commit
5495b94924
@@ -28,6 +28,19 @@ const PROTECTED_PATHS = [
|
||||
|
||||
const AUTH_PATHS = ["/login", "/signup", "/forgot-password"]
|
||||
|
||||
// Origin of the Sentry ingest endpoint, derived from the public DSN so the
|
||||
// CSP stays in sync with whatever project/region the DSN points at. Returns
|
||||
// null when Sentry is not configured.
|
||||
function sentryIngestOrigin(): string | null {
|
||||
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN
|
||||
if (!dsn) return null
|
||||
try {
|
||||
return new URL(dsn).origin
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Build the per-request Content-Security-Policy. `script-src` carries a
|
||||
// per-request nonce instead of 'unsafe-inline'. `style-src` keeps
|
||||
// 'unsafe-inline' because Radix / Tailwind / framer-motion inject inline
|
||||
@@ -36,6 +49,7 @@ const AUTH_PATHS = ["/login", "/signup", "/forgot-password"]
|
||||
// scripts automatically.
|
||||
function buildCsp(nonce: string): string {
|
||||
const isDev = process.env.NODE_ENV !== "production"
|
||||
const sentry = sentryIngestOrigin()
|
||||
|
||||
// In development, Next.js/React and Turbopack HMR require eval() for hot
|
||||
// reloading and debugging features, and open a dev websocket. These are NOT
|
||||
@@ -43,9 +57,14 @@ function buildCsp(nonce: string): string {
|
||||
const scriptSrc = isDev
|
||||
? `script-src 'self' 'nonce-${nonce}' 'unsafe-eval' https://challenges.cloudflare.com`
|
||||
: `script-src 'self' 'nonce-${nonce}' https://challenges.cloudflare.com`
|
||||
const connectSrc = isDev
|
||||
? "connect-src 'self' ws: wss: https://api.stripe.com https://api.openai.com https://challenges.cloudflare.com"
|
||||
: "connect-src 'self' https://api.stripe.com https://api.openai.com https://challenges.cloudflare.com"
|
||||
const connectSrc = [
|
||||
"connect-src 'self'",
|
||||
isDev ? "ws: wss:" : "",
|
||||
"https://api.stripe.com https://api.openai.com https://challenges.cloudflare.com",
|
||||
sentry ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
|
||||
return [
|
||||
"default-src 'self'",
|
||||
@@ -54,13 +73,39 @@ function buildCsp(nonce: string): string {
|
||||
scriptSrc,
|
||||
"font-src 'self' data:",
|
||||
connectSrc,
|
||||
// Sentry Session Replay spins up its compression worker from a blob: URL;
|
||||
// without worker-src the browser falls back to script-src and blocks it.
|
||||
"worker-src 'self' blob:",
|
||||
"frame-src https://js.stripe.com https://hooks.stripe.com https://challenges.cloudflare.com",
|
||||
"frame-ancestors 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"object-src 'none'",
|
||||
].join("; ")
|
||||
}
|
||||
|
||||
// Cookie presence is only a hint (cheap, no DB). Before bouncing a visitor off
|
||||
// an auth page we confirm the session is actually alive — otherwise a stale
|
||||
// cookie loops forever: /dashboard → /login (server sees no session) →
|
||||
// /dashboard (proxy sees a cookie) → … until ERR_TOO_MANY_REDIRECTS.
|
||||
// "unknown" (auth service unreachable / rate-limited) renders the auth page
|
||||
// without touching cookies, which is safe in both directions.
|
||||
async function sessionState(request: NextRequest): Promise<"valid" | "invalid" | "unknown"> {
|
||||
try {
|
||||
const base = process.env.BETTER_AUTH_URL ?? request.nextUrl.origin
|
||||
const res = await fetch(new URL("/api/auth/get-session", base), {
|
||||
headers: { cookie: request.headers.get("cookie") ?? "" },
|
||||
cache: "no-store",
|
||||
})
|
||||
if (!res.ok) return "unknown"
|
||||
// Better Auth returns JSON `null` when the session is missing or revoked.
|
||||
const session = await res.json()
|
||||
return session ? "valid" : "invalid"
|
||||
} catch {
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
export async function proxy(request: NextRequest) {
|
||||
const pathname = request.nextUrl.pathname
|
||||
|
||||
@@ -76,10 +121,18 @@ export async function proxy(request: NextRequest) {
|
||||
}
|
||||
|
||||
const isAuthPage = AUTH_PATHS.some((p) => pathname.startsWith(p))
|
||||
let dropStaleSessionCookie = false
|
||||
if (isAuthPage && sessionCookie) {
|
||||
const url = request.nextUrl.clone()
|
||||
url.pathname = "/dashboard"
|
||||
return NextResponse.redirect(url)
|
||||
const state = await sessionState(request)
|
||||
if (state === "valid") {
|
||||
const url = request.nextUrl.clone()
|
||||
url.pathname = "/dashboard"
|
||||
return NextResponse.redirect(url)
|
||||
}
|
||||
// Dead cookie (session revoked or expired): render the auth page and drop
|
||||
// the cookie below so protected paths stop treating this visitor as
|
||||
// signed in. On "unknown", render the page but keep the cookie.
|
||||
dropStaleSessionCookie = state === "invalid"
|
||||
}
|
||||
|
||||
// Per-request CSP nonce. UUID contains only hex + dashes, so it never
|
||||
@@ -96,6 +149,21 @@ export async function proxy(request: NextRequest) {
|
||||
const response = NextResponse.next({ request: { headers: requestHeaders } })
|
||||
// Also set the CSP on the outgoing response so the browser enforces it.
|
||||
response.headers.set("Content-Security-Policy", csp)
|
||||
|
||||
if (dropStaleSessionCookie) {
|
||||
// Covers both the plain and __Secure-prefixed Better Auth cookie names.
|
||||
for (const cookie of request.cookies.getAll()) {
|
||||
if (!cookie.name.includes("better-auth.session_token")) continue
|
||||
response.cookies.set(cookie.name, "", {
|
||||
maxAge: 0,
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: cookie.name.startsWith("__Secure-"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user