fix(security): audit completo — secondo gate admin, hardening slug, XSS, CSP/HSTS, update CVE
Audit di sicurezza su tutta l'app. Report in .planning/SECURITY-SCAN.md (codice), .planning/SECURITY-AUDIT-INFRA.md (dipendenze/segreti/deploy) e piano in .planning/SECURITY-REMEDIATION-PLAN.md. CRITICO — l'autorizzazione admin era un unico punto di rottura: nessuna delle 21 pagine /admin controllava la sessione e admin/layout.tsx renderizzava comunque i figli quando mancava. L'unico guard era proxy.ts, su un Next.js affetto da GHSA-6gpp-xcg3-4w24 (proxy bypass). Ora il layout è un secondo gate indipendente; proxy.ts marca il path con un token derivato da NEXTAUTH_SECRET, così il gate non è aggirabile forgiando header e fallisce chiuso se il proxy non gira. ALTO — gli slug cliente avevano 4 caratteri casuali da Math.random() (~20 bit, 1.7M tentativi) e risolvono prima del token: ora 12 caratteri via nanoid (CSPRNG, ~62 bit). Aggiunto rate limit al ramo /client/, che ne era privo. ALTO — src/lib/quote-actions.ts esponeva due server action pubbliche senza autenticazione, una delle quali scriveva su DB. Codice morto, zero chiamanti: rimosso. MEDIO — i quattro dangerouslySetInnerHTML nelle sezioni proposta rendevano output AI come HTML grezzo su pagina pubblica, alimentato da transcript di terzi. Sostituiti con RichText (whitelist di emphasis, nessun HTML al DOM). I transcript ora sono recintati in tag che il system prompt dichiara essere dati, non istruzioni. Inoltre: next 16.2.6 -> 16.2.12 e next-auth 4.24.14 -> 4.24.15 (chiude 9 CVE Next piu GHSA-xmf8-cvqr-rfgj su getToken, raggiungibile dal proxy); HSTS e CSP; potatura della Map di rate-limit.ts, che cresceva senza limite; espunta la password Postgres di produzione dai due 07-01-SUMMARY.md. Verificato: tsc pulito, build OK, smoke test su login/redirect/header forgiati. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
import { Fragment } from "react";
|
||||
|
||||
/**
|
||||
* Renders AI-generated proposal strings with emphasis, without ever handing raw
|
||||
* HTML to the DOM.
|
||||
*
|
||||
* These strings come from Claude (src/lib/proposal/agent.ts), which builds its
|
||||
* prompt from client transcripts — third-party text. They were previously
|
||||
* rendered with dangerouslySetInnerHTML on the public /preventivo/[slug] page,
|
||||
* so a successful prompt injection became stored XSS against the prospect
|
||||
* (C-4 in .planning/SECURITY-SCAN.md). The prompt never asks for HTML in the
|
||||
* first place; the only markup worth keeping is emphasis.
|
||||
*
|
||||
* Recognises **bold** and <strong>/<b> and turns them into real React elements.
|
||||
* Anything else — including <img onerror>, <script>, stray angle brackets — is
|
||||
* emitted as text by React's normal escaping.
|
||||
*/
|
||||
|
||||
// No dotAll flag: emphasis is not expected to span lines, and the project's
|
||||
// TS target predates es2018.
|
||||
const PATTERN = /\*\*(.+?)\*\*|<(?:strong|b)>(.+?)<\/(?:strong|b)>/gi;
|
||||
|
||||
export function RichText({ children }: { children: string }) {
|
||||
const parts: React.ReactNode[] = [];
|
||||
let cursor = 0;
|
||||
|
||||
for (const match of children.matchAll(PATTERN)) {
|
||||
const at = match.index;
|
||||
if (at > cursor) parts.push(children.slice(cursor, at));
|
||||
parts.push(<strong key={at}>{match[1] ?? match[2]}</strong>);
|
||||
cursor = at + match[0].length;
|
||||
}
|
||||
|
||||
if (cursor < children.length) parts.push(children.slice(cursor));
|
||||
|
||||
return (
|
||||
<span>
|
||||
{parts.map((p, i) => (
|
||||
<Fragment key={i}>{p}</Fragment>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ProposalContent } from "@/lib/proposal/schema";
|
||||
import { X } from "lucide-react";
|
||||
import { RichText } from "@/components/public/proposal/RichText";
|
||||
|
||||
type Props = { deliverables: ProposalContent["deliverables"] };
|
||||
|
||||
@@ -18,7 +19,7 @@ export function DeliverablesSection({ deliverables }: Props) {
|
||||
{deliverables.deliverables.map((d, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm text-foreground">
|
||||
<span className="text-primary mt-0.5">›</span>
|
||||
<span dangerouslySetInnerHTML={{ __html: d }} />
|
||||
<RichText>{d}</RichText>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ProposalContent } from "@/lib/proposal/schema";
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
import { RichText } from "@/components/public/proposal/RichText";
|
||||
|
||||
type Props = { scope: ProposalContent["scope"] };
|
||||
|
||||
@@ -28,7 +29,7 @@ export function ScopeSection({ scope }: Props) {
|
||||
{scope.objectives.map((obj, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm text-foreground">
|
||||
<CheckCircle2 size={14} className="text-primary mt-0.5 shrink-0" />
|
||||
<span dangerouslySetInnerHTML={{ __html: obj }} />
|
||||
<RichText>{obj}</RichText>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { SolutionNode } from "@/lib/proposal/schema";
|
||||
import { RichText } from "@/components/public/proposal/RichText";
|
||||
|
||||
type Props = { solution: SolutionNode };
|
||||
|
||||
@@ -25,7 +26,7 @@ export function SolutionNodeSection({ solution }: Props) {
|
||||
{solution.throughWhat.map((item, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm text-foreground">
|
||||
<span className="text-primary mt-0.5">›</span>
|
||||
<span dangerouslySetInnerHTML={{ __html: item }} />
|
||||
<RichText>{item}</RichText>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ConsultantProfile } from "@/lib/proposal/profile";
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
import { RichText } from "@/components/public/proposal/RichText";
|
||||
|
||||
type Props = { consultant: ConsultantProfile };
|
||||
|
||||
@@ -33,7 +34,7 @@ export function StrategistSection({ consultant }: Props) {
|
||||
{consultant.credentials.map((c, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm text-muted-foreground">
|
||||
<CheckCircle2 size={14} className="text-primary mt-0.5 shrink-0" />
|
||||
<span dangerouslySetInnerHTML={{ __html: c }} />
|
||||
<RichText>{c}</RichText>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
Reference in New Issue
Block a user