fix(pitch-deck): close auth gaps, isolate finanzplan scenario access, enforce TS
Some checks failed
Build pitch-deck / build-push-deploy (push) Failing after 1m4s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 57s
CI / test-python-voice (push) Successful in 42s
CI / test-bqas (push) Successful in 42s
Some checks failed
Build pitch-deck / build-push-deploy (push) Failing after 1m4s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 57s
CI / test-python-voice (push) Successful in 42s
CI / test-bqas (push) Successful in 42s
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 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,30 @@ const TABLE_MAP: Record<string, string> = {
|
||||
guv: 'fp_guv',
|
||||
}
|
||||
|
||||
// Whitelist of scalar columns that may be edited per table
|
||||
const SCALAR_COLUMNS_WHITELIST: Record<string, string[]> = {
|
||||
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<string, number> = {}
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user