Files
pastilhas f7c9a28c33 a login through the proxy lost its csrf cookie, and looped
`stripHopByHop` copies headers with `out.set(key, value)`, which overwrites.
Correct for every header except the one that legitimately repeats: a NextAuth
sign-in sends several Set-Cookie - csrf-token, callback-url, session-token - and
collapsing them to one drops the rest.

Measured: two Set-Cookie headers direct from Linkwarden, one through the door,
and the survivor was callback-url. So the csrf cookie never reached the browser,
the sign-in posted, failed its CSRF check and bounced back to the form. A
redirect loop with nothing in any log saying why - and the owner predicted this
exact symptom from the other side before it was found.

`getSetCookie()` returns them unjoined, which is the reason to use it rather than
splitting a combined value: `Expires` contains a comma, so naive splitting
corrupts the date and the cookie is discarded.

Both hops use this one function, so both are fixed by it.
2026-09-05 21:23:48 +01:00

132 lines
5.6 KiB
TypeScript

import { getLinkwardenConfig } from '../db/queries';
// Where Linkwarden is, for the officer-linkwarden sidecar, plus header hygiene.
//
// ── The URL comes from the database, and can change while this is running ──
//
// `db/schema.ts` says why it is not an environment variable. The consequence here is the interesting
// part: a value that would have been constant for the process lifetime is now something the owner can
// change from a settings panel without restarting anything.
//
// ── Why a cache with a TTL rather than a read per request ──
//
// This sits in front of every proxied byte. A Postgres round trip per request would put the database on
// the hot path of a proxy whose whole design rule is to do as little as possible. Ten seconds is chosen
// against what the value actually is — a URL edited by hand, roughly never, and then wanted immediately.
// Imperceptible to somebody who just pressed Save, and free the rest of the time.
//
// The TTL is the WHOLE mechanism. There is no push: `sidecar-registry.ts` carries events from a sidecar
// to the platform, not commands the other way, so the config route has nothing to call. Building that
// channel for a cache hint would be a channel built for the wrong reason.
//
// A failed read keeps serving the last known-good value rather than dropping to null, because "cannot
// reach Postgres" and "the owner never configured this" look identical on screen and are not the same.
const TTL_MS = 10_000;
type Cached = { url: string | null; enabled: boolean; at: number };
let cache: Cached | null = null;
let inflight: Promise<Cached> | null = null;
async function read(): Promise<Cached> {
try {
const config = await getLinkwardenConfig();
cache = { url: config.url, enabled: config.enabled, at: Date.now() };
return cache;
} catch (err) {
console.error(`[linkwarden] could not read config: ${err instanceof Error ? err.message : String(err)}`);
if (cache) return { ...cache, at: Date.now() };
return { url: null, enabled: true, at: Date.now() };
}
}
async function current(): Promise<Cached> {
if (cache && Date.now() - cache.at < TTL_MS) return cache;
// One read at a time. Without this, every request under load misses the same expired entry and fires
// its own query — the stampede that makes a cache slower than no cache.
inflight ??= read().finally(() => {
inflight = null;
});
return inflight;
}
/** The base URL to forward to, or null when unconfigured or switched off. No trailing slash. */
export async function getLinkwardenBase(): Promise<string | null> {
const { url, enabled } = await current();
return enabled ? url : null;
}
const HOP_BY_HOP = new Set([
'connection',
'keep-alive',
'proxy-authenticate',
'proxy-authorization',
'te',
'trailer',
'transfer-encoding',
'upgrade',
'host',
]);
/** Copy headers, dropping only hop-by-hop and `proxy-*` ones. Authorization and cookies pass through. */
export function stripHopByHop(src: Headers): Headers {
const out = new Headers();
src.forEach((value, key) => {
const k = key.toLowerCase();
if (HOP_BY_HOP.has(k) || k.startsWith('proxy-')) return;
out.set(key, value);
});
return out;
}
/**
* Headers for a response we are handing back, which is NOT the same as headers for a request.
*
* ── `content-encoding` has to go, and finding out why cost a 502 ──
*
* `fetch` decompresses a gzipped response body before anyone sees it. The headers it returns still say
* `content-encoding: gzip`, so copying them onto the decoded bytes tells the client to gunzip plain
* JSON — `Decompression error: ZlibError`, surfacing as a 502 from the catch below.
*
* It hides well. A 401 body is a few bytes and is not compressed, so every unauthenticated probe passes
* and the first REAL request is the one that fails. Measured exactly that way on alpha: the door
* answered 401 correctly without a token and 502 with one.
*
* `content-length` goes with it for the same reason — it describes the compressed length, and the body
* being handed on is not that length.
*/
export function stripResponseHeaders(src: Headers): Headers {
const out = stripHopByHop(src);
out.delete('content-encoding');
out.delete('content-length');
/*
── `Set-Cookie` is the one header that legitimately repeats, and `.set()` keeps only the last ──
`stripHopByHop` copies with `out.set(key, value)`, which OVERWRITES. Everything else is fine that
way; Set-Cookie is not. A NextAuth sign-in sends several — csrf-token, callback-url, session-token —
and collapsing them to one silently drops the rest.
Measured on this install: two headers direct from Linkwarden, one through the proxy, and the one
that survived was `callback-url`. The csrf cookie never reached the browser, which is a sign-in that
posts, fails its CSRF check, and bounces back to the form — the redirect loop, with nothing in any
log to say why.
`getSetCookie()` is the standard accessor for exactly this case: it returns the values unjoined,
which matters because a cookie's `Expires` contains a comma and naive comma-splitting corrupts it.
*/
out.delete('set-cookie');
for (const cookie of src.getSetCookie()) out.append('set-cookie', cookie);
return out;
}
/*
Paths are logged; tokens are not.
Linkwarden puts an access token in a query string in more than one place, and a NextAuth callback
carries a csrf token the same way. None of it belongs in a log that gets pasted into a chat.
*/
const REDACT = /([?&](access_token|token|csrfToken|code)=)[^&]*/gi;
export const redactPath = (pathAndQuery: string): string => pathAndQuery.replace(REDACT, '$1<redacted>');