commit 9718a3bfddc58eacbcaf2af3aa8fc05c255004ce
parent 24470cd87c2cbb1c1c49c7500363e8f05d41e675
Author: Florian Dold <dold@taler.net>
Date: Sun, 13 Sep 2026 16:16:50 +0200
taler-util: enforce TLS throughout redirect chains
Follow redirects in the shared HTTP layer when TLS is required, checking
each destination before making another request. Preserve redirect method
and body semantics, remove credentials on origin changes, and apply one
deadline and cancellation token to the entire chain.
Diffstat:
3 files changed, 291 insertions(+), 1 deletion(-)
diff --git a/packages/taler-util/src/http-common.test.ts b/packages/taler-util/src/http-common.test.ts
@@ -28,6 +28,8 @@ import {
readUnexpectedResponseDetails,
} from "./http-common.js";
import { TalerErrorCode } from "./taler-error-codes.js";
+import { CancellationToken } from "./CancellationToken.js";
+import { qtartRedirectMode } from "./http-impl.qtart-common.js";
function jsonResponse(body: string, status = 400): HttpResponse {
const headers = new HeadersImpl();
@@ -157,3 +159,119 @@ test("HTTP cancellation maps to the public cancellation error", async () => {
e.errorDetail.code === TalerErrorCode.WALLET_CORE_REQUEST_CANCELLED,
);
});
+
+test("TLS redirect handling preserves methods and strips headers at boundaries", async () => {
+ for (const status of [301, 302, 303, 307, 308]) {
+ const seen: { url: string; opt: HttpRawRequestOptions }[] = [];
+ const http = new HttpLib(
+ {
+ async fetch(url, opt) {
+ seen.push({ url, opt: { ...opt, headers: { ...opt.headers } } });
+ assert.strictEqual(qtartRedirectMode(opt.redirect), 1);
+ const headers = new HeadersImpl();
+ if (seen.length === 1) headers.set("location", "/relative");
+ if (seen.length === 2)
+ headers.set("location", "https://other.example/done");
+ return {
+ status: seen.length < 3 ? status : 200,
+ headers,
+ bytes: async () => new Uint8Array(),
+ };
+ },
+ },
+ { requireTls: true, enableThrottling: false },
+ );
+ await http.fetch("https://example.com/start", {
+ method: "POST",
+ body: "payload",
+ headers: {
+ Authorization: "secret",
+ Cookie: "session",
+ "Proxy-Authorization": "proxy",
+ Host: "example.com",
+ },
+ });
+ const preservesBody = status === 307 || status === 308;
+ assert.deepStrictEqual(
+ seen.map((r) => r.url),
+ [
+ "https://example.com/start",
+ "https://example.com/relative",
+ "https://other.example/done",
+ ],
+ );
+ assert.strictEqual(seen[1].opt.headers.Authorization, "secret");
+ assert.strictEqual(seen[1].opt.headers.Host, undefined);
+ const last = seen[2].opt;
+ assert.strictEqual(last.method, preservesBody ? "POST" : "GET");
+ assert.strictEqual(last.body === undefined, !preservesBody);
+ for (const header of [
+ "Authorization",
+ "Cookie",
+ "Proxy-Authorization",
+ "Host",
+ ]) {
+ assert.strictEqual(last.headers[header], undefined);
+ }
+ if (!preservesBody)
+ assert.ok(!Object.keys(last.headers).some((k) => /^content-/i.test(k)));
+ }
+});
+
+test("TLS redirects reject downgrade and loops before another request", async () => {
+ for (const location of [
+ "http://example.com/insecure",
+ "ftp://example.com/file",
+ "https://user:password@other.example/",
+ "/loop",
+ ]) {
+ let calls = 0;
+ const http = new HttpLib(
+ {
+ async fetch() {
+ calls++;
+ const headers = new HeadersImpl();
+ headers.set("location", location);
+ return { status: 307, headers, bytes: async () => new Uint8Array() };
+ },
+ },
+ { requireTls: true, enableThrottling: false },
+ );
+ await assert.rejects(http.fetch("https://example.com/start"));
+ assert.strictEqual(calls, location === "/loop" ? 22 : 1);
+ }
+});
+
+test("TLS redirect chains share a deadline and observe cancellation between hops", async () => {
+ for (const cancel of [false, true]) {
+ const token = CancellationToken.create();
+ let calls = 0;
+ const http = new HttpLib(
+ {
+ async fetch(_url, opt) {
+ calls++;
+ assert.ok(opt.timeoutMs! <= 10);
+ if (cancel) token.cancel();
+ else await new Promise((resolve) => setTimeout(resolve, 20));
+ const headers = new HeadersImpl();
+ headers.set("location", "/next");
+ return { status: 302, headers, bytes: async () => new Uint8Array() };
+ },
+ },
+ { requireTls: true, enableThrottling: false },
+ );
+ await assert.rejects(
+ http.fetch("https://example.com/start", {
+ timeout: { d_ms: 10 },
+ cancellationToken: token.token,
+ }),
+ (e: unknown) =>
+ e instanceof TalerError &&
+ e.errorDetail.code ===
+ (cancel
+ ? TalerErrorCode.WALLET_CORE_REQUEST_CANCELLED
+ : TalerErrorCode.WALLET_HTTP_REQUEST_GENERIC_TIMEOUT),
+ );
+ assert.strictEqual(calls, 1);
+ }
+});
diff --git a/packages/taler-util/src/http-common.ts b/packages/taler-util/src/http-common.ts
@@ -709,7 +709,7 @@ export class HttpLib implements HttpRequestLibrary {
let resp: HttpRawResponse;
try {
- resp = await this.raw.fetch(requestUrl, {
+ resp = await this.fetchWithTransportPolicy(requestUrl, {
method: requestMethod,
headers,
body,
@@ -803,6 +803,72 @@ export class HttpLib implements HttpRequestLibrary {
};
}
+ /** Enforce TLS before every hop, including on native transports. */
+ private async fetchWithTransportPolicy(
+ requestUrl: string,
+ options: HttpRawRequestOptions,
+ ): Promise<HttpRawResponse> {
+ if (
+ !this.requireTls ||
+ options.redirect === "manual" ||
+ options.redirect === "error"
+ ) {
+ return this.raw.fetch(requestUrl, options);
+ }
+ const deadline =
+ options.timeoutMs === undefined
+ ? undefined
+ : Date.now() + options.timeoutMs;
+ let url = new URL(requestUrl);
+ const opt = {
+ ...options,
+ headers: { ...options.headers },
+ redirect: "manual" as const,
+ };
+ for (let redirects = 0; ; redirects++) {
+ if (opt.cancellationToken?.isCancelled) throw new RequestCancelledError();
+ if (url.protocol !== "https:") {
+ throw Error(`TLS required for redirect to ${url.origin}`);
+ }
+ if (deadline !== undefined) {
+ opt.timeoutMs = deadline - Date.now();
+ if (opt.timeoutMs <= 0) throw new RequestTimeoutError();
+ }
+ const response = await this.raw.fetch(url.href, opt);
+ const location = response.headers.get("location");
+ if (
+ ![301, 302, 303, 307, 308].includes(response.status) ||
+ location == null
+ ) {
+ return response;
+ }
+ if (redirects >= 21) throw Error("too many HTTP redirects");
+ const next = new URL(location, url);
+ // URL credentials must not silently become credentials for a new host.
+ if (next.username || next.password)
+ throw Error("credentials in redirect URL");
+ const dropBody =
+ ((response.status === 301 || response.status === 302) &&
+ opt.method === "POST") ||
+ (response.status === 303 && opt.method !== "GET");
+ if (dropBody) {
+ opt.method = "GET";
+ opt.body = undefined;
+ }
+ for (const name of Object.keys(opt.headers)) {
+ if (
+ /^host$/i.test(name) ||
+ (dropBody && /^content-/i.test(name)) ||
+ (next.origin !== url.origin &&
+ /^(authorization|proxy-authorization|cookie)$/i.test(name))
+ ) {
+ delete opt.headers[name];
+ }
+ }
+ url = next;
+ }
+ }
+
/**
* Turn whatever a raw implementation threw into a TalerError that carries
* the request URL and method.
diff --git a/packages/taler-util/src/http-impl.node.test.ts b/packages/taler-util/src/http-impl.node.test.ts
@@ -22,6 +22,11 @@ import type { IncomingHttpHeaders } from "node:http";
import type { AddressInfo } from "node:net";
import { test } from "node:test";
import { createPlatformHttpLib } from "./http.js";
+import https from "node:https";
+import { mkdtemp, readFile, rm } from "node:fs/promises";
+import { execFileSync } from "node:child_process";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
test("node HTTP requests with a buffered body use Content-Length", async (t) => {
let received: Promise<{ headers: IncomingHttpHeaders; bodyLength: number }>;
@@ -110,3 +115,104 @@ test("node HTTP implements follow, manual and error redirect modes", async (t) =
await assert.rejects(() => http.fetch(url, { redirect: "error" }));
});
+
+test("TLS-required HTTP follows secure redirects without sending plaintext requests", async () => {
+ const dir = await mkdtemp(join(tmpdir(), "taler-http-tls-"));
+ const oldCa = https.globalAgent.options.ca;
+ let plaintextRequests = 0;
+ const plain = createServer((_req, res) => {
+ plaintextRequests++;
+ res.end();
+ });
+ let secure: https.Server | undefined;
+ try {
+ // A fresh local certificate avoids clock-dependent test fixtures. Trust
+ // this certificate; do not disable certificate verification.
+ execFileSync(
+ "openssl",
+ [
+ "req",
+ "-x509",
+ "-newkey",
+ "rsa:2048",
+ "-nodes",
+ "-keyout",
+ join(dir, "key.pem"),
+ "-out",
+ join(dir, "cert.pem"),
+ "-days",
+ "1",
+ "-subj",
+ "/CN=localhost",
+ "-addext",
+ "subjectAltName=IP:127.0.0.1",
+ ],
+ { stdio: "ignore" },
+ );
+ const cert = await readFile(join(dir, "cert.pem"));
+ https.globalAgent.options.ca = cert;
+ await new Promise<void>((resolve) => plain.listen(0, "127.0.0.1", resolve));
+ const plainPort = (plain.address() as AddressInfo).port;
+ secure = https.createServer(
+ { cert, key: await readFile(join(dir, "key.pem")) },
+ (req, res) => {
+ if (req.url === "/via-secure") {
+ res.writeHead(307, { location: "/307" });
+ res.end();
+ } else if (req.url === "/secure") {
+ res.writeHead(307, { location: "/destination" });
+ res.end();
+ } else if (req.url === "/destination") {
+ req.pipe(res);
+ } else {
+ res.writeHead(Number(req.url!.slice(1)), {
+ location: `http://127.0.0.1:${plainPort}/plaintext`,
+ });
+ res.end();
+ }
+ },
+ );
+ await new Promise<void>((resolve) =>
+ secure!.listen(0, "127.0.0.1", resolve),
+ );
+ const base = `https://127.0.0.1:${(secure.address() as AddressInfo).port}`;
+ const http = createPlatformHttpLib({
+ requireTls: true,
+ enableThrottling: false,
+ });
+ for (const status of [307, 308]) {
+ await assert.rejects(
+ http.fetch(`${base}/${status}`, {
+ method: "POST",
+ body: "private-payload",
+ }),
+ );
+ }
+ await assert.rejects(
+ http.fetch(`${base}/via-secure`, {
+ method: "POST",
+ body: "private-payload",
+ }),
+ );
+ assert.strictEqual(plaintextRequests, 0);
+ const followed = await http.fetch(`${base}/secure`, {
+ method: "POST",
+ body: "secure-payload",
+ });
+ assert.strictEqual(await followed.text(), "secure-payload");
+ assert.strictEqual(
+ (await http.fetch(`${base}/307`, { redirect: "manual" })).status,
+ 307,
+ );
+ await assert.rejects(http.fetch(`${base}/307`, { redirect: "error" }));
+ assert.strictEqual(plaintextRequests, 0);
+ } finally {
+ https.globalAgent.options.ca = oldCa;
+ await Promise.all(
+ [plain, secure]
+ .filter((s) => s !== undefined)
+ .map((s) => new Promise<void>((resolve) => s!.close(() => resolve()))),
+ );
+ await rm(dir, { recursive: true, force: true });
+ }
+});