fix(tradein-frontend): safeUrl validator, apiFetch dedup, ListingsCard a11y, error boundary #512

Merged
lekss361 merged 1 commit from feat/tradein-frontend-security-a11y into main 2026-05-24 11:47:39 +00:00
4 changed files with 96 additions and 26 deletions

View 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>
);
}

View file

@ -5,6 +5,7 @@
* Counts strip + filters + price bar + table.
*/
import type { AggregatedEstimate, AnalogLot } from "@/types/trade-in";
import { safeUrl } from "@/lib/safeUrl";
interface Props {
estimate: AggregatedEstimate;
@ -243,13 +244,15 @@ function AnalogRow({ lot }: { lot: AnalogLot }) {
const dot = SOURCE_DOTS[src] ?? "dom";
const label = SOURCE_LABELS[src] ?? src;
const distance =
lot.distance_m === null ? "—" : lot.distance_m < 1000 ? `${lot.distance_m} м` : `${(lot.distance_m / 1000).toFixed(1)} км`;
const onClick = lot.source_url
? () => window.open(lot.source_url!, "_blank", "noopener,noreferrer")
: undefined;
lot.distance_m === null
? "—"
: lot.distance_m < 1000
? `${lot.distance_m} м`
: `${(lot.distance_m / 1000).toFixed(1)} км`;
const externalUrl = safeUrl(lot.source_url);
return (
<tr onClick={onClick} style={{ cursor: lot.source_url ? "pointer" : "default" }}>
<tr>
<td>
<div className="addr">
<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">{distance}</td>
<td>
{lot.source_url && (
{externalUrl && (
<a
href={lot.source_url}
href={externalUrl}
target="_blank"
rel="noopener noreferrer"
className="row-link"
onClick={(e) => e.stopPropagation()}
aria-label="Открыть оригинал"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">

View file

@ -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 для случаев
* когда логика зависит от HTTP кода (например 202 Accepted для async jobs).
@ -93,3 +75,11 @@ export async function apiFetchWithStatus<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;
}

View 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;
}
}