`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.
132 lines
5.6 KiB
TypeScript
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>');
|