From b9cd1338f3eb585fc4df84b1b6beabb301b9170c Mon Sep 17 00:00:00 2001 From: Webdev Ayomide Date: Mon, 29 Jun 2026 05:54:30 +0000 Subject: [PATCH 1/5] 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/5] 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/5] 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/5] 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/5] chore: retrigger CI