Files
linkwarden/packages/lib/safeFetch.ts
Marcin Rybak a2223bb61e fix: add default User-Agent header to safeFetch
safeFetch sends requests without a User-Agent header. Some servers
(e.g. Flipboard via CloudFront/WAF) reject such requests with HTTP 403,
which breaks RSS feed polling and potentially other server-side fetches.

Add a default User-Agent that callers can override via options.headers.
2026-04-03 09:29:53 +01:00

130 lines
3.3 KiB
TypeScript

import fetch, { RequestInit, Response } from "node-fetch";
import http from "http";
import https from "https";
import { HttpsProxyAgent } from "https-proxy-agent";
import { SocksProxyAgent } from "socks-proxy-agent";
import {
assertUrlIsSafeForServerSideFetch,
defaultHostnameLookup,
resolveHostnameForServerSideFetch,
} from "@linkwarden/lib/ssrf";
type SafeFetchOptions = Omit<RequestInit, "agent" | "redirect"> & {
maxRedirects?: number;
};
function createSafeLookup() {
const lookup: any = (
hostname: string,
options: number | { family?: number | string; all?: boolean },
callback: (
error: NodeJS.ErrnoException | null,
addressOrAddresses?:
| string
| ReadonlyArray<{ address: string; family: 4 | 6 }>,
family?: number
) => void
) => {
const normalizedOptions =
typeof options === "number" ? { family: options } : options ?? {};
const requestedFamily =
normalizedOptions.family === "IPv4"
? 4
: normalizedOptions.family === "IPv6"
? 6
: normalizedOptions.family;
void resolveHostnameForServerSideFetch(hostname, defaultHostnameLookup)
.then((resolved) => {
if (normalizedOptions.all) {
callback(null, resolved);
return;
}
const match =
resolved.find(
(entry) => !requestedFamily || entry.family === requestedFamily
) ?? resolved[0];
callback(null, match.address, match.family);
})
.catch((error) => {
callback(error as NodeJS.ErrnoException);
});
};
return lookup;
}
function createAgent(target: URL) {
if (process.env.PROXY) {
const proxy = new URL(process.env.PROXY);
if (process.env.PROXY_USERNAME) {
proxy.username = process.env.PROXY_USERNAME;
proxy.password = process.env.PROXY_PASSWORD || "";
}
const ProxyAgent = proxy.protocol.includes("http")
? HttpsProxyAgent
: SocksProxyAgent;
return new ProxyAgent(proxy.toString());
}
const lookup = createSafeLookup();
if (target.protocol === "http:") {
return new http.Agent({ lookup });
}
return new https.Agent({
lookup,
rejectUnauthorized:
process.env.ALLOW_INSECURE_TLS === "true" ||
process.env.IGNORE_UNAUTHORIZED_CA === "true"
? false
: true,
});
}
function isRedirectStatus(status: number) {
return status >= 300 && status < 400;
}
export async function safeFetch(
input: string,
options: SafeFetchOptions = {}
): Promise<Response> {
const { maxRedirects = 5, ...fetchOptions } = options;
let currentUrl = input;
for (let redirectCount = 0; redirectCount <= maxRedirects; redirectCount++) {
const validatedUrl = await assertUrlIsSafeForServerSideFetch(currentUrl);
const response = await fetch(validatedUrl.toString(), {
...fetchOptions,
headers: {
"User-Agent": "Linkwarden (Server-Side Fetch)",
...fetchOptions.headers,
},
agent: createAgent(validatedUrl),
redirect: "manual",
});
if (!isRedirectStatus(response.status)) {
return response;
}
const location = response.headers.get("location");
if (!location) {
return response;
}
currentUrl = new URL(location, validatedUrl).toString();
}
throw new Error("Fetch redirected too many times.");
}