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',
|
'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'];
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user