v3: the API stops trusting what it's handed — a review of api/ turned up a server anyone could crash, uploads that ran script on the app's origin, and a server that would fetch its own network on request, and this closes them along with the smaller holes around them. A WebSocket message that wasn't an object (a bare null will do) threw inside the listener, which Deno treats as uncaught and exits on; messages are now shape-checked and the handler can't throw out. Uploaded files kept the MIME type the uploader's browser claimed, so an .html or .svg dump opened from /api/files ran with the session token in reach; only images, audio, video, PDF and plain text are served inline now, the rest download, and every file carries nosniff and a sandboxing CSP (PDFs aside, which Chrome won't render sandboxed). The image proxy passed image/svg+xml straight through the same way and now takes raster formats only. Outbound fetches for previews, the proxy and Bandcamp checked the hostname against a partial list — no 169.254.169.254, no 0.0.0.0, an IPv6 check that never matched a bracketed hostname, nothing for names that resolve inward, and redirects followed blindly; every hop now resolves and is refused if any address is private or reserved, redirects are walked by hand, the curl fallback is pinned to the checked IP, and /api/preview needs a login. PATCH /users/me accepted a new password without the current one and now takes only the description and email; a wrong password at login answered 500 because the handler caught its own 401, and now says 401. Changing or resetting a password now ends every older session through users.tokens_valid_after (migration 0015) and an iat on every token, with change-password handing back fresh tokens the modal keeps, and all token checks go through one authenticate() that also refuses deleted users. Invites and reset links are claimed atomically before the async hash rather than marked after, so one invite no longer opens several accounts, and an email address belongs to one account. File and thumbnail URLs carry a media-only token rather than the session token, so a URL in a log exposes files and not the account; older sessions fetch one on load. Private things stop leaking into broadcasts: private playlist updates go to their owner, votes and likes on someone else's private dump 404 and their counts go to the owner alone, and a private dump can't be slipped into a playlist. Smaller: selfProvider only claims this site's own host, so github.com/users/bob stops rendering our bob; the chat read marker can't be parked in the future on an empty chat; dump mutations use the resolved id so a slug works; multipart dumps get the title and comment limits; logins and reset requests are rate-limited through a shared token bucket; the password compare is constant-time; ffmpeg reads only local files in known video containers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-24 20:07:33 +00:00
parent 3a09d0e354
commit 2ad8ae73d5
38 changed files with 1032 additions and 352 deletions

View File

@@ -3,6 +3,7 @@ import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { API_URL, VALIDATION } from "../config/api.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { deserializeAuthResponse } from "../model.ts";
import { Modal } from "./Modal.tsx";
import {
expectOk,
@@ -25,7 +26,7 @@ interface Values {
}
export function ChangePasswordModal({ onClose }: ChangePasswordModalProps) {
const { authFetch } = useAuth();
const { authFetch, login } = useAuth();
const [done, setDone] = useState(false);
const form = useApiForm<Values>({
mode: "onChange",
@@ -33,13 +34,19 @@ export function ChangePasswordModal({ onClose }: ChangePasswordModalProps) {
});
const onSubmit = form.submit(async ({ currentPassword, newPassword }) => {
await expectOk(
// The change signs out every session opened with the old password, this
// one included, so keep the fresh tokens it hands back.
const tokens = await expectOk<{ token: string; mediaToken: string }>(
await authFetch(`${API_URL}/api/users/me/change-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ currentPassword, newPassword }),
}),
);
const storedRaw = localStorage.getItem("authResponse");
if (storedRaw) {
login({ ...deserializeAuthResponse(JSON.parse(storedRaw)), ...tokens });
}
setDone(true);
});

View File

@@ -30,7 +30,7 @@ export function DumpCard(
DumpCardProps,
) {
const { cardProps, handledAsNewTab } = useDumpCardLink(dump);
const { token } = useAuth();
const { mediaToken } = useAuth();
const { byId } = useCategories();
const dumpCategories = (dump.categoryIds ?? [])
.map((id) => byId.get(id))
@@ -59,7 +59,7 @@ export function DumpCard(
compact
dumpHref={dumpUrl(dump)}
thumbnailOverrideUrl={dump.thumbnailMime
? dumpThumbnailUrl(dump, token)
? dumpThumbnailUrl(dump, mediaToken)
: undefined}
/>
)

View File

@@ -389,7 +389,7 @@ export function DumpCreateModal(
debounceRef.current = setTimeout(() => {
const encoded = encodeURIComponent(canonicalUrl);
fetch(`${API_URL}/api/preview?url=${encoded}`)
authFetch(`${API_URL}/api/preview?url=${encoded}`)
.then((res) => res.json())
.then((body) => {
const data = body.success

View File

@@ -52,7 +52,7 @@ function filePlayerItem(
function AudioFilePreview(
{ fileUrl, mime, dump }: { fileUrl: string; mime: string; dump: Dump },
) {
const { token } = useAuth();
const { mediaToken: token } = useAuth();
const { current, playing, currentTime, duration, play, togglePlay, seekTo } =
useContext(PlayerContext);
const [peaks, setPeaks] = useState<Float32Array | null>(null);
@@ -174,7 +174,7 @@ export default function FilePreview(
{ dump, compact = false, global: useGlobal = false }: FilePreviewProps,
) {
const { current, playing, play, togglePlay } = useContext(PlayerContext);
const { token } = useAuth();
const { mediaToken: token } = useAuth();
const fileUrl = dumpFileUrl(dump, token);
const mime = dump.fileMime ?? "";
const isPlaying = current?.kind === "file" && current.fileUrl === fileUrl;

View File

@@ -34,7 +34,7 @@ export function JournalCard(
JournalCardProps,
) {
const { go, cardProps, handledAsNewTab } = useDumpCardLink(dump);
const { token } = useAuth();
const { mediaToken } = useAuth();
const { playRichContent } = usePlayRichContent();
const unread = !isOwner && isRecent(dump.createdAt) &&
!isDumpVisited(dump.id);
@@ -49,12 +49,12 @@ export function JournalCard(
// Mirrors FilePreview (the hot/new feeds) so a video shows its generated
// still here too, rather than degrading to a text card with a 🎬.
const thumbnailUrl = dump.thumbnailMime
? dumpThumbnailUrl(dump, token)
? dumpThumbnailUrl(dump, mediaToken)
: dump.kind === "file"
? (dump.fileMime?.startsWith("image/")
? dumpFileUrl(dump, token)
? dumpFileUrl(dump, mediaToken)
: dump.fileMime?.startsWith("video/")
? dumpThumbnailUrl(dump, token)
? dumpThumbnailUrl(dump, mediaToken)
: null)
: (dump.richContent?.thumbnailUrl ?? null);

View File

@@ -99,6 +99,33 @@ export function AuthProvider({ children }: { children: ReactNode }) {
};
}, [token]);
// Sessions from before media tokens existed have none, and would keep putting
// the full session token in file and thumbnail URLs; fetch one once.
const needsMediaToken = !!token && !authResponse?.mediaToken;
useEffect(() => {
if (!token || !needsMediaToken) return;
let cancelled = false;
fetch(`${API_URL}/api/users/me/media-token`, {
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
})
.then((res) => res.ok ? res.json() : null)
.then((json) => {
const mediaToken = json?.data?.mediaToken;
if (cancelled || typeof mediaToken !== "string") return;
setAuthResponse((prev) => {
if (!prev || prev.token !== token) return prev;
const updated = { ...prev, mediaToken };
localStorage.setItem("authResponse", JSON.stringify(updated));
return updated;
});
})
.catch(() => {/* keep using the session token until next load */});
return () => {
cancelled = true;
};
}, [token, needsMediaToken]);
const value: AuthContextValue = { authResponse, setAuthResponse };
return (

View File

@@ -116,6 +116,10 @@ export const useAuth = () => {
return {
user: authResponse?.user ?? null,
token: authResponse?.token ?? null,
// For file and thumbnail URLs, where the token is visible in the query
// string. Sessions from before media tokens fall back to the session token
// until AuthProvider fetches one.
mediaToken: authResponse?.mediaToken ?? authResponse?.token ?? null,
login,
logout,
authFetch,
@@ -124,12 +128,13 @@ export const useAuth = () => {
};
export const useRequiredAuth = () => {
const { user, token, login, logout, authFetch, authUpload } = useAuth();
const { user, token, mediaToken, login, logout, authFetch, authUpload } =
useAuth();
if (!user) {
throw new Error(
"Invariant: useRequiredAuth called outside a protected route",
);
}
return { user, token, login, logout, authFetch, authUpload };
return { user, token, mediaToken, login, logout, authFetch, authUpload };
};

View File

@@ -208,6 +208,8 @@ export function deserializeInviteTreeEntry(
export interface AuthResponse {
token: string;
/** Read-only token for media URLs (`?token=`); absent on older sessions. */
mediaToken?: string;
user: User;
}

View File

@@ -80,7 +80,7 @@ export function Dump() {
const [titleSaving, setTitleSaving] = useState(false);
const [titleError, setTitleError] = useState<string | null>(null);
const { user, token, authFetch } = useAuth();
const { user, token, mediaToken, authFetch } = useAuth();
const { byId } = useCategories();
const {
voteCounts,
@@ -497,7 +497,7 @@ export function Dump() {
richContent={dump.richContent}
dumpHref={dumpUrl(dump)}
thumbnailOverrideUrl={dump.thumbnailMime
? dumpThumbnailUrl(dump, token)
? dumpThumbnailUrl(dump, mediaToken)
: undefined}
/>
)

View File

@@ -39,7 +39,7 @@ type DumpEditState =
export function DumpEdit() {
const { selectedDump } = useParams();
const navigate = useNavigate();
const { authFetch, token } = useRequiredAuth();
const { authFetch, token, mediaToken } = useRequiredAuth();
const [state, setState] = useState<DumpEditState>({ status: "loading" });
@@ -195,12 +195,12 @@ export function DumpEdit() {
const { dump } = state;
const currentThumbnailSrc = dump.thumbnailMime
? dumpThumbnailUrl(dump, token)
? dumpThumbnailUrl(dump, mediaToken)
: dump.kind === "file"
? (dump.fileMime?.startsWith("image/")
? dumpFileUrl(dump, token)
? dumpFileUrl(dump, mediaToken)
: dump.fileMime?.startsWith("video/")
? dumpThumbnailUrl(dump, token)
? dumpThumbnailUrl(dump, mediaToken)
: null)
: dump.richContent?.thumbnailUrl ?? null;