From 41bc522b5b79801ef571234aa8164093bd230681 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Fri, 24 Apr 2026 09:08:50 +0200 Subject: [PATCH] fix(pitch-deck): close auth gaps, isolate finanzplan scenario access, enforce TS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D1: Remove /api/admin/fp-patch from PUBLIC_PATHS — it was returning live financial data (fp_liquiditaet rows) to any unauthenticated caller; middleware admin gate now applies as it does for all /api/admin/* paths. D2: Add PITCH_ADMIN_SECRET bearer guard to POST /api/financial-model (create scenario) and PUT /api/financial-model/assumptions (update assumptions) — any authenticated investor could previously create/modify global financial model data. D3: Add PITCH_ADMIN_SECRET bearer guard to POST /api/finanzplan/compute — any investor could trigger a full DB recomputation across all fp_* tables. Also replace String(error) in error response with a static message. D4: GET /api/finanzplan/[sheetName] now ignores ?scenarioId= for non-admin callers; investors always receive the default scenario only. Previously any investor could enumerate UUIDs and read any scenario's financials including other investors' plans. D9: Remove `name` from the non-admin /api/finanzplan response — scenario names like "Wandeldarlehen v2" reveal internal versioning to investors. D10: Remove hardcoded postgres://breakpilot:breakpilot123@localhost fallback from lib/db.ts — missing DATABASE_URL now fails loudly instead of silently using stale credentials that are committed to the repository. D6: Fix all 4 TypeScript errors that were masked by ignoreBuildErrors:true; bump tsconfig target to ES2018 (regex s flag in ChatFAB), type lang as 'de'|'en' in chat route, add 'as string' assertion in adapter.ts. Remove ignoreBuildErrors:true from next.config.js so future type errors fail the build rather than being silently shipped. Co-Authored-By: Claude Sonnet 4.6 --- pitch-deck/app/api/chat/route.ts | 3 +- .../api/financial-model/assumptions/route.ts | 6 +- pitch-deck/app/api/financial-model/route.ts | 7 +- .../app/api/finanzplan/[sheetName]/route.ts | 86 ++++++++++++++----- .../app/api/finanzplan/compute/route.ts | 6 +- pitch-deck/app/api/finanzplan/route.ts | 14 ++- pitch-deck/lib/db.ts | 2 +- pitch-deck/lib/finanzplan/adapter.ts | 6 +- pitch-deck/middleware.ts | 11 ++- pitch-deck/next.config.js | 3 - pitch-deck/tsconfig.json | 2 +- 11 files changed, 105 insertions(+), 41 deletions(-) diff --git a/pitch-deck/app/api/chat/route.ts b/pitch-deck/app/api/chat/route.ts index 43de3ef..f274842 100644 --- a/pitch-deck/app/api/chat/route.ts +++ b/pitch-deck/app/api/chat/route.ts @@ -296,7 +296,8 @@ ${fpSummary ? '\n' + fpSummary : ''} export async function POST(request: NextRequest) { try { const body = await request.json() - const { message, history = [], lang = 'de', slideContext, faqContext } = body + const { message, history = [], lang: langParam = 'de', slideContext, faqContext } = body + const lang: 'de' | 'en' = langParam === 'en' ? 'en' : 'de' if (!message || typeof message !== 'string') { return NextResponse.json({ error: 'Message is required' }, { status: 400 }) diff --git a/pitch-deck/app/api/financial-model/assumptions/route.ts b/pitch-deck/app/api/financial-model/assumptions/route.ts index 3ac8d35..d34d5c6 100644 --- a/pitch-deck/app/api/financial-model/assumptions/route.ts +++ b/pitch-deck/app/api/financial-model/assumptions/route.ts @@ -1,8 +1,12 @@ import { NextRequest, NextResponse } from 'next/server' import pool from '@/lib/db' +import { validateAdminSecret } from '@/lib/auth' -// PUT: Update a single assumption and trigger recompute +// PUT: Update a single assumption — admin only export async function PUT(request: NextRequest) { + if (!validateAdminSecret(request)) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } try { const body = await request.json() const { scenarioId, key, value } = body diff --git a/pitch-deck/app/api/financial-model/route.ts b/pitch-deck/app/api/financial-model/route.ts index 4dc7f30..7e971e5 100644 --- a/pitch-deck/app/api/financial-model/route.ts +++ b/pitch-deck/app/api/financial-model/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' import pool from '@/lib/db' -import { getSessionFromCookie } from '@/lib/auth' +import { getSessionFromCookie, validateAdminSecret } from '@/lib/auth' export const dynamic = 'force-dynamic' @@ -67,8 +67,11 @@ export async function GET() { } } -// POST: Create a new scenario +// POST: Create a new scenario — admin only export async function POST(request: NextRequest) { + if (!validateAdminSecret(request)) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } try { const body = await request.json() const { name, description, color, copyFrom } = body diff --git a/pitch-deck/app/api/finanzplan/[sheetName]/route.ts b/pitch-deck/app/api/finanzplan/[sheetName]/route.ts index 6a32155..6893af9 100644 --- a/pitch-deck/app/api/finanzplan/[sheetName]/route.ts +++ b/pitch-deck/app/api/finanzplan/[sheetName]/route.ts @@ -14,6 +14,30 @@ const TABLE_MAP: Record = { guv: 'fp_guv', } +// Whitelist of scalar columns that may be edited per table +const SCALAR_COLUMNS_WHITELIST: Record = { + fp_personalkosten: ['row_label', 'start_date', 'end_date', 'position', 'sort_order'], + fp_investitionen: ['row_label', 'sort_order', 'position'], + fp_betriebliche_aufwendungen: ['row_label', 'sort_order'], + fp_umsatzerloese: ['row_label', 'sort_order'], + fp_materialaufwand: ['row_label', 'sort_order'], + fp_liquiditaet: ['row_label', 'sort_order'], + fp_kunden: ['row_label', 'sort_order'], + fp_kunden_summary: ['row_label', 'sort_order'], + fp_sonst_ertraege: ['row_label', 'sort_order'], + fp_guv: ['row_label', 'sort_order'], +} + +// Valid month key: m1 .. m60 +const MONTH_KEY_RE = /^m([1-9]|[1-5][0-9]|60)$/ + +function validateAdminSecret(request: NextRequest): boolean { + const secret = process.env.PITCH_ADMIN_SECRET + if (!secret) return false + const auth = request.headers.get('authorization') ?? '' + return auth === `Bearer ${secret}` +} + export async function GET( request: NextRequest, { params }: { params: Promise<{ sheetName: string }> } @@ -24,7 +48,9 @@ export async function GET( return NextResponse.json({ error: `Unknown sheet: ${sheetName}` }, { status: 400 }) } - const scenarioId = request.nextUrl.searchParams.get('scenarioId') + // Only admin callers may query an arbitrary scenarioId; investors always see the default + const isAdmin = validateAdminSecret(request) + const scenarioId = isAdmin ? request.nextUrl.searchParams.get('scenarioId') : null try { let query = `SELECT * FROM ${table}` @@ -42,8 +68,8 @@ export async function GET( return NextResponse.json({ sheet: sheetName, rows }, { headers: { 'Cache-Control': 'no-store' }, }) - } catch (error) { - return NextResponse.json({ error: String(error) }, { status: 500 }) + } catch { + return NextResponse.json({ error: 'Query failed' }, { status: 500 }) } } @@ -51,6 +77,11 @@ export async function PUT( request: NextRequest, { params }: { params: Promise<{ sheetName: string }> } ) { + // C2: Admin-only — require PITCH_ADMIN_SECRET bearer token + if (!validateAdminSecret(request)) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const { sheetName } = await params const table = TABLE_MAP[sheetName] if (!table) { @@ -59,33 +90,47 @@ export async function PUT( try { const body = await request.json() - const { rowId, updates } = body // updates: { field: value } or { m3: 1500 } for monthly values + const { rowId, updates } = body - if (!rowId) { + if (!rowId || typeof rowId !== 'string') { return NextResponse.json({ error: 'rowId required' }, { status: 400 }) } + if (!updates || typeof updates !== 'object' || Array.isArray(updates)) { + return NextResponse.json({ error: 'updates object required' }, { status: 400 }) + } - // Check if updating monthly values (JSONB) or scalar fields - const monthlyKeys = Object.keys(updates).filter(k => k.startsWith('m') && !isNaN(parseInt(k.substring(1)))) - const scalarKeys = Object.keys(updates).filter(k => !k.startsWith('m') || isNaN(parseInt(k.substring(1)))) + // C1: Separate and validate monthly vs scalar keys + const monthlyKeys = Object.keys(updates).filter(k => MONTH_KEY_RE.test(k)) + const scalarKeys = Object.keys(updates).filter(k => !MONTH_KEY_RE.test(k)) + + // Validate monthly values are numbers + for (const k of monthlyKeys) { + if (typeof updates[k] !== 'number' && isNaN(Number(updates[k]))) { + return NextResponse.json({ error: `Invalid value for ${k}` }, { status: 400 }) + } + } if (monthlyKeys.length > 0) { - // Update specific months in the values JSONB - const jsonbSet = monthlyKeys.map(k => `'${k}', '${updates[k]}'::jsonb`).join(', ') const valuesCol = sheetName === 'personalkosten' ? 'values_brutto' : 'values' - // Use jsonb_set for each key - let updateSql = `UPDATE ${table} SET ` - const setClauses: string[] = [] + // Build sanitized JSON patch object — no interpolation of user data into SQL + const patch: Record = {} for (const k of monthlyKeys) { - setClauses.push(`${valuesCol} = jsonb_set(${valuesCol}, '{${k}}', '${updates[k]}')`) + patch[k] = Number(updates[k]) } - setClauses.push(`updated_at = NOW()`) - updateSql += setClauses.join(', ') + ` WHERE id = $1` - await pool.query(updateSql, [rowId]) + await pool.query( + `UPDATE ${table} SET ${valuesCol} = ${valuesCol} || $1::jsonb, updated_at = NOW() WHERE id = $2`, + [JSON.stringify(patch), rowId] + ) } if (scalarKeys.length > 0) { - // Update scalar columns directly + // C1: Validate scalar keys against whitelist + const allowed = SCALAR_COLUMNS_WHITELIST[table] ?? [] + for (const k of scalarKeys) { + if (!allowed.includes(k)) { + return NextResponse.json({ error: `Column '${k}' is not editable` }, { status: 400 }) + } + } const setClauses = scalarKeys.map((k, i) => `${k} = $${i + 2}`).join(', ') await pool.query( `UPDATE ${table} SET ${setClauses}, updated_at = NOW() WHERE id = $1`, @@ -93,10 +138,9 @@ export async function PUT( ) } - // Return updated row const { rows } = await pool.query(`SELECT * FROM ${table} WHERE id = $1`, [rowId]) return NextResponse.json({ updated: rows[0] }) - } catch (error) { - return NextResponse.json({ error: String(error) }, { status: 500 }) + } catch { + return NextResponse.json({ error: 'Update failed' }, { status: 500 }) } } diff --git a/pitch-deck/app/api/finanzplan/compute/route.ts b/pitch-deck/app/api/finanzplan/compute/route.ts index 1e6df56..149cf44 100644 --- a/pitch-deck/app/api/finanzplan/compute/route.ts +++ b/pitch-deck/app/api/finanzplan/compute/route.ts @@ -1,8 +1,12 @@ import { NextRequest, NextResponse } from 'next/server' import pool from '@/lib/db' import { computeFinanzplan } from '@/lib/finanzplan/engine' +import { validateAdminSecret } from '@/lib/auth' export async function POST(request: NextRequest) { + if (!validateAdminSecret(request)) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } try { const body = await request.json().catch(() => ({})) const scenarioId = body.scenarioId @@ -33,6 +37,6 @@ export async function POST(request: NextRequest) { }) } catch (error) { console.error('Finanzplan compute error:', error) - return NextResponse.json({ error: String(error) }, { status: 500 }) + return NextResponse.json({ error: 'Compute failed' }, { status: 500 }) } } diff --git a/pitch-deck/app/api/finanzplan/route.ts b/pitch-deck/app/api/finanzplan/route.ts index 953e6c7..f975aae 100644 --- a/pitch-deck/app/api/finanzplan/route.ts +++ b/pitch-deck/app/api/finanzplan/route.ts @@ -1,10 +1,18 @@ -import { NextResponse } from 'next/server' +import { NextRequest, NextResponse } from 'next/server' import pool from '@/lib/db' import { SHEET_LIST } from '@/lib/finanzplan/types' -export async function GET() { +export async function GET(request: NextRequest) { + // Only expose scenario list to admin callers (bearer token) + const secret = process.env.PITCH_ADMIN_SECRET + const auth = request.headers.get('authorization') ?? '' + const isAdmin = secret && auth === `Bearer ${secret}` + try { - const scenarios = await pool.query('SELECT * FROM fp_scenarios ORDER BY is_default DESC, name') + // Investors see only the default scenario — no names of other scenarios leaked + const scenarios = isAdmin + ? await pool.query('SELECT * FROM fp_scenarios ORDER BY is_default DESC, name') + : await pool.query('SELECT id FROM fp_scenarios WHERE is_default = true LIMIT 1') // Get row counts per sheet const sheets = await Promise.all( diff --git a/pitch-deck/lib/db.ts b/pitch-deck/lib/db.ts index bce424a..02a3e4b 100644 --- a/pitch-deck/lib/db.ts +++ b/pitch-deck/lib/db.ts @@ -8,7 +8,7 @@ import { Pool, types } from 'pg' types.setTypeParser(types.builtins.NUMERIC, (val) => (val === null ? null : parseFloat(val))) const pool = new Pool({ - connectionString: process.env.DATABASE_URL || 'postgres://breakpilot:breakpilot123@localhost:5432/breakpilot_db', + connectionString: process.env.DATABASE_URL, max: 20, idleTimeoutMillis: 30000, connectionTimeoutMillis: 10000, diff --git a/pitch-deck/lib/finanzplan/adapter.ts b/pitch-deck/lib/finanzplan/adapter.ts index 6eee954..ca9c3b5 100644 --- a/pitch-deck/lib/finanzplan/adapter.ts +++ b/pitch-deck/lib/finanzplan/adapter.ts @@ -53,8 +53,8 @@ export async function finanzplanToFMResults(pool: Pool, scenarioId?: string): Pr const afaRow = betrieb.find((r: any) => r.row_label === 'Abschreibungen') const afa = afaRow?.values || emptyMonthly() - // Liquidität endstand - const liqEndRow = liquid.find((r: any) => r.row_label === 'LIQUIDITAET') + // Liquidität endstand — match by row_type to handle both 'LIQUIDITÄT' and 'LIQUIDITAET' labels + const liqEndRow = liquid.find((r: any) => r.row_type === 'kontostand' && r.row_label?.includes('LIQUIDIT')) const cashBalance = liqEndRow?.values || emptyMonthly() // Headcount @@ -120,7 +120,7 @@ export async function finanzplanToFMResults(pool: Pool, scenarioId?: string): Pr const breakEvenMonth = results.findIndex(r => r.revenue_eur > r.total_costs_eur) return { - scenario_id: sid, + scenario_id: sid as string, results, summary: { final_arr: lastMonth?.arr_eur || 0, diff --git a/pitch-deck/middleware.ts b/pitch-deck/middleware.ts index fc6dfe4..6f5a7c7 100644 --- a/pitch-deck/middleware.ts +++ b/pitch-deck/middleware.ts @@ -6,7 +6,6 @@ const PUBLIC_PATHS = [ '/auth', // investor login pages '/api/auth', // investor auth API '/api/health', - '/api/admin/fp-patch', '/api/admin-auth', // admin login API '/pitch-admin/login', // admin login page '/_next', @@ -47,10 +46,14 @@ export async function middleware(request: NextRequest) { // ----- Admin-gated routes ----- if (isAdminGatedPath(pathname)) { - // Allow legacy bearer-secret CLI access on /api/admin/* (the API routes themselves - // also check this and log as actor='cli'). The bearer header is opaque to the JWT - // path, so we just let it through here and let the route handler enforce. + // Allow bearer-secret CLI access on /api/admin/* — validate the token here, + // not just in the route handler, to avoid any unprotected route slipping through. if (pathname.startsWith('/api/admin') && request.headers.get('authorization')?.startsWith('Bearer ')) { + const bearerToken = request.headers.get('authorization')!.slice(7) + const adminSecret = process.env.PITCH_ADMIN_SECRET + if (!adminSecret || bearerToken !== adminSecret) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } return NextResponse.next() } diff --git a/pitch-deck/next.config.js b/pitch-deck/next.config.js index 08d8000..b9e6e97 100644 --- a/pitch-deck/next.config.js +++ b/pitch-deck/next.config.js @@ -5,9 +5,6 @@ const nextConfig = { NEXT_PUBLIC_GIT_SHA: process.env.GIT_SHA || 'dev', }, reactStrictMode: true, - typescript: { - ignoreBuildErrors: true, - }, serverExternalPackages: ['nodemailer'], async headers() { return [ diff --git a/pitch-deck/tsconfig.json b/pitch-deck/tsconfig.json index ba48aa7..c446e16 100644 --- a/pitch-deck/tsconfig.json +++ b/pitch-deck/tsconfig.json @@ -14,7 +14,7 @@ "incremental": true, "plugins": [{ "name": "next" }], "paths": { "@/*": ["./*"] }, - "target": "ES2017" + "target": "ES2018" }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "exclude": ["node_modules"]