Subscribe to a calendar published at a URL
A timetable, a rota, a public holiday list: the calendars people are given as a link, which ihasmail could not show at all. Nothing is stored. The document is fetched when the calendar is opened and parsed in the browser; the server keeps no copy, no cache and no schedule, which is what lets an immutable container serve this. There is no timer either -- there is nowhere to run one -- so the guarantee is that a subscription is as current as the last time somebody looked, which is also when it matters. That is said plainly rather than implied. The fetch has to happen on the server: a calendar URL belongs to whoever published it and almost none of them send CORS headers. That makes it the second place this app knocks on a door somebody else chose, so the guard the image proxy has always had was lifted out and both now call it. A second SSRF implementation is how one of them ends up missing a case; this way there is one, and the extraction is covered by the image proxy's own tests still passing unchanged. webcal: is understood, because that is how these are published, and it is read as https: rather than waved past the checks -- a webcal URL pointing at loopback is refused exactly like an http one. Recurrence is deliberately not expanded. RRULE is a small language with a lot of edge cases, and a subscription quietly showing the wrong dates would be worse than one showing the first occurrence and saying so. The parser is a subscription parser rather than an importer: a subscribed calendar is read-only and redrawn from scratch each refresh, so nothing has to round-trip or survive an edit, which is most of what makes a full iCalendar implementation large. What it does have to do is never mis-state a time -- a DATE is built in local time rather than at UTC midnight, which would land on the day before for anyone west of Greenwich -- and never hang on a document somebody else wrote. Events go through instancesIn like the birthdays, so no view has to know they are not real calendars, and the calendar they hang off reports no write rights, so everything that asks before offering an edit declines on its own. A subscription that cannot be read says so in the sidebar rather than drawing an empty calendar, which looks like a calendar with nothing in it.
This commit is contained in:
@@ -30,6 +30,7 @@ import {
|
||||
revokeAppPassword,
|
||||
} from "./account.js";
|
||||
import { imageProxyHandler } from "./imageproxy.js";
|
||||
import { icsProxyHandler } from "./icsproxy.js";
|
||||
import { staticHandler } from "./static.js";
|
||||
|
||||
type Env = { Variables: { session: LiveSession } };
|
||||
@@ -613,6 +614,9 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
|
||||
// ---------- Remote image privacy proxy ----------
|
||||
api.get("/image", requireSession, imageProxyHandler);
|
||||
// Behind the session for the same reason the image proxy is: an open fetcher
|
||||
// on someone else's server is a gift to whoever finds it.
|
||||
api.get("/ics", requireSession, icsProxyHandler);
|
||||
|
||||
api.notFound((c) => c.json({ error: "not_found" }, 404));
|
||||
api.onError((err, c) => {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
process.env.STALWART_URL = "http://127.0.0.1:1";
|
||||
process.env.APP_SECRET = "test-secret-for-ics-proxy";
|
||||
|
||||
const { safeFetch, safeFetchStatus } = await import("./imageproxy.js");
|
||||
|
||||
/**
|
||||
* Subscribing to a calendar makes the server fetch a URL a stranger published,
|
||||
* which is the second time this app knocks on a door somebody else chose. It
|
||||
* goes through the same guard as the first — these tests are about that guard
|
||||
* being reached, and about `webcal:` not being a way around it.
|
||||
*/
|
||||
|
||||
test("a calendar URL is refused before any connection when it points somewhere private", async () => {
|
||||
for (const url of [
|
||||
"http://127.0.0.1/calendar.ics",
|
||||
"http://169.254.169.254/latest/meta-data/", // cloud metadata
|
||||
"http://[::1]/calendar.ics",
|
||||
"http://10.0.0.1/c.ics",
|
||||
"https://192.168.1.1/c.ics",
|
||||
]) {
|
||||
const got = await safeFetch(url, 500);
|
||||
assert.equal(got, "forbidden_target", url);
|
||||
}
|
||||
});
|
||||
|
||||
test("webcal: is treated as https rather than waved through", async () => {
|
||||
// Every subscription URL people are given is a webcal: one. It has to be
|
||||
// understood, and it must not be a way past the address check.
|
||||
const got = await safeFetch("webcal://127.0.0.1/calendar.ics", 500);
|
||||
assert.equal(got, "forbidden_target");
|
||||
});
|
||||
|
||||
test("schemes that are not http, https or webcal are refused", async () => {
|
||||
for (const url of ["file:///etc/passwd", "ftp://example.com/c.ics", "gopher://example.com", "data:text/calendar,BEGIN:VCALENDAR"]) {
|
||||
const got = await safeFetch(url, 500);
|
||||
assert.equal(got, "bad_scheme", url);
|
||||
}
|
||||
});
|
||||
|
||||
test("a URL carrying credentials is refused", async () => {
|
||||
// Credentials in a subscription URL would be sent by the server on the
|
||||
// reader's behalf to a host the reader may not have looked at.
|
||||
assert.equal(await safeFetch("http://user:[email protected]/c.ics", 500), "bad_url");
|
||||
});
|
||||
|
||||
test("nonsense is refused rather than guessed at", async () => {
|
||||
for (const url of ["", "not a url", "://missing-scheme"]) {
|
||||
assert.equal(await safeFetch(url, 500), "bad_url", JSON.stringify(url));
|
||||
}
|
||||
});
|
||||
|
||||
test("each refusal has a status that says which kind it was", () => {
|
||||
assert.equal(safeFetchStatus("forbidden_target"), 403);
|
||||
assert.equal(safeFetchStatus("bad_scheme"), 400);
|
||||
assert.equal(safeFetchStatus("bad_url"), 400);
|
||||
assert.equal(safeFetchStatus("bad_redirect"), 400);
|
||||
assert.equal(safeFetchStatus("dns_failure"), 502);
|
||||
assert.equal(safeFetchStatus("fetch_failed"), 502);
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { Context } from "hono";
|
||||
import { safeFetch, safeFetchStatus } from "./imageproxy.js";
|
||||
|
||||
/**
|
||||
* Fetching a calendar somebody has subscribed to.
|
||||
*
|
||||
* The browser cannot do this itself: a calendar URL belongs to whoever
|
||||
* published it and almost none of them send CORS headers, so the request has
|
||||
* to be made from here. That makes it the second place ihasmail reaches out to
|
||||
* an address a stranger chose, and it goes through exactly the same guard as
|
||||
* the first — `safeFetch` resolves the name, refuses private space on every
|
||||
* answer, pins the connection to the address it checked, and re-checks each
|
||||
* redirect. There is deliberately no second implementation of that.
|
||||
*
|
||||
* **Nothing is stored.** The text goes straight back to the browser, which
|
||||
* parses it and holds the result in memory for as long as the tab is open. The
|
||||
* server keeps no copy, no cache and no schedule, which is what lets an
|
||||
* immutable container serve this at all.
|
||||
*/
|
||||
|
||||
/** Generous for a calendar, small enough that nobody can post a film through it. */
|
||||
const MAX_ICS_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Types a calendar is served as in practice. `text/plain` and the octet-stream
|
||||
* are here because a great many servers get this wrong, and refusing a real
|
||||
* calendar over a header the publisher chose badly helps nobody -- the parser
|
||||
* checks the content itself, which is the claim that actually matters.
|
||||
*/
|
||||
const ACCEPTABLE = new Set(["text/calendar", "text/plain", "application/octet-stream", "application/ics", ""]);
|
||||
|
||||
export async function icsProxyHandler(c: Context) {
|
||||
const got = await safeFetch(c.req.query("url") ?? "", 20_000);
|
||||
if (typeof got === "string") return c.json({ error: got }, safeFetchStatus(got) as 400);
|
||||
const { res, done } = got;
|
||||
|
||||
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
|
||||
done();
|
||||
res.resume();
|
||||
return c.json({ error: "fetch_failed", status: res.statusCode ?? 0 }, 502);
|
||||
}
|
||||
const type = (res.headers["content-type"] ?? "").split(";")[0]!.trim().toLowerCase();
|
||||
if (!ACCEPTABLE.has(type)) {
|
||||
done();
|
||||
res.resume();
|
||||
return c.json({ error: "not_calendar", type }, 415);
|
||||
}
|
||||
const declared = Number(res.headers["content-length"] ?? "0");
|
||||
if (declared > MAX_ICS_BYTES) {
|
||||
done();
|
||||
res.resume();
|
||||
return c.json({ error: "too_large" }, 413);
|
||||
}
|
||||
|
||||
// Read it here rather than streaming: the browser needs the whole document
|
||||
// to parse it, and the cap has to hold whether or not a length was declared.
|
||||
let total = 0;
|
||||
const chunks: Buffer[] = [];
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
res.on("data", (chunk: Buffer) => {
|
||||
total += chunk.byteLength;
|
||||
if (total > MAX_ICS_BYTES) {
|
||||
res.destroy();
|
||||
reject(new Error("too_large"));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
res.on("end", () => resolve());
|
||||
res.on("error", reject);
|
||||
});
|
||||
} catch (err) {
|
||||
done();
|
||||
return c.json({ error: (err as Error).message === "too_large" ? "too_large" : "fetch_failed" }, 502);
|
||||
}
|
||||
done();
|
||||
|
||||
return c.body(Buffer.concat(chunks).toString("utf8"), 200, {
|
||||
"Content-Type": "text/calendar; charset=utf-8",
|
||||
// Never stored on disk, and never held by anything in between either.
|
||||
"Cache-Control": "no-store",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
});
|
||||
}
|
||||
+60
-22
@@ -98,32 +98,50 @@ export function fetchPinned(url: URL, addr: string, signal?: AbortSignal): Promi
|
||||
* Gmail-style remote content proxy: hides the reader's IP address and
|
||||
* user-agent from tracking pixels, and blocks SSRF to internal networks.
|
||||
*/
|
||||
export async function imageProxyHandler(c: Context) {
|
||||
if (!config.imageProxy) return c.json({ error: "disabled" }, 404);
|
||||
const raw = c.req.query("url") ?? "";
|
||||
/** Why a guarded fetch refused, in the words the handlers answer with. */
|
||||
export type SafeFetchError = "bad_url" | "bad_scheme" | "forbidden_target" | "dns_failure" | "fetch_failed" | "bad_redirect";
|
||||
|
||||
export interface SafeFetchResult {
|
||||
res: IncomingMessage;
|
||||
/** The URL actually fetched, which is not the one asked for if it redirected. */
|
||||
url: URL;
|
||||
done: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a URL nobody here chose, with every check the image proxy has always
|
||||
* made — and made in one place, because a second copy of an SSRF guard is how
|
||||
* one of them ends up missing a case.
|
||||
*
|
||||
* The name is resolved first and *every* answer has to be acceptable, the
|
||||
* connection is pinned to the address that was checked, and each redirect hop
|
||||
* is re-resolved and re-pinned rather than handed to the socket library.
|
||||
*/
|
||||
export async function safeFetch(raw: string, timeoutMs = 15_000): Promise<SafeFetchResult | SafeFetchError> {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
return c.json({ error: "bad_url" }, 400);
|
||||
return "bad_url";
|
||||
}
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return c.json({ error: "bad_scheme" }, 400);
|
||||
if (url.username || url.password) return c.json({ error: "bad_url" }, 400);
|
||||
// webcal: is an http URL wearing a different word; nothing else is allowed.
|
||||
if (url.protocol === "webcal:") url = new URL(`https:${raw.slice(raw.indexOf(":") + 1)}`);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return "bad_scheme";
|
||||
if (url.username || url.password) return "bad_url";
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 15_000);
|
||||
let res: IncomingMessage;
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const done = () => clearTimeout(timer);
|
||||
try {
|
||||
let addr: string;
|
||||
try {
|
||||
addr = await resolveAllowed(url.hostname);
|
||||
} catch (err) {
|
||||
clearTimeout(timer);
|
||||
return err instanceof BlockedTarget ? c.json({ error: "forbidden_target" }, 403) : c.json({ error: "dns_failure" }, 502);
|
||||
done();
|
||||
return err instanceof BlockedTarget ? "forbidden_target" : "dns_failure";
|
||||
}
|
||||
res = await fetchPinned(url, addr, controller.signal);
|
||||
let res = await fetchPinned(url, addr, controller.signal);
|
||||
|
||||
// Follow a limited number of redirects, re-checking and re-pinning each hop.
|
||||
let hops = 0;
|
||||
while (res.statusCode && [301, 302, 303, 307, 308].includes(res.statusCode) && hops < 3) {
|
||||
const loc = res.headers.location;
|
||||
@@ -131,38 +149,58 @@ export async function imageProxyHandler(c: Context) {
|
||||
res.resume(); // discard the redirect body
|
||||
const next = new URL(loc, url);
|
||||
if (next.protocol !== "http:" && next.protocol !== "https:") {
|
||||
clearTimeout(timer);
|
||||
return c.json({ error: "bad_redirect" }, 400);
|
||||
done();
|
||||
return "bad_redirect";
|
||||
}
|
||||
try {
|
||||
addr = await resolveAllowed(next.hostname);
|
||||
} catch (err) {
|
||||
clearTimeout(timer);
|
||||
return err instanceof BlockedTarget ? c.json({ error: "forbidden_target" }, 403) : c.json({ error: "dns_failure" }, 502);
|
||||
done();
|
||||
return err instanceof BlockedTarget ? "forbidden_target" : "dns_failure";
|
||||
}
|
||||
url = next;
|
||||
res = await fetchPinned(url, addr, controller.signal);
|
||||
hops++;
|
||||
}
|
||||
return { res, url, done };
|
||||
} catch {
|
||||
clearTimeout(timer);
|
||||
return c.json({ error: "fetch_failed" }, 502);
|
||||
done();
|
||||
return "fetch_failed";
|
||||
}
|
||||
}
|
||||
|
||||
const SAFE_FETCH_STATUS: Record<SafeFetchError, number> = {
|
||||
bad_url: 400,
|
||||
bad_scheme: 400,
|
||||
bad_redirect: 400,
|
||||
forbidden_target: 403,
|
||||
dns_failure: 502,
|
||||
fetch_failed: 502,
|
||||
};
|
||||
|
||||
export function safeFetchStatus(err: SafeFetchError): number {
|
||||
return SAFE_FETCH_STATUS[err];
|
||||
}
|
||||
|
||||
export async function imageProxyHandler(c: Context) {
|
||||
if (!config.imageProxy) return c.json({ error: "disabled" }, 404);
|
||||
const got = await safeFetch(c.req.query("url") ?? "");
|
||||
if (typeof got === "string") return c.json({ error: got }, safeFetchStatus(got) as 400);
|
||||
const { res, done } = got;
|
||||
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
|
||||
clearTimeout(timer);
|
||||
done();
|
||||
res.resume();
|
||||
return c.json({ error: "fetch_failed" }, 502);
|
||||
}
|
||||
const type = (res.headers["content-type"] ?? "").split(";")[0]!.trim().toLowerCase();
|
||||
if (!type.startsWith("image/") || type === "image/svg+xml") {
|
||||
clearTimeout(timer);
|
||||
done();
|
||||
res.resume();
|
||||
return c.json({ error: "not_image" }, 415);
|
||||
}
|
||||
const len = Number(res.headers["content-length"] ?? "0");
|
||||
if (len > MAX_IMAGE_BYTES) {
|
||||
clearTimeout(timer);
|
||||
done();
|
||||
res.resume();
|
||||
return c.json({ error: "too_large" }, 413);
|
||||
}
|
||||
@@ -176,7 +214,7 @@ export async function imageProxyHandler(c: Context) {
|
||||
else controller2.enqueue(chunk);
|
||||
},
|
||||
});
|
||||
res.on("close", () => clearTimeout(timer));
|
||||
res.on("close", done);
|
||||
const headers = new Headers({
|
||||
"Content-Type": type,
|
||||
"Cache-Control": "private, max-age=86400",
|
||||
|
||||
Reference in New Issue
Block a user