Fix security audit findings: auth scoping, OIDC hardening, CSP, file download
Deploy to LXC / deploy (push) Successful in 1m56s
Validate / validate (push) Successful in 33s

C3: Budget allocation now verifies project belongs to company
M4: Expense approve/reject scoped by company via project join
H2: OIDC cookies get secure flag on HTTPS
H3: OIDC auto-link only when email_verified by provider
H4: Content-Security-Policy + X-Content-Type-Options in hooks
M7: SSRF favicon redirect depth capped at 3
M2: File downloads use attachment disposition (not inline)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-17 14:18:28 +07:00
parent dbfd229ba8
commit b4eda2d553
8 changed files with 34 additions and 16 deletions
+9 -1
View File
@@ -30,5 +30,13 @@ export const handle: Handle = async ({ event, resolve }) => {
event.locals.session = null; event.locals.session = null;
} }
return resolve(event); const response = await resolve(event);
response.headers.set(
'Content-Security-Policy',
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'"
);
response.headers.set('X-Content-Type-Options', 'nosniff');
return response;
}; };
+1 -1
View File
@@ -117,7 +117,7 @@ export async function exchangeCode(
export async function getUserInfo( export async function getUserInfo(
accessToken: string accessToken: string
): Promise<{ sub: string; email: string; name?: string }> { ): Promise<{ sub: string; email: string; name?: string; email_verified?: boolean }> {
const config = await getOIDCConfig(); const config = await getOIDCConfig();
const res = await fetch(config.userinfoEndpoint, { const res = await fetch(config.userinfoEndpoint, {
+3 -2
View File
@@ -74,7 +74,8 @@ async function resolvePublicIp(hostname: string): Promise<string> {
return ips[0]; return ips[0];
} }
async function safeFetch(targetUrl: URL): Promise<Response | null> { async function safeFetch(targetUrl: URL, depth = 0): Promise<Response | null> {
if (depth > 3) return null;
if (targetUrl.protocol !== 'http:' && targetUrl.protocol !== 'https:') return null; if (targetUrl.protocol !== 'http:' && targetUrl.protocol !== 'https:') return null;
try { try {
await resolvePublicIp(targetUrl.hostname); await resolvePublicIp(targetUrl.hostname);
@@ -109,7 +110,7 @@ async function safeFetch(targetUrl: URL): Promise<Response | null> {
} catch { } catch {
return null; return null;
} }
return safeFetch(next); return safeFetch(next, depth + 1);
} }
return res; return res;
} catch { } catch {
@@ -9,7 +9,7 @@ import {
expenses, expenses,
companyLog companyLog
} from '$lib/server/db/schema.js'; } from '$lib/server/db/schema.js';
import { eq, sql } from 'drizzle-orm'; import { and, eq, sql } from 'drizzle-orm';
import { requireCompanyRole } from '$lib/server/authorization.js'; import { requireCompanyRole } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js'; import { logCompanyEvent } from '$lib/server/audit.js';
import { formatCurrency } from '$lib/utils/currency.js'; import { formatCurrency } from '$lib/utils/currency.js';
@@ -118,12 +118,13 @@ export const actions: Actions = {
return fail(400, { error: 'Project and non-zero amount are required' }); return fail(400, { error: 'Project and non-zero amount are required' });
} }
// Get project name and company currency for the log // Verify project belongs to this company
const [project] = await db const [project] = await db
.select({ name: projects.name }) .select({ name: projects.name })
.from(projects) .from(projects)
.where(eq(projects.id, projectId)) .where(and(eq(projects.id, projectId), eq(projects.companyId, params.companyId)))
.limit(1); .limit(1);
if (!project) return fail(400, { error: 'Project not found in this company' });
const [company] = await db const [company] = await db
.select({ currency: companies.currency }) .select({ currency: companies.currency })
@@ -137,7 +138,7 @@ export const actions: Actions = {
allocatedBudget: sql`${projects.allocatedBudget}::numeric + ${amount.toFixed(2)}::numeric`, allocatedBudget: sql`${projects.allocatedBudget}::numeric + ${amount.toFixed(2)}::numeric`,
updatedAt: new Date() updatedAt: new Date()
}) })
.where(eq(projects.id, projectId)); .where(and(eq(projects.id, projectId), eq(projects.companyId, params.companyId)));
await db.insert(budgetAllocations).values({ await db.insert(budgetAllocations).values({
companyId: params.companyId, companyId: params.companyId,
@@ -41,8 +41,9 @@ export const GET: RequestHandler = async ({ locals, params }) => {
return new Response(new Blob([buf as BlobPart], { type: row.mimeType }), { return new Response(new Blob([buf as BlobPart], { type: row.mimeType }), {
headers: { headers: {
'Content-Disposition': `inline; filename="${safeName}"`, 'Content-Disposition': `attachment; filename="${safeName}"`,
'Cache-Control': 'private, no-store' 'Cache-Control': 'private, no-store',
'X-Content-Type-Options': 'nosniff'
} }
}); });
}; };
@@ -94,8 +94,10 @@ export const actions: Actions = {
accountId: expenses.accountId accountId: expenses.accountId
}) })
.from(expenses) .from(expenses)
.where(eq(expenses.id, expenseId)) .innerJoin(projects, eq(expenses.projectId, projects.id))
.where(and(eq(expenses.id, expenseId), eq(projects.companyId, params.companyId)))
.limit(1); .limit(1);
if (!expense) return fail(404, { error: 'Expense not found' });
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
await tx await tx
@@ -137,8 +139,10 @@ export const actions: Actions = {
const [expense] = await db const [expense] = await db
.select({ title: expenses.title, amount: expenses.amount, currency: expenses.currency }) .select({ title: expenses.title, amount: expenses.amount, currency: expenses.currency })
.from(expenses) .from(expenses)
.where(eq(expenses.id, expenseId)) .innerJoin(projects, eq(expenses.projectId, projects.id))
.where(and(eq(expenses.id, expenseId), eq(projects.companyId, params.companyId)))
.limit(1); .limit(1);
if (!expense) return fail(404, { error: 'Expense not found' });
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
await tx await tx
+4 -1
View File
@@ -7,7 +7,8 @@ import {
isOIDCEnabled isOIDCEnabled
} from '$lib/server/auth/oidc.js'; } from '$lib/server/auth/oidc.js';
export const GET: RequestHandler = async ({ cookies }) => { export const GET: RequestHandler = async ({ cookies, url: reqUrl }) => {
const isSecure = reqUrl.protocol === 'https:';
if (!isOIDCEnabled()) { if (!isOIDCEnabled()) {
redirect(302, '/login'); redirect(302, '/login');
} }
@@ -17,6 +18,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
cookies.set('oidc_state', state, { cookies.set('oidc_state', state, {
httpOnly: true, httpOnly: true,
secure: isSecure,
sameSite: 'lax', sameSite: 'lax',
path: '/', path: '/',
maxAge: 600 // 10 minutes maxAge: 600 // 10 minutes
@@ -24,6 +26,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
cookies.set('oidc_code_verifier', codeVerifier, { cookies.set('oidc_code_verifier', codeVerifier, {
httpOnly: true, httpOnly: true,
secure: isSecure,
sameSite: 'lax', sameSite: 'lax',
path: '/', path: '/',
maxAge: 600 maxAge: 600
+3 -3
View File
@@ -45,8 +45,8 @@ export const GET: RequestHandler = async (event) => {
.then((r) => r[0] ?? null); .then((r) => r[0] ?? null);
if (!user) { if (!user) {
// Check if a user with this email exists (link accounts) // Check if a user with this email exists — only auto-link if provider verified the email
if (userInfo.email) { if (userInfo.email && userInfo.email_verified) {
user = await db user = await db
.select() .select()
.from(users) .from(users)
@@ -55,7 +55,7 @@ export const GET: RequestHandler = async (event) => {
.then((r) => r[0] ?? null); .then((r) => r[0] ?? null);
if (user) { if (user) {
// Link OIDC identity to existing user // Link OIDC identity to existing user (email verified by provider)
await db await db
.update(users) .update(users)
.set({ .set({