taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

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:
Mpackages/taler-util/src/codec.test.ts | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-util/src/codec.ts | 42++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-util/src/types-taler-prepared-transfer.ts | 3+--
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 =