commit 5285cd6f27a02e8288cf254450e86524c53792c1
parent 62e790759350accb96a11ba7ec0fe89735d2bd9e
Author: Florian Dold <dold@taler.net>
Date: Sat, 22 Aug 2026 14:13:05 +0200
util: add safe external URL validation
Diffstat:
3 files changed, 117 insertions(+), 2 deletions(-)
diff --git a/packages/taler-util/src/codec.test.ts b/packages/taler-util/src/codec.test.ts
@@ -30,7 +30,13 @@ import {
buildCodecForUnion,
DecodingError,
codecOptional,
+ codecForSafeExternalUrlString,
+ isSafeExternalUrl,
} from "./codec.js";
+import {
+ codecForRegistrationResponse,
+ codecForUriSubject,
+} from "./types-taler-prepared-transfer.js";
interface MyObj {
foo: string;
@@ -131,3 +137,71 @@ test("Codec is invariant, so optional/required mismatches are compile errors", (
const d: Codec<string | undefined> = opt;
assert.ok(a === req && b === opt && c === req && d === opt);
});
+
+test("external URL policy permits encrypted and loopback navigation", () => {
+ for (const value of [
+ "https://bank.example/transfer/1",
+ "http://localhost:8080/transfer",
+ "http://127.0.0.42/transfer",
+ "http://[::1]:8080/transfer",
+ ]) {
+ assert.equal(isSafeExternalUrl(value), true, value);
+ assert.equal(codecForSafeExternalUrlString().decode(value), value);
+ }
+});
+
+test("external URL policy rejects unsafe destinations", () => {
+ for (const value of [
+ "http://bank.example/transfer",
+ "https://bank.example@evil.example/transfer",
+ "javascript:alert(1)",
+ "data:text/html,phishing",
+ "file:///etc/passwd",
+ "banking-app://transfer/1",
+ "not a URL",
+ undefined,
+ ]) {
+ assert.equal(isSafeExternalUrl(value), false, String(value));
+ assert.throws(() => codecForSafeExternalUrlString().decode(value));
+ }
+});
+
+test("prepared-transfer URI subjects remain opaque at the protocol boundary", () => {
+ const codec = codecForUriSubject();
+ assert.equal(
+ codec.decode({
+ type: "URI",
+ credit_amount: "CHF:1",
+ uri: "https://bank.example/transfer/1",
+ }).uri,
+ "https://bank.example/transfer/1",
+ );
+ assert.equal(
+ codec.decode({
+ type: "URI",
+ credit_amount: "CHF:1",
+ uri: "http://bank.example/transfer/1",
+ }).uri,
+ "http://bank.example/transfer/1",
+ );
+ const response = codecForRegistrationResponse().decode({
+ subjects: [
+ {
+ type: "URI",
+ credit_amount: "CHF:1",
+ uri: "banking-app://transfer/1",
+ },
+ {
+ type: "SIMPLE",
+ credit_amount: "CHF:1",
+ subject: "TALER transfer subject",
+ },
+ ],
+ expiration: { t_s: 1_800_000_000 },
+ });
+ assert.equal(response.subjects.length, 2);
+ assert.equal(
+ isSafeExternalUrl((response.subjects[0] as { uri: string }).uri),
+ false,
+ );
+});
diff --git a/packages/taler-util/src/codec.ts b/packages/taler-util/src/codec.ts
@@ -563,6 +563,48 @@ export function codecForHttpUrlString(
}
/**
+ * Check whether a URL is safe to hand to a browser as an external link.
+ *
+ * Plain HTTP is deliberately limited to loopback hosts so local development
+ * remains possible without allowing an attacker to downgrade navigation to an
+ * unencrypted remote origin. User information is rejected because it can
+ * obscure the actual destination in browser UI.
+ */
+export function isSafeExternalUrl(x: string | undefined): x is string {
+ if (!x) return false;
+ let url: URL;
+ try {
+ url = new URL(x);
+ } catch {
+ return false;
+ }
+ if (url.username !== "" || url.password !== "" || url.hostname === "") {
+ return false;
+ }
+ if (url.protocol === "https:") return true;
+ if (url.protocol !== "http:") return false;
+ if (url.hostname === "localhost" || url.hostname === "[::1]") return true;
+ const octets = url.hostname.split(".");
+ return octets.length === 4 && octets[0] === "127";
+}
+
+/**
+ * Return a codec for URLs that satisfy the external-navigation policy.
+ */
+export function codecForSafeExternalUrlString(): Codec<string> {
+ return {
+ decode(x: any, c?: Context): string {
+ if (typeof x !== "string" || !isSafeExternalUrl(x)) {
+ throw new DecodingError(
+ `expected a safe external URL at ${renderContext(c)} but got ${JSON.stringify(x)}`,
+ );
+ }
+ return x;
+ },
+ };
+}
+
+/**
* Return a codec for a string that must be an absolute http(s) URL in
* canonical base URL form.
*
diff --git a/packages/taler-util/src/types-taler-prepared-transfer.ts b/packages/taler-util/src/types-taler-prepared-transfer.ts
@@ -26,7 +26,6 @@ import {
codecForEither,
codecForList,
codecForString,
- codecForStringURL,
codecOptional,
} from "./codec.js";
import { codecForTimestamp, TalerProtocolTimestamp } from "./time.js";
@@ -203,7 +202,7 @@ export const codecForUriSubject =
buildCodecForObject<UriSubject>()
.property("type", codecForConstString("URI"))
.property("credit_amount", codecForAmountString())
- .property("uri", codecForStringURL())
+ .property("uri", codecForString())
.build("TalerPreparedTransferApi.UriSubject");
export const codecForSwissQrBillSubject =