From 9de9d6cb74aed652a43d0a97a1abd9743629e899 Mon Sep 17 00:00:00 2001 From: Rohit <71192000+rohitsux@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:05:31 +0530 Subject: [PATCH 1/2] fix(ts-sdk): decode base64 data URLs with media-type parameters toImageBytes()'s regex ^data:[^;]+;base64, requires a ;-free media type, so valid RFC 2397 data URLs with a media-type parameter (e.g. data:image/svg+xml;charset=utf-8;base64,...) or an omitted media type (data:;base64,...) don't match and fall through, handing the entire data URL to the base64 decoder. That decoder then throws InvalidCharacterError under atob (browser) or silently corrupts bytes under Buffer.from (Node). Fix matches up to the ;base64, marker ([^,]*) instead. Base64 payloads never contain a comma, so the payload is still captured correctly. No change in behavior for existing inputs. Added two regression tests covering a media type with a parameter and an omitted media type. --- packages/sie_ts_sdk/src/images.ts | 8 ++++++-- packages/sie_ts_sdk/tests/images.test.ts | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/sie_ts_sdk/src/images.ts b/packages/sie_ts_sdk/src/images.ts index 1e94817f7..9f9a2f6e7 100644 --- a/packages/sie_ts_sdk/src/images.ts +++ b/packages/sie_ts_sdk/src/images.ts @@ -79,8 +79,12 @@ export async function toImageBytes(input: ImageInput): Promise { // Base64 string or data URL if (typeof input === "string") { - // Check if it's a data URL - const dataUrlMatch = input.match(/^data:[^;]+;base64,(.+)$/); + // Check if it's a base64 data URL. Per RFC 2397 the media type may carry + // parameters (e.g. ";charset=utf-8") or be omitted entirely, so match + // everything up to the ";base64," marker rather than a single ";"-free + // segment — otherwise such URLs fall through and the whole data URL is + // handed to the base64 decoder (corrupting the bytes or throwing). + const dataUrlMatch = input.match(/^data:[^,]*;base64,(.+)$/); if (dataUrlMatch?.[1]) { return base64ToBytes(dataUrlMatch[1]); } diff --git a/packages/sie_ts_sdk/tests/images.test.ts b/packages/sie_ts_sdk/tests/images.test.ts index 769fe3796..d560b2472 100644 --- a/packages/sie_ts_sdk/tests/images.test.ts +++ b/packages/sie_ts_sdk/tests/images.test.ts @@ -51,6 +51,25 @@ describe("toImageBytes", () => { expect(new TextDecoder().decode(result)).toBe("test"); }); + it("decodes a data URL whose media type carries a parameter", async () => { + // Valid per RFC 2397: the media type may be followed by ";param=value" + // (e.g. charset) before ";base64,". "Hello" base64-encoded. + const dataUrl = "data:image/svg+xml;charset=utf-8;base64,SGVsbG8="; + const result = await toImageBytes(dataUrl); + + expect(result).toBeInstanceOf(Uint8Array); + expect(new TextDecoder().decode(result)).toBe("Hello"); + }); + + it("decodes a data URL with an omitted media type", async () => { + // RFC 2397 permits an empty media type (defaults to text/plain). + const dataUrl = "data:;base64,SGVsbG8="; + const result = await toImageBytes(dataUrl); + + expect(result).toBeInstanceOf(Uint8Array); + expect(new TextDecoder().decode(result)).toBe("Hello"); + }); + it("throws for unsupported input type", async () => { await expect(toImageBytes(123 as unknown as Uint8Array)).rejects.toThrow( "Unsupported image input type", From 970dddaff878635929fd4ff2a65ec31a6e983faf Mon Sep 17 00:00:00 2001 From: Rohit <71192000+rohitsux@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:10:49 +0530 Subject: [PATCH 2/2] fix(ts-sdk): parse base64 data URLs structurally with explicit edge cases Replace the data-URL regex in toImageBytes with a parseBase64DataUrl helper that walks the RFC 2397 grammar structurally: it splits on the first comma, matches the data: scheme and the base64 marker case-insensitively, percent-decodes the payload, and throws a clear error for a malformed or non-base64 data: URL instead of silently handing it to the base64 decoder. base64ToBytes is now responsible only for decoding the extracted payload. Add tests for an uppercase scheme and BASE64 marker, percent-escaped padding (%3D), an empty payload, and the two clear-error cases. --- packages/sie_ts_sdk/src/images.ts | 62 +++++++++++++++++++----- packages/sie_ts_sdk/tests/images.test.ts | 38 +++++++++++++++ 2 files changed, 88 insertions(+), 12 deletions(-) diff --git a/packages/sie_ts_sdk/src/images.ts b/packages/sie_ts_sdk/src/images.ts index 9f9a2f6e7..606e1d7fc 100644 --- a/packages/sie_ts_sdk/src/images.ts +++ b/packages/sie_ts_sdk/src/images.ts @@ -79,23 +79,61 @@ export async function toImageBytes(input: ImageInput): Promise { // Base64 string or data URL if (typeof input === "string") { - // Check if it's a base64 data URL. Per RFC 2397 the media type may carry - // parameters (e.g. ";charset=utf-8") or be omitted entirely, so match - // everything up to the ";base64," marker rather than a single ";"-free - // segment — otherwise such URLs fall through and the whole data URL is - // handed to the base64 decoder (corrupting the bytes or throwing). - const dataUrlMatch = input.match(/^data:[^,]*;base64,(.+)$/); - if (dataUrlMatch?.[1]) { - return base64ToBytes(dataUrlMatch[1]); - } - - // Assume it's raw base64 - return base64ToBytes(input); + // A `data:` URL is parsed structurally by parseBase64DataUrl; a plain + // base64 string (which can never begin with "data:", since ":" is not a + // base64 character) is decoded as-is. + const payload = parseBase64DataUrl(input); + return base64ToBytes(payload ?? input); } throw new Error(`Unsupported image input type: ${typeof input}`); } +/** + * Extract the base64 payload from a `data:` URL. + * + * Rather than encoding the RFC 2397 grammar in a regex, this walks the URL + * structurally: everything before the first comma is the header, and the + * `;`-delimited metadata's final segment must be the `base64` marker. The + * scheme and marker are matched case-insensitively, and the payload is + * percent-decoded (so escaped characters such as `%3D` padding are restored) + * before it reaches the base64 decoder. + * + * @returns the base64 payload (possibly empty), or `undefined` when `input` is + * not a `data:` URL at all — in which case the caller treats it as a raw + * base64 string. + * @throws if `input` is a `data:` URL that is malformed (no payload delimiter + * or invalid percent-encoding) or is not base64-encoded, so such inputs fail + * loudly instead of silently corrupting in the base64 decoder. + */ +function parseBase64DataUrl(input: string): string | undefined { + if (!/^data:/i.test(input)) { + return undefined; + } + + const comma = input.indexOf(","); + if (comma === -1) { + throw new Error("Malformed data URL: missing ',' delimiter between metadata and payload"); + } + + const metadata = input.slice("data:".length, comma); + const params = metadata.split(";"); + // split(";") always yields at least one element, but the compiler can't prove + // it under noUncheckedIndexedAccess; optional chaining keeps this type-safe and + // still treats a missing marker as "not base64". + const marker = params.at(-1); + if (marker?.toLowerCase() !== "base64") { + throw new Error("Unsupported data URL: only base64-encoded payloads are supported"); + } + + const payload = input.slice(comma + 1); + try { + return decodeURIComponent(payload); + } catch { + throw new Error("Malformed data URL: payload has invalid percent-encoding"); + } +} + /** * Convert base64 string to Uint8Array. */ diff --git a/packages/sie_ts_sdk/tests/images.test.ts b/packages/sie_ts_sdk/tests/images.test.ts index d560b2472..be6ef191d 100644 --- a/packages/sie_ts_sdk/tests/images.test.ts +++ b/packages/sie_ts_sdk/tests/images.test.ts @@ -70,6 +70,44 @@ describe("toImageBytes", () => { expect(new TextDecoder().decode(result)).toBe("Hello"); }); + it("decodes a data URL with an uppercase scheme and BASE64 marker", async () => { + // RFC 2397: the scheme and the "base64" marker are case-insensitive. + const dataUrl = "DATA:image/png;BASE64,SGVsbG8="; + const result = await toImageBytes(dataUrl); + + expect(result).toBeInstanceOf(Uint8Array); + expect(new TextDecoder().decode(result)).toBe("Hello"); + }); + + it("percent-decodes the payload before base64 decoding", async () => { + // The "=" padding can arrive percent-escaped as "%3D". + const dataUrl = "data:image/png;base64,SGVsbG8%3D"; + const result = await toImageBytes(dataUrl); + + expect(result).toBeInstanceOf(Uint8Array); + expect(new TextDecoder().decode(result)).toBe("Hello"); + }); + + it("returns an empty array for an empty base64 data URL payload", async () => { + const dataUrl = "data:;base64,"; + const result = await toImageBytes(dataUrl); + + expect(result).toBeInstanceOf(Uint8Array); + expect(result.length).toBe(0); + }); + + it("throws a clear error for a data URL that is not base64-encoded", async () => { + // A non-base64 data URL cannot yield image bytes; it must fail loudly + // instead of being handed to the base64 decoder. + await expect(toImageBytes("data:text/plain,Hello")).rejects.toThrow( + "only base64-encoded payloads are supported", + ); + }); + + it("throws a clear error for a data URL missing the payload delimiter", async () => { + await expect(toImageBytes("data:image/png;base64")).rejects.toThrow("missing ',' delimiter"); + }); + it("throws for unsupported input type", async () => { await expect(toImageBytes(123 as unknown as Uint8Array)).rejects.toThrow( "Unsupported image input type",