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:
@@ -70,6 +70,20 @@ const AGGREGATABLE: ReadonlySet<NotesEntityType> = new Set<NotesEntityType>([
|
||||
'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> = {
|
||||
client: 'bg-violet-100 text-violet-900',
|
||||
interest: 'bg-blue-100 text-blue-900',
|
||||
@@ -189,7 +203,7 @@ export function NotesList({
|
||||
}, []);
|
||||
|
||||
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 queryKey = [entityType, entityId, 'notes', aggregateOn ? 'aggregated' : 'own'];
|
||||
|
||||
|
||||
@@ -223,6 +223,11 @@ export const webhooksWorker = new Worker(
|
||||
|
||||
const response = await fetch(webhook.url, {
|
||||
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: {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'PortNimara-Webhook/1.0',
|
||||
@@ -238,10 +243,19 @@ export const webhooksWorker = new Worker(
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
responseStatus = response.status;
|
||||
// Read up to 1KB of response body
|
||||
const rawBody = await response.text();
|
||||
responseBody = rawBody.slice(0, 1024);
|
||||
success = response.status >= 200 && response.status < 300;
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
// Redirect not followed — treat as a delivery failure and do NOT
|
||||
// read/expose the (un-validated) redirected response.
|
||||
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) {
|
||||
// Network error or timeout
|
||||
logger.warn({ webhookId, deliveryId, err }, 'Webhook delivery network error');
|
||||
|
||||
@@ -286,7 +286,7 @@ export async function resolveDashboardReportData(
|
||||
and(
|
||||
eq(auditLogs.portId, portId),
|
||||
eq(auditLogs.entityType, 'berth'),
|
||||
sql`${auditLogs.newValue}->>'status' = 'Sold'`,
|
||||
sql`${auditLogs.newValue}->>'status' = 'sold'`,
|
||||
gte(auditLogs.createdAt, windowFrom),
|
||||
lte(auditLogs.createdAt, windowTo),
|
||||
),
|
||||
@@ -457,12 +457,7 @@ export async function resolveDashboardReportData(
|
||||
const [{ occCount = 0 } = {}] = await db
|
||||
.select({ occCount: count() })
|
||||
.from(berths)
|
||||
.where(
|
||||
and(
|
||||
eq(berths.portId, portId),
|
||||
sql`${berths.status} IN ('Sold', 'under_offer', 'Under offer')`,
|
||||
),
|
||||
);
|
||||
.where(and(eq(berths.portId, portId), sql`${berths.status} IN ('sold', 'under_offer')`));
|
||||
const currentRate = Number(totalCount) > 0 ? (Number(occCount) / Number(totalCount)) * 100 : 0;
|
||||
const series: Array<{ date: string; rate: number }> = [];
|
||||
const dayMs = 86_400_000;
|
||||
|
||||
Reference in New Issue
Block a user