fix(tradein-frontend): safeUrl validator, apiFetch dedup, ListingsCard a11y, error boundary
Four security/UX fixes from 2026-05-24 audit: * lib/safeUrl.ts (new): validate external URLs, reject non-http(s) schemes (closes finding #3 — backend supplies lot.source_url verbatim from scraped sites; javascript:/data:/file: would have executed on click). * lib/api.ts: refactor apiFetch to delegate to apiFetchWithStatus — single read-body-once implementation, drops the body-stream-twice anti-pattern (finding #8). * components/trade-in/ListingsCard.tsx: drop tr.onClick (not keyboard accessible), keep the inline <a> as the only interactive element, gate it through safeUrl() (findings #7 + #3). * app/error.tsx (new): Next 15 App Router route-segment error boundary — prevents white-screen on component crash (finding #16). Scope-cut from original audit: * #17 (sessionId crypto.randomUUID) — already implemented in sessionId.ts; no change needed. * safeUrl wiring in DealsCard / IMVBenchmark — neither renders user-supplied URLs (DealsCard has no links, IMVBenchmark links a hardcoded domain).
This commit is contained in:
parent
0767fb7401
commit
5bd9c2f66d
4 changed files with 96 additions and 26 deletions
55
tradein-mvp/frontend/src/app/error.tsx
Normal file
55
tradein-mvp/frontend/src/app/error.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App Router error boundary for the trade-in page (Next 15 idiom).
|
||||||
|
*
|
||||||
|
* Triggers when a component below `app/page.tsx` throws during render or
|
||||||
|
* inside an Effect. Without this, the page would white-screen. Closes
|
||||||
|
* finding #16 from 2026-05-24 trade-in audit.
|
||||||
|
*/
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Error({ error, reset }: Props) {
|
||||||
|
useEffect(() => {
|
||||||
|
// Surfaces to browser devtools + (if wired) Sentry.
|
||||||
|
console.error("trade-in page error:", error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="page" role="alert" style={{ padding: "32px 16px", maxWidth: 600, margin: "0 auto" }}>
|
||||||
|
<h1 style={{ marginBottom: 12, fontSize: 20, fontWeight: 600 }}>
|
||||||
|
Что-то пошло не так
|
||||||
|
</h1>
|
||||||
|
<p style={{ color: "var(--muted)", marginBottom: 16, lineHeight: 1.5 }}>
|
||||||
|
Произошла ошибка при отображении страницы. Можно попробовать ещё раз —
|
||||||
|
если повторяется, перезагрузите страницу.
|
||||||
|
</p>
|
||||||
|
{error.digest && (
|
||||||
|
<p style={{ color: "var(--muted-2)", fontSize: 12, marginBottom: 16, fontFamily: "monospace" }}>
|
||||||
|
ID ошибки: {error.digest}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={reset}
|
||||||
|
style={{
|
||||||
|
padding: "10px 16px",
|
||||||
|
background: "var(--accent)",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Попробовать ещё раз
|
||||||
|
</button>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
* Counts strip + filters + price bar + table.
|
* Counts strip + filters + price bar + table.
|
||||||
*/
|
*/
|
||||||
import type { AggregatedEstimate, AnalogLot } from "@/types/trade-in";
|
import type { AggregatedEstimate, AnalogLot } from "@/types/trade-in";
|
||||||
|
import { safeUrl } from "@/lib/safeUrl";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
estimate: AggregatedEstimate;
|
estimate: AggregatedEstimate;
|
||||||
|
|
@ -243,13 +244,15 @@ function AnalogRow({ lot }: { lot: AnalogLot }) {
|
||||||
const dot = SOURCE_DOTS[src] ?? "dom";
|
const dot = SOURCE_DOTS[src] ?? "dom";
|
||||||
const label = SOURCE_LABELS[src] ?? src;
|
const label = SOURCE_LABELS[src] ?? src;
|
||||||
const distance =
|
const distance =
|
||||||
lot.distance_m === null ? "—" : lot.distance_m < 1000 ? `${lot.distance_m} м` : `${(lot.distance_m / 1000).toFixed(1)} км`;
|
lot.distance_m === null
|
||||||
const onClick = lot.source_url
|
? "—"
|
||||||
? () => window.open(lot.source_url!, "_blank", "noopener,noreferrer")
|
: lot.distance_m < 1000
|
||||||
: undefined;
|
? `${lot.distance_m} м`
|
||||||
|
: `${(lot.distance_m / 1000).toFixed(1)} км`;
|
||||||
|
const externalUrl = safeUrl(lot.source_url);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr onClick={onClick} style={{ cursor: lot.source_url ? "pointer" : "default" }}>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<div className="addr">
|
<div className="addr">
|
||||||
<span className="a-main">{lot.address}</span>
|
<span className="a-main">{lot.address}</span>
|
||||||
|
|
@ -270,13 +273,12 @@ function AnalogRow({ lot }: { lot: AnalogLot }) {
|
||||||
<td className="num">{fmtRub(lot.price_rub)}</td>
|
<td className="num">{fmtRub(lot.price_rub)}</td>
|
||||||
<td className="num">{distance}</td>
|
<td className="num">{distance}</td>
|
||||||
<td>
|
<td>
|
||||||
{lot.source_url && (
|
{externalUrl && (
|
||||||
<a
|
<a
|
||||||
href={lot.source_url}
|
href={externalUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="row-link"
|
className="row-link"
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
aria-label="Открыть оригинал"
|
aria-label="Открыть оригинал"
|
||||||
>
|
>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
|
|
||||||
|
|
@ -26,24 +26,6 @@ function withSessionHeader(init?: RequestInit): RequestInit {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiFetch<T>(
|
|
||||||
path: string,
|
|
||||||
init?: RequestInit,
|
|
||||||
): Promise<T> {
|
|
||||||
const merged = withSessionHeader(init);
|
|
||||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
|
||||||
...merged,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
...(merged.headers ?? {}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`API error ${response.status}: ${await response.text()}`);
|
|
||||||
}
|
|
||||||
return response.json() as Promise<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Status-aware variant of apiFetch — возвращает status + body для случаев
|
* Status-aware variant of apiFetch — возвращает status + body для случаев
|
||||||
* когда логика зависит от HTTP кода (например 202 Accepted для async jobs).
|
* когда логика зависит от HTTP кода (например 202 Accepted для async jobs).
|
||||||
|
|
@ -93,3 +75,11 @@ export async function apiFetchWithStatus<T>(
|
||||||
}
|
}
|
||||||
return { status: response.status, body: body as T };
|
return { status: response.status, body: body as T };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function apiFetch<T>(
|
||||||
|
path: string,
|
||||||
|
init?: RequestInit,
|
||||||
|
): Promise<T> {
|
||||||
|
const { body } = await apiFetchWithStatus<T>(path, init);
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
|
||||||
23
tradein-mvp/frontend/src/lib/safeUrl.ts
Normal file
23
tradein-mvp/frontend/src/lib/safeUrl.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
/**
|
||||||
|
* Validate an external URL string. Returns the URL only if it parses and uses
|
||||||
|
* an http/https scheme — otherwise null.
|
||||||
|
*
|
||||||
|
* Defends against:
|
||||||
|
* - javascript: URLs (XSS via href clicks)
|
||||||
|
* - data: URLs (data exfiltration, weird content)
|
||||||
|
* - file:, ftp:, custom-scheme abuse
|
||||||
|
* - malformed strings (null reference protection)
|
||||||
|
*
|
||||||
|
* Closes finding #3 from 2026-05-24 trade-in audit. Backend supplies
|
||||||
|
* lot.source_url verbatim from scraped sites — must not trust the string.
|
||||||
|
*/
|
||||||
|
export function safeUrl(raw: string | null | undefined): string | null {
|
||||||
|
if (!raw || typeof raw !== "string") return null;
|
||||||
|
try {
|
||||||
|
const u = new URL(raw);
|
||||||
|
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
|
||||||
|
return u.toString();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue