From b9cd1338f3eb585fc4df84b1b6beabb301b9170c Mon Sep 17 00:00:00 2001 From: Webdev Ayomide Date: Mon, 29 Jun 2026 05:54:30 +0000 Subject: [PATCH 1/6] fix(core): sanitize hidden control characters and whitespace in extractRouting --- .gitignore | 1 + .../core-dart/lib/src/routing/extract.dart | 23 +++++- packages/core-go/routing/extract.go | 30 +++++++- packages/spec/vectors.json | 75 +++++++++++++++++++ spec/vectors.json | 75 +++++++++++++++++++ 5 files changed, 200 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 2bede6e5..7493c4de 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules/ __pycache__/ build/ +node_modules/ diff --git a/packages/core-dart/lib/src/routing/extract.dart b/packages/core-dart/lib/src/routing/extract.dart index b780a438..00b9861b 100644 --- a/packages/core-dart/lib/src/routing/extract.dart +++ b/packages/core-dart/lib/src/routing/extract.dart @@ -4,11 +4,28 @@ import '../muxed/decode.dart'; import 'routing_result.dart'; import 'memo.dart'; +String _sanitizeAddress(String raw) { + final cleaned = raw + .replaceAll(RegExp( + r'[\u0000-\u001F\u007F-\u009F\u00AD\u034F\u061C\u115F\u1160\u17B4\u17B5' + r'\u180B-\u180E\u200B-\u200F\u202A-\u202E\u2060-\u206F\u3164\uFEFF\uFFA0' + r'\r\n\t]', + ), '') + .trim(); + return cleaned; +} + /// Extracts deposit routing information from a Stellar payment input. /// Following the standard priority policy, M-address identifiers take /// precedence over any provided memo. RoutingResult extractRouting(RoutingInput input) { - final trimmed = input.destination.trim(); + final original = input.destination; + final sanitized = _sanitizeAddress(original); + if (sanitized != original.trim()) { + print('[stellar-address-kit] SANITIZED_HIDDEN_CHARS: Input contained hidden characters and was sanitized before processing.'); + } + + final trimmed = sanitized.trim(); if (trimmed.isEmpty) { throw const ExtractRoutingException('Invalid input: destination must be a non-empty string.'); } @@ -16,7 +33,7 @@ RoutingResult extractRouting(RoutingInput input) { final prefix = trimmed[0].toUpperCase(); if (prefix != 'G' && prefix != 'M') { throw ExtractRoutingException( - 'Invalid destination: expected a G or M address, got "${input.destination}".', + 'Invalid destination: expected a G or M address, got "$sanitized".', ); } @@ -34,7 +51,7 @@ RoutingResult extractRouting(RoutingInput input) { } } - final parsed = parse(input.destination); + final parsed = parse(sanitized); if (parsed.kind == null) { return RoutingResult( diff --git a/packages/core-go/routing/extract.go b/packages/core-go/routing/extract.go index 329c9979..06dc0cbd 100644 --- a/packages/core-go/routing/extract.go +++ b/packages/core-go/routing/extract.go @@ -1,6 +1,7 @@ package routing import ( + "log" "strconv" "strings" @@ -40,6 +41,28 @@ func normalizeUnsupportedMemoType(memoType string) string { } } +// sanitizeAddress strips hidden Unicode control characters and invisible +// formatting characters from an address string, then trims whitespace. +func sanitizeAddress(raw string) (string, bool) { + var b strings.Builder + b.Grow(len(raw)) + for _, r := range raw { + if r <= 0x1f || (0x7f <= r && r <= 0x9f) || + r == 0xad || r == 0x34f || r == 0x61c || + r == 0x115f || r == 0x1160 || r == 0x17b4 || r == 0x17b5 || + (0x180b <= r && r <= 0x180e) || + (0x200b <= r && r <= 0x200f) || + (0x202a <= r && r <= 0x202e) || + (0x2060 <= r && r <= 0x206f) || + r == 0x3164 || r == 0xfeff || r == 0xffa0 { + continue + } + b.WriteRune(r) + } + cleaned := strings.TrimSpace(b.String()) + return cleaned, cleaned != strings.TrimSpace(raw) +} + // ExtractRouting identifies the deposit routing destination and identifier from a Stellar // payment input. It implements the standard priority policy where M-address identifiers // take precedence over any provided memo. Returns a RoutingResult with the decoded @@ -59,7 +82,12 @@ func ExtractRouting(input RoutingInput) RoutingResult { } } - parsed, err := address.Parse(input.Destination) + sanitized, wasSanitized := sanitizeAddress(input.Destination) + if wasSanitized { + log.Println("[stellar-address-kit] SANITIZED_HIDDEN_CHARS: Input contained hidden characters and was sanitized before processing.") + } + + parsed, err := address.Parse(sanitized) if err != nil { addrErr, ok := err.(*address.AddressError) if !ok { diff --git a/packages/spec/vectors.json b/packages/spec/vectors.json index 8a98f208..93744e1b 100644 --- a/packages/spec/vectors.json +++ b/packages/spec/vectors.json @@ -292,6 +292,81 @@ "negative", "edge" ] + }, + { + "module": "extract_routing", + "description": "G-address with leading zero-width space - should resolve identically", + "input": { + "destination": "\u200bGAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI", + "memoType": "none" + }, + "expected": { + "destinationBaseAccount": "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI", + "routingId": null, + "routingSource": "none", + "warnings": [] + }, + "tags": ["positive", "regression"] + }, + { + "module": "extract_routing", + "description": "G-address with trailing CRLF - should resolve identically", + "input": { + "destination": "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI\r\n", + "memoType": "none" + }, + "expected": { + "destinationBaseAccount": "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI", + "routingId": null, + "routingSource": "none", + "warnings": [] + }, + "tags": ["positive", "regression"] + }, + { + "module": "extract_routing", + "description": "M-address with embedded ZWNJ - should resolve identically", + "input": { + "destination": "MAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQAC\u200cABAAAAAAAAAAEVIG", + "memoType": "none" + }, + "expected": { + "destinationBaseAccount": "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI", + "routingId": "9007199254740993", + "routingSource": "muxed", + "warnings": [] + }, + "tags": ["positive", "regression"] + }, + { + "module": "extract_routing", + "description": "G-address with BOM prefix - should resolve identically", + "input": { + "destination": "\ufeffGAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI", + "memoType": "none" + }, + "expected": { + "destinationBaseAccount": "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI", + "routingId": null, + "routingSource": "none", + "warnings": [] + }, + "tags": ["positive", "regression"] + }, + { + "module": "extract_routing", + "description": "G-address with scattered invisible characters - should resolve identically", + "input": { + "destination": "\u200bGAYCUYT553C5LHVE2XP\u200cW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI\uFEFF", + "memoType": "none" + }, + "expected": { + "destinationBaseAccount": "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI", + "routingId": null, + "routingSource": "none", + "warnings": [] + }, + "tags": ["positive", "regression"] } ] } diff --git a/spec/vectors.json b/spec/vectors.json index 566a99b5..233413a5 100644 --- a/spec/vectors.json +++ b/spec/vectors.json @@ -312,6 +312,81 @@ "negative", "edge" ] + }, + { + "module": "extract_routing", + "description": "G-address with leading zero-width space - should resolve identically", + "input": { + "destination": "\u200bGAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI", + "memoType": "none" + }, + "expected": { + "destinationBaseAccount": "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI", + "routingId": null, + "routingSource": "none", + "warnings": [] + }, + "tags": ["positive", "regression"] + }, + { + "module": "extract_routing", + "description": "G-address with trailing CRLF - should resolve identically", + "input": { + "destination": "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI\r\n", + "memoType": "none" + }, + "expected": { + "destinationBaseAccount": "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI", + "routingId": null, + "routingSource": "none", + "warnings": [] + }, + "tags": ["positive", "regression"] + }, + { + "module": "extract_routing", + "description": "M-address with embedded ZWNJ - should resolve identically", + "input": { + "destination": "MAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQAC\u200cABAAAAAAAAAAEVIG", + "memoType": "none" + }, + "expected": { + "destinationBaseAccount": "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI", + "routingId": "9007199254740993", + "routingSource": "muxed", + "warnings": [] + }, + "tags": ["positive", "regression"] + }, + { + "module": "extract_routing", + "description": "G-address with BOM prefix - should resolve identically", + "input": { + "destination": "\ufeffGAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI", + "memoType": "none" + }, + "expected": { + "destinationBaseAccount": "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI", + "routingId": null, + "routingSource": "none", + "warnings": [] + }, + "tags": ["positive", "regression"] + }, + { + "module": "extract_routing", + "description": "G-address with scattered invisible characters - should resolve identically", + "input": { + "destination": "\u200bGAYCUYT553C5LHVE2XP\u200cW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI\uFEFF", + "memoType": "none" + }, + "expected": { + "destinationBaseAccount": "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI", + "routingId": null, + "routingSource": "none", + "warnings": [] + }, + "tags": ["positive", "regression"] } ] } From d6c1d560d120e8efc07313302e90c70058806489 Mon Sep 17 00:00:00 2001 From: Webdev Ayomide Date: Mon, 29 Jun 2026 11:17:03 +0000 Subject: [PATCH 2/6] fix(core-ts): apply sanitizeAddress in extractRouting TypeScript implementation --- packages/core-ts/src/routing/extract.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/core-ts/src/routing/extract.ts b/packages/core-ts/src/routing/extract.ts index 8f632b2a..a082be1f 100644 --- a/packages/core-ts/src/routing/extract.ts +++ b/packages/core-ts/src/routing/extract.ts @@ -12,6 +12,13 @@ export class ExtractRoutingError extends Error { } } +function sanitizeAddress(raw: string): string { + return raw + .replace(/[\u0000-\u001F\u007F-\u009F\u00AD\u034F\u061C\u115F\u1160\u17B4\u17B5\u180B-\u180E\u200B-\u200F\u202A-\u202E\u2060-\u206F\u3164\uFEFF\uFFA0]/g, '') + .replace(/[\r\n\t]/g, '') + .trim(); +} + /** * Validates that the destination string passes the minimum structural * requirements for a Stellar address before routing logic is applied. @@ -44,11 +51,17 @@ function assertRoutableAddress(destination: string): void { * @returns A result containing the base account, routing ID, source, and any warnings. */ export function extractRouting(input: RoutingInput): RoutingResult { - assertRoutableAddress(input.destination); + const rawDestination = input.destination; + const sanitizedDestination = sanitizeAddress(rawDestination); + if (sanitizedDestination !== rawDestination) { + console.info('[stellar-address-kit] SANITIZED_HIDDEN_CHARS: Input contained hidden characters and was sanitized before processing.'); + } + const sanitizedInput: RoutingInput = { ...input, destination: sanitizedDestination }; + assertRoutableAddress(sanitizedInput.destination); let parsed; try { - parsed = parse(input.destination); + parsed = parse(sanitizedInput.destination); } catch (error) { if (error instanceof AddressParseError) { return { From 905b7faa9aa41ed7752e81fa8330ee4fa070dd6f Mon Sep 17 00:00:00 2001 From: Webdev Ayomide Date: Wed, 1 Jul 2026 08:37:11 +0000 Subject: [PATCH 3/6] fix(core-ts): fix extractFromURI test import paths and M-address handling --- .../core-ts/src/routing/extractFromURI.test.ts | 8 ++++---- packages/core-ts/src/routing/extractFromURI.ts | 14 ++++++++++++-- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/core-ts/src/routing/extractFromURI.test.ts b/packages/core-ts/src/routing/extractFromURI.test.ts index 9c0c4bdf..18e26838 100644 --- a/packages/core-ts/src/routing/extractFromURI.test.ts +++ b/packages/core-ts/src/routing/extractFromURI.test.ts @@ -1,5 +1,5 @@ -import { isSuccessfulURIResult } from "../src/lib/extractRoutingFromURI"; -import { extractRoutingFromURI } from '../lib/extractRoutingFromURI'; +import { isSuccessfulURIResult } from "./extractFromURI"; +import { extractRoutingFromURI } from './extractFromURI'; describe("extractRoutingFromURI", () => { describe("scheme validation", () => { @@ -128,12 +128,12 @@ describe("extractRoutingFromURI", () => { describe("M-address handling", () => { it("passes M-address to extractRouting for canonical expansion", () => { const result = extractRoutingFromURI( - "web+stellar:pay?destination=MA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLT7AV7Y6S33Z6S3CHBAAAAAAAAAAAAABQD" + "web+stellar:pay?destination=MBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OAAAAAAAAAAAPOGVY" ); expect(result.success).toBe(true); if (isSuccessfulURIResult(result)) { // extractRouting should expand M-address to G-address + routingId - expect(result.routing.address).toMatch(/^G/); + expect(result.routing.destinationBaseAccount).toMatch(/^G/); expect(result.routing.routingId).toBeDefined(); } }); diff --git a/packages/core-ts/src/routing/extractFromURI.ts b/packages/core-ts/src/routing/extractFromURI.ts index f8cdb009..0360ff98 100644 --- a/packages/core-ts/src/routing/extractFromURI.ts +++ b/packages/core-ts/src/routing/extractFromURI.ts @@ -155,10 +155,20 @@ export function extractRoutingFromURI(uriString: string): ExtractRoutingFromURIR // 8. Delegate to core extractRouting logic const routingResult = extractRouting(routingInput); - // 9. Return combined result + // 9. Normalize returned routing fields for URI extraction. + // Some Stellar SDK methods may return String wrapper objects instead of + // primitive strings for parsed address components. + const normalizedRouting = { + ...routingResult, + destinationBaseAccount: + routingResult.destinationBaseAccount === null + ? null + : String(routingResult.destinationBaseAccount), + }; + return { success: true, - routing: routingResult, + routing: normalizedRouting, rawParams, }; } From c44a0b66ca371381ffae655ca7fcaee772a84040 Mon Sep 17 00:00:00 2001 From: Webdev Ayomide Date: Wed, 1 Jul 2026 13:58:01 +0000 Subject: [PATCH 4/6] fix(core-dart): fix broken function structure and List.== test bug - Merged sanitization into extractRoutingSync and removed orphaned extractRouting function header that broke the file structure - Fixed async test that relied on Dart List.== (identity-based) by comparing individual warning fields instead --- packages/core-dart/lib/src/routing/extract.dart | 16 ++++++---------- .../core-dart/test/extract_routing_test.dart | 5 +++-- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/packages/core-dart/lib/src/routing/extract.dart b/packages/core-dart/lib/src/routing/extract.dart index 40ba47cb..48b03c52 100644 --- a/packages/core-dart/lib/src/routing/extract.dart +++ b/packages/core-dart/lib/src/routing/extract.dart @@ -15,10 +15,12 @@ String _sanitizeAddress(String raw) { return cleaned; } -/// Extracts deposit routing information from a Stellar payment input. -/// Following the standard priority policy, M-address identifiers take -/// precedence over any provided memo. -RoutingResult extractRouting(RoutingInput input) { +/// Extracts deposit routing information from a Stellar payment input synchronously. +/// +/// This is the synchronous variant for pure string parsing. +/// For future compatibility with async network checks (Federation, SEP-0029), +/// use [extractRouting] instead. +RoutingResult extractRoutingSync(RoutingInput input) { final original = input.destination; final sanitized = _sanitizeAddress(original); if (sanitized != original.trim()) { @@ -26,12 +28,6 @@ RoutingResult extractRouting(RoutingInput input) { } final trimmed = sanitized.trim(); -/// -/// This is the synchronous variant for pure string parsing. -/// For future compatibility with async network checks (Federation, SEP-0029), -/// use [extractRouting] instead. -RoutingResult extractRoutingSync(RoutingInput input) { - final trimmed = input.destination.trim(); if (trimmed.isEmpty) { throw const ExtractRoutingException('Invalid input: destination must be a non-empty string.'); } diff --git a/packages/core-dart/test/extract_routing_test.dart b/packages/core-dart/test/extract_routing_test.dart index 0c42a3cf..9a19d2a2 100644 --- a/packages/core-dart/test/extract_routing_test.dart +++ b/packages/core-dart/test/extract_routing_test.dart @@ -129,8 +129,9 @@ void main() { result.id == null && result.source == RoutingSource.none && result.destinationError == null && - result.warnings.map((w) => w.code).toList() == - ['memo-ignored', 'MEMO_TEXT_UNROUTABLE'])), + result.warnings.length == 2 && + result.warnings[0].code == 'memo-ignored' && + result.warnings[1].code == 'MEMO_TEXT_UNROUTABLE')), ); }); From bf513a603dbf176d4bb16ad3c08677c2a70998f0 Mon Sep 17 00:00:00 2001 From: Webdev Ayomide Date: Mon, 6 Jul 2026 08:07:24 +0000 Subject: [PATCH 5/6] chore: retrigger CI From e5a875ebd43e46bd8d2acaf13cb1c05c73f83193 Mon Sep 17 00:00:00 2001 From: Webdev Ayomide Date: Sat, 11 Jul 2026 20:03:07 +0000 Subject: [PATCH 6/6] fix(core-dart): detect hidden char sanitization when chars are also whitespace - Fix _sanitizeAddress comparison to use original instead of original.trim() so trailing CRLF and BOM are detected - Sync packages/spec/vectors.json with root canonical spec - Add CHROME_ARGS=--no-sandbox to CI web compat test --- .github/workflows/verify-parity.yml | 1 + .../core-dart/lib/src/routing/extract.dart | 2 +- packages/spec/vectors.json | 20 +++++++++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/.github/workflows/verify-parity.yml b/.github/workflows/verify-parity.yml index d2f1ea74..91422844 100644 --- a/.github/workflows/verify-parity.yml +++ b/.github/workflows/verify-parity.yml @@ -63,6 +63,7 @@ jobs: - run: cd packages/core-dart && dart test test/web_compat --platform chrome env: CHROME_EXECUTABLE: ${{ steps.setup-chrome.outputs.chrome-path }} + CHROME_ARGS: --no-sandbox parity-gate: name: parity gate diff --git a/packages/core-dart/lib/src/routing/extract.dart b/packages/core-dart/lib/src/routing/extract.dart index 48b03c52..6774964f 100644 --- a/packages/core-dart/lib/src/routing/extract.dart +++ b/packages/core-dart/lib/src/routing/extract.dart @@ -23,7 +23,7 @@ String _sanitizeAddress(String raw) { RoutingResult extractRoutingSync(RoutingInput input) { final original = input.destination; final sanitized = _sanitizeAddress(original); - if (sanitized != original.trim()) { + if (sanitized != original) { print('[stellar-address-kit] SANITIZED_HIDDEN_CHARS: Input contained hidden characters and was sanitized before processing.'); } diff --git a/packages/spec/vectors.json b/packages/spec/vectors.json index 93744e1b..233413a5 100644 --- a/packages/spec/vectors.json +++ b/packages/spec/vectors.json @@ -1,5 +1,6 @@ { "spec_version": "1.0.0", + "description": "Normative test vectors for the Stellar Address Kit. This file is the single source of truth for routing logic across TypeScript, Go, and Dart implementations. Any change to routing behavior MUST start here.", "cases": [ { "module": "muxed_encode", @@ -246,6 +247,25 @@ "positive" ] }, + { + "module": "extract_routing", + "description": "G-address + MEMO_ID routing with 2^53+1 canary", + "input": { + "destination": "GA7QYNF7SZFX4X7X5JFZZ3UQ6BXHDSY2RKVKZKX5FFQJ1ZMZX1", + "memoType": "id", + "memoValue": "9007199254740993" + }, + "expected": { + "destinationBaseAccount": "GA7QYNF7SZFX4X7X5JFZZ3UQ6BXHDSY2RKVKZKX5FFQJ1ZMZX1", + "routingId": "9007199254740993", + "routingSource": "memo", + "warnings": [] + }, + "tags": [ + "positive", + "edge" + ] + }, { "module": "extract_routing", "description": "G-address + MEMO_TEXT numeric routing",