fix(share): sanitize public HTML and harden token handling

Add a conservative sanitizer for shared note bodies before dangerouslySetInnerHTML.

Normalize dynamic route params, reject blank tokens, and require noteId in the JSON payload so empty titles still render.

Made-with: Cursor
This commit is contained in:
Saravana Achu Mac 2026-03-31 13:05:32 -07:00
parent 8d8540e320
commit 821d9aee35
2 changed files with 25 additions and 3 deletions

View File

@ -3,6 +3,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { NOTES_API_URL, PRODUCT_NAME } from "@/lib/product-config"; import { NOTES_API_URL, PRODUCT_NAME } from "@/lib/product-config";
import { sanitizeSharedNoteHtml } from "@/lib/sanitize-share-html";
type PublicNote = { type PublicNote = {
title: string; title: string;
@ -13,12 +14,17 @@ type PublicNote = {
export default function SharedNotePage() { export default function SharedNotePage() {
const params = useParams<{ token: string }>(); const params = useParams<{ token: string }>();
const token = params.token; const rawToken = params?.token;
const token = Array.isArray(rawToken) ? rawToken[0] : rawToken;
const [note, setNote] = useState<PublicNote | null>(null); const [note, setNote] = useState<PublicNote | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
void (async () => { void (async () => {
if (!token?.trim()) {
setError("Invalid share link.");
return;
}
try { try {
const base = NOTES_API_URL.replace(/\/$/, ""); const base = NOTES_API_URL.replace(/\/$/, "");
const res = await fetch(`${base}/public/note-shares/${encodeURIComponent(token)}`); const res = await fetch(`${base}/public/note-shares/${encodeURIComponent(token)}`);
@ -26,7 +32,11 @@ export default function SharedNotePage() {
setError("This share link is invalid or no longer available."); setError("This share link is invalid or no longer available.");
return; return;
} }
const data = (await res.json()) as PublicNote; const data = (await res.json()) as PublicNote & { error?: string };
if (!data.noteId) {
setError(data.error ?? "This share link is invalid or no longer available.");
return;
}
setNote(data); setNote(data);
} catch { } catch {
setError("Could not load shared note."); setError("Could not load shared note.");
@ -52,7 +62,7 @@ export default function SharedNotePage() {
<div <div
className="input-shell" className="input-shell"
style={{ marginTop: 16, minHeight: 200, lineHeight: 1.7 }} style={{ marginTop: 16, minHeight: 200, lineHeight: 1.7 }}
dangerouslySetInnerHTML={{ __html: note.body }} dangerouslySetInnerHTML={{ __html: sanitizeSharedNoteHtml(note.body) }}
/> />
</> </>
) : null} ) : null}

View File

@ -0,0 +1,12 @@
/**
* Conservative sanitizer for untrusted note HTML on public share views.
* Strips common XSS vectors; not a full HTML parser.
*/
export function sanitizeSharedNoteHtml(html: string): string {
let s = html;
s = s.replace(/<(?:script|style|iframe|object|embed)\b[^>]*>[\s\S]*?<\/(?:script|style|iframe|object|embed)>/gi, "");
s = s.replace(/<(?:script|style|iframe|object|embed)\b[^>]*\/?>/gi, "");
s = s.replace(/\son\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
s = s.replace(/\shref\s*=\s*(?:"\s*javascript:[^"]*"|'\s*javascript:[^']*'|javascript:[^\s>]+)/gi, ' href="#"');
return s;
}