fix(audit): H1 (webhook redirect SSRF), H6 (berth-status case), H7 (residential notes URL)

H1: webhook delivery fetch now uses redirect:'manual' and refuses to read
or expose a redirected (un-revalidated) response, closing the SSRF read
primitive. H6: dashboard report queries matched title-case 'Sold'/'Under
offer' that never match the lowercase canonical, silently reporting 0 sold
/ understated occupancy — now lowercase. H7: NotesList maps the entityType
discriminator to its REST path (residential_* -> residential/clients|
interests) instead of interpolating the raw underscore, which 404'd every
residential notes request.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 12:03:35 +02:00
parent 3c9310f81c
commit f4fb7aae84
3 changed files with 35 additions and 12 deletions

View File

@@ -70,6 +70,20 @@ const AGGREGATABLE: ReadonlySet<NotesEntityType> = new Set<NotesEntityType>([
'residential_clients', 'residential_clients',
]); ]);
/** Maps the entityType discriminator to its REST path segment. The
* residential entities use slash-separated routes
* (`/api/v1/residential/clients/...`), so the raw underscore discriminator
* must NOT be interpolated into the URL — doing so 404'd every residential
* notes request and the UI silently showed "No notes yet" (audit H7). */
const NOTES_API_PATH: Record<NotesEntityType, string> = {
clients: 'clients',
interests: 'interests',
yachts: 'yachts',
companies: 'companies',
residential_clients: 'residential/clients',
residential_interests: 'residential/interests',
};
const SOURCE_BADGE_CLASS: Record<NoteSource, string> = { const SOURCE_BADGE_CLASS: Record<NoteSource, string> = {
client: 'bg-violet-100 text-violet-900', client: 'bg-violet-100 text-violet-900',
interest: 'bg-blue-100 text-blue-900', interest: 'bg-blue-100 text-blue-900',
@@ -189,7 +203,7 @@ export function NotesList({
}, []); }, []);
const aggregateOn = !!aggregate && AGGREGATABLE.has(entityType); const aggregateOn = !!aggregate && AGGREGATABLE.has(entityType);
const baseEndpoint = `/api/v1/${entityType}/${entityId}/notes`; const baseEndpoint = `/api/v1/${NOTES_API_PATH[entityType]}/${entityId}/notes`;
const listEndpoint = aggregateOn ? `${baseEndpoint}?aggregate=true` : baseEndpoint; const listEndpoint = aggregateOn ? `${baseEndpoint}?aggregate=true` : baseEndpoint;
const queryKey = [entityType, entityId, 'notes', aggregateOn ? 'aggregated' : 'own']; const queryKey = [entityType, entityId, 'notes', aggregateOn ? 'aggregated' : 'own'];

View File

@@ -223,6 +223,11 @@ export const webhooksWorker = new Worker(
const response = await fetch(webhook.url, { const response = await fetch(webhook.url, {
method: 'POST', method: 'POST',
// SSRF guard (audit H1): never follow redirects. resolveAndCheckHost
// validated the configured host, but a 3xx Location could point at
// cloud-metadata / RFC1918 with no re-validation. With 'manual' the
// redirect is returned, not followed, and we refuse to read its body.
redirect: 'manual',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'User-Agent': 'PortNimara-Webhook/1.0', 'User-Agent': 'PortNimara-Webhook/1.0',
@@ -238,10 +243,19 @@ export const webhooksWorker = new Worker(
clearTimeout(timeoutId); clearTimeout(timeoutId);
responseStatus = response.status; responseStatus = response.status;
// Read up to 1KB of response body if (response.status >= 300 && response.status < 400) {
const rawBody = await response.text(); // Redirect not followed — treat as a delivery failure and do NOT
responseBody = rawBody.slice(0, 1024); // read/expose the (un-validated) redirected response.
success = response.status >= 200 && response.status < 300; responseBody = `Blocked: redirect (${response.status} -> ${
response.headers.get('location') ?? 'unknown'
}) not followed (SSRF guard)`;
success = false;
} else {
// Read up to 1KB of response body
const rawBody = await response.text();
responseBody = rawBody.slice(0, 1024);
success = response.status >= 200 && response.status < 300;
}
} catch (err) { } catch (err) {
// Network error or timeout // Network error or timeout
logger.warn({ webhookId, deliveryId, err }, 'Webhook delivery network error'); logger.warn({ webhookId, deliveryId, err }, 'Webhook delivery network error');

View File

@@ -286,7 +286,7 @@ export async function resolveDashboardReportData(
and( and(
eq(auditLogs.portId, portId), eq(auditLogs.portId, portId),
eq(auditLogs.entityType, 'berth'), eq(auditLogs.entityType, 'berth'),
sql`${auditLogs.newValue}->>'status' = 'Sold'`, sql`${auditLogs.newValue}->>'status' = 'sold'`,
gte(auditLogs.createdAt, windowFrom), gte(auditLogs.createdAt, windowFrom),
lte(auditLogs.createdAt, windowTo), lte(auditLogs.createdAt, windowTo),
), ),
@@ -457,12 +457,7 @@ export async function resolveDashboardReportData(
const [{ occCount = 0 } = {}] = await db const [{ occCount = 0 } = {}] = await db
.select({ occCount: count() }) .select({ occCount: count() })
.from(berths) .from(berths)
.where( .where(and(eq(berths.portId, portId), sql`${berths.status} IN ('sold', 'under_offer')`));
and(
eq(berths.portId, portId),
sql`${berths.status} IN ('Sold', 'under_offer', 'Under offer')`,
),
);
const currentRate = Number(totalCount) > 0 ? (Number(occCount) / Number(totalCount)) * 100 : 0; const currentRate = Number(totalCount) > 0 ? (Number(occCount) / Number(totalCount)) * 100 : 0;
const series: Array<{ date: string; rate: number }> = []; const series: Array<{ date: string; rate: number }> = [];
const dayMs = 86_400_000; const dayMs = 86_400_000;