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:
@@ -4,6 +4,11 @@ import { bandcampProvider } from "./providers/bandcamp.ts";
|
||||
import { soundcloudProvider } from "./providers/soundcloud.ts";
|
||||
import { selfProvider } from "./providers/self.ts";
|
||||
import { genericProvider } from "./providers/generic.ts";
|
||||
import {
|
||||
isPrivateHostname,
|
||||
resolvePublicAddress,
|
||||
} from "../lib/public-address.ts";
|
||||
import { isSelfUrl } from "../lib/self-url.ts";
|
||||
|
||||
export interface RichContentProvider {
|
||||
name: string;
|
||||
@@ -34,23 +39,35 @@ const FETCH_HEADERS = {
|
||||
"Accept-Language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||
};
|
||||
|
||||
const MAX_REDIRECTS = 5;
|
||||
|
||||
/**
|
||||
* Fetch `url` via a `curl --insecure` subprocess and return a minimal Response.
|
||||
* Used as a last resort when Deno's rustls rejects the server's TLS certificate
|
||||
* due to an unsupported algorithm (e.g. UnsupportedSignatureAlgorithm).
|
||||
* Returns null if curl is unavailable or exits non-zero.
|
||||
*
|
||||
* `address` pins the connection to the IP already checked by
|
||||
* resolvePublicAddress, and curl doesn't follow redirects itself: a 3xx comes
|
||||
* back with its Location so fetchWithTimeout can check the next hop too.
|
||||
*/
|
||||
async function fetchViaCurl(
|
||||
url: string,
|
||||
url: URL,
|
||||
address: string,
|
||||
timeoutMs: number,
|
||||
): Promise<Response | null> {
|
||||
const tmpPath = await Deno.makeTempFile();
|
||||
const port = url.port || (url.protocol === "https:" ? "443" : "80");
|
||||
const pinned = address.includes(":") ? `[${address}]` : address;
|
||||
try {
|
||||
const { code, stdout } = await new Deno.Command("curl", {
|
||||
args: [
|
||||
"--silent",
|
||||
"--insecure",
|
||||
"--location",
|
||||
"--proto",
|
||||
"=http,https",
|
||||
"--resolve",
|
||||
`${url.hostname.replace(/^\[(.*)\]$/, "$1")}:${port}:${pinned}`,
|
||||
"--max-time",
|
||||
String(Math.ceil(timeoutMs / 1000)),
|
||||
"--user-agent",
|
||||
@@ -62,8 +79,8 @@ async function fetchViaCurl(
|
||||
"--output",
|
||||
tmpPath,
|
||||
"--write-out",
|
||||
"%{content_type}",
|
||||
url,
|
||||
"%{http_code}\n%{redirect_url}\n%{content_type}",
|
||||
url.href,
|
||||
],
|
||||
stdout: "piped",
|
||||
stderr: "null",
|
||||
@@ -71,8 +88,18 @@ async function fetchViaCurl(
|
||||
|
||||
if (code !== 0) return null;
|
||||
|
||||
const contentType = new TextDecoder().decode(stdout).trim();
|
||||
const [status, location, contentType = ""] = new TextDecoder()
|
||||
.decode(stdout).split("\n").map((l) => l.trim());
|
||||
const statusCode = Number(status);
|
||||
if (!(statusCode >= 200 && statusCode <= 599)) return null;
|
||||
const headers = new Headers();
|
||||
if (location) headers.set("location", location);
|
||||
if (contentType) headers.set("content-type", contentType);
|
||||
|
||||
const bytes = await Deno.readFile(tmpPath);
|
||||
if (!contentType.startsWith("text/")) {
|
||||
return new Response(bytes, { status: statusCode, headers });
|
||||
}
|
||||
// Decode using the charset declared in the Content-Type header so that
|
||||
// pages served in ISO-8859-1, windows-1252, etc. are read correctly.
|
||||
const charset = /charset=([\w-]+)/i.exec(contentType)?.[1] ?? "utf-8";
|
||||
@@ -82,7 +109,8 @@ async function fetchViaCurl(
|
||||
} catch {
|
||||
bodyText = new TextDecoder("utf-8", { fatal: false }).decode(bytes);
|
||||
}
|
||||
return new Response(bodyText, { headers: { "content-type": "text/html" } });
|
||||
headers.set("content-type", contentType.replace(/;.*$/, "; charset=utf-8"));
|
||||
return new Response(bodyText, { status: statusCode, headers });
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
@@ -90,25 +118,20 @@ async function fetchViaCurl(
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchWithTimeout(
|
||||
url: string,
|
||||
timeoutMs = 5000,
|
||||
/** One request, no redirects followed. */
|
||||
async function fetchOnce(
|
||||
url: URL,
|
||||
address: string,
|
||||
timeoutMs: number,
|
||||
): Promise<Response> {
|
||||
async function attempt(): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: FETCH_HEADERS,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await attempt();
|
||||
return await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: FETCH_HEADERS,
|
||||
redirect: "manual",
|
||||
});
|
||||
} catch (err) {
|
||||
if (!(err instanceof TypeError && err.message.includes("certificate"))) {
|
||||
throw err;
|
||||
@@ -118,10 +141,37 @@ export async function fetchWithTimeout(
|
||||
// or an unsupported signature algorithm) and offers no way to override
|
||||
// verification programmatically. Fall back to a `curl --insecure`
|
||||
// subprocess, which uses its own TLS stack.
|
||||
const curlRes = await fetchViaCurl(url, timeoutMs);
|
||||
const curlRes = await fetchViaCurl(url, address, timeoutMs);
|
||||
if (curlRes) return curlRes;
|
||||
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a user-supplied URL. Every hop — the URL itself and each redirect —
|
||||
* must resolve to a public address (see resolvePublicAddress), so a link or a
|
||||
* redirect can't point the server at its own network.
|
||||
*/
|
||||
export async function fetchWithTimeout(
|
||||
url: string,
|
||||
timeoutMs = 5000,
|
||||
): Promise<Response> {
|
||||
let current = new URL(url);
|
||||
for (let hop = 0;; hop++) {
|
||||
if (current.protocol !== "http:" && current.protocol !== "https:") {
|
||||
throw new TypeError(`Unsupported protocol ${current.protocol}`);
|
||||
}
|
||||
const address = await resolvePublicAddress(current);
|
||||
const res = await fetchOnce(current, address, timeoutMs);
|
||||
const location = res.headers.get("location");
|
||||
if (res.status < 300 || res.status >= 400 || !location) return res;
|
||||
|
||||
await res.body?.cancel();
|
||||
if (hop >= MAX_REDIRECTS) throw new TypeError("Too many redirects");
|
||||
current = new URL(location, current);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -529,24 +579,14 @@ export function extractLinkHref(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isPrivateHost(hostname: string): boolean {
|
||||
// Block loopback and RFC-1918 ranges. Note: DNS rebinding is not fully mitigated.
|
||||
if (hostname === "localhost" || hostname === "::1") return true;
|
||||
return /^(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/.test(hostname);
|
||||
}
|
||||
|
||||
const SELF_PATH_RE = /^\/(dumps|users|playlists)\/[^/]+$/;
|
||||
|
||||
export function isValidHttpUrl(raw: string): boolean {
|
||||
try {
|
||||
const u = new URL(raw);
|
||||
if (u.protocol !== "http:" && u.protocol !== "https:") return false;
|
||||
// Allow private hosts for self-referential gerbeur URLs — they are
|
||||
// resolved directly from the DB by selfProvider, no outbound HTTP needed.
|
||||
if (isPrivateHost(u.hostname) && !SELF_PATH_RE.test(u.pathname)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
// This site's own pages are fine even on a private host (e.g. localhost in
|
||||
// development): selfProvider reads them from the DB, no HTTP involved.
|
||||
// Every actual fetch is checked again, after DNS, in fetchWithTimeout.
|
||||
return !isPrivateHostname(u) || isSelfUrl(u);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user