From 5d122917593f426e79b88e8440075124d0be1cc4 Mon Sep 17 00:00:00 2001 From: Naor Sabag <32329815+naorsabag@users.noreply.github.com> Date: Tue, 12 May 2026 10:08:34 +0000 Subject: [PATCH 1/3] feat(web): shrink share URLs ~30-50% via minify + deflate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub Pages playground share URLs were 1.1K-4.4K chars because the hash held lz-string-compressed raw YAML. Slack/email/WhatsApp start mangling URLs past ~2K, and order-flow.yaml at 4,359 chars was unusable in many clients. This switches the encoder to: 1. Parse + restringify the YAML with 1-space indent and no line wrap. Drops comments and whitespace the renderer never reads. 2. DEFLATE-raw via fflate (sync, ~8KB gzipped — no async refactor needed for the existing callers in AppFragment). 3. Base64url + a '~1' version prefix so the decoder can tell v1 fragments from legacy lz-string ones. Legacy decode path is kept so any v0 share URL already in the wild keeps resolving. '~' is outside lz-string's output alphabet, so the prefix is unambiguous. Measured on the bundled example flows: simple-crud 1122 -> 609 (46% shorter) auth-flow 1424 -> 1046 (27%) order-flow 4321 -> 2834 (34%) self-loops 2215 -> 1409 (36%) type-variants 2162 -> 1002 (54%) Adds 3 tests (v1 prefix, legacy decode round-trip, "new shorter than legacy"); the original round-trip test now compares parsed objects since minification rewrites whitespace. --- package-lock.json | 8 +++ packages/web/__tests__/share-url.test.ts | 40 ++++++++++---- packages/web/package.json | 3 ++ packages/web/src/lib/share-url.ts | 68 ++++++++++++++++++++++-- 4 files changed, 105 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2d37b95..42b3555 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4248,6 +4248,11 @@ } } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -8718,6 +8723,9 @@ "name": "@openhop/web", "version": "0.2.0", "license": "MIT", + "dependencies": { + "fflate": "^0.8.2" + }, "devDependencies": { "@codemirror/lang-yaml": "^6.1.3", "@eslint/js": "^9.39.4", diff --git a/packages/web/__tests__/share-url.test.ts b/packages/web/__tests__/share-url.test.ts index 74c91e5..23b43c5 100644 --- a/packages/web/__tests__/share-url.test.ts +++ b/packages/web/__tests__/share-url.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from 'vitest' +import LZString from 'lz-string' +import YAML from 'yaml' import { parseFlowYaml } from '@openhop/shared' import { buildShareUrl, decodeFragment, encodeFragment } from '../src/lib/share-url' @@ -20,24 +22,41 @@ flow: ` describe('share-url encode/decode', () => { - it('round-trips a typical flow', () => { + it('round-trips a typical flow (semantically — minification rewrites whitespace)', () => { const enc = encodeFragment(SAMPLE_YAML) expect(enc).not.toContain(' ') expect(enc).not.toContain('\n') const dec = decodeFragment(enc) - expect(dec).toBe(SAMPLE_YAML) - // And the round-tripped YAML still validates against the schema. + expect(dec).not.toBeNull() + // YAML stringify may pick a different indent / quoting style, so compare + // the parsed objects rather than the raw text. + expect(YAML.parse(dec!)).toEqual(YAML.parse(SAMPLE_YAML)) expect(parseFlowYaml(dec ?? '').success).toBe(true) }) - it('compresses well — typical flow encodes to fewer than the raw byte count', () => { - // Locks in that the lz-string variant is actually denser than the raw - // bytes for representative input. (URL-safe base64 of raw text would be - // ~1.34× — lz-string typically wins by ~3× on YAML's repetitive shape.) + it('emits the v1 prefix (~1) for new shares', () => { + expect(encodeFragment(SAMPLE_YAML).startsWith('~1')).toBe(true) + }) + + it('compresses meaningfully — encoded form is shorter than the raw YAML', () => { const enc = encodeFragment(SAMPLE_YAML) expect(enc.length).toBeLessThan(SAMPLE_YAML.length) }) + it('new format is shorter than the legacy lz-string form for typical input', () => { + const v1 = encodeFragment(SAMPLE_YAML) + const v0 = LZString.compressToEncodedURIComponent(SAMPLE_YAML) + expect(v1.length).toBeLessThan(v0.length) + }) + + it('still decodes legacy lz-string fragments (backward compat for old share URLs)', () => { + const legacy = LZString.compressToEncodedURIComponent(SAMPLE_YAML) + const dec = decodeFragment(legacy) + // Legacy v0 returns the YAML verbatim (no minify step on decode), so + // string-equality holds in this direction. + expect(dec).toBe(SAMPLE_YAML) + }) + it('decodes empty / missing fragment to null', () => { expect(decodeFragment('')).toBeNull() }) @@ -45,14 +64,17 @@ describe('share-url encode/decode', () => { it('decodes garbage to null (caller renders the corrupted-link banner)', () => { expect(decodeFragment('this-is-not-lz-encoded')).toBeNull() expect(decodeFragment('!!!~~~')).toBeNull() + // v1 prefix with junk after it — the base64 might decode but inflate + // will fail. + expect(decodeFragment('~1!!!not-base64!!!')).toBeNull() }) it('buildShareUrl uses Vite BASE_URL so dev (/) and Pages (/openhop/) both work', () => { const dev = buildShareUrl(SAMPLE_YAML, 'http://localhost:8788', '/') - expect(dev).toMatch(/^http:\/\/localhost:8788\/#[A-Za-z0-9_+\-$.]+$/) + expect(dev).toMatch(/^http:\/\/localhost:8788\/#~1[A-Za-z0-9_-]+$/) const pages = buildShareUrl(SAMPLE_YAML, 'https://naorsabag.github.io', '/openhop/') - expect(pages).toMatch(/^https:\/\/naorsabag\.github\.io\/openhop\/#[A-Za-z0-9_+\-$.]+$/) + expect(pages).toMatch(/^https:\/\/naorsabag\.github\.io\/openhop\/#~1[A-Za-z0-9_-]+$/) // Hash content matches encodeFragment for both URLs (proves the BASE_URL // only affects the path, never the encoded payload). diff --git a/packages/web/package.json b/packages/web/package.json index c9d17e7..e04e6bf 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -62,5 +62,8 @@ "vite": "^8.0.11", "vitest": "^1.6.1", "yaml": "^2.8.4" + }, + "dependencies": { + "fflate": "^0.8.2" } } diff --git a/packages/web/src/lib/share-url.ts b/packages/web/src/lib/share-url.ts index 8ffadc6..006f6a4 100644 --- a/packages/web/src/lib/share-url.ts +++ b/packages/web/src/lib/share-url.ts @@ -1,23 +1,81 @@ import LZString from 'lz-string' +import { deflateSync, inflateSync } from 'fflate' +import YAML from 'yaml' /** * URL-fragment encoding for the GitHub Pages deploy. * * The static deploy has no API, so a flow's full YAML lives in the URL hash: - * https://naorsabag.github.io/OpenHop/# + * https://naorsabag.github.io/openhop/# * - * `compressToEncodedURIComponent` is the LZ variant designed for URLs — its - * output is already safe to drop straight into a hash without further encoding. - * Decompresses to `null` on bad input (truncated link, foreign payload, etc.), + * Two on-the-wire formats coexist; the leading byte(s) of the fragment tell + * the decoder which one to use: + * + * ~1 — version 1: YAML parsed + restringified with minimal + * indentation (drops comments/whitespace), DEFLATE-raw + * compressed, base64url-encoded. ~30-50% shorter than v0. + * — legacy: lz-string's URL-safe variant of the raw YAML. + * + * `~` is outside lz-string's output alphabet + * (`[A-Za-z0-9$_-]`), so the version prefix is unambiguous and old share + * URLs in the wild keep working. + * + * Bad input (truncated link, foreign payload, etc.) round-trips to `null`, * which the app surfaces as a "share link looks corrupted" banner. */ +const V1_PREFIX = '~1' + +function bytesToBase64Url(bytes: Uint8Array): string { + // btoa() needs a binary string, not a Uint8Array. + let binary = '' + for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]) + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +function base64UrlToBytes(input: string): Uint8Array { + let s = input.replace(/-/g, '+').replace(/_/g, '/') + while (s.length % 4 !== 0) s += '=' + const binary = atob(s) + const out = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i) + return out +} + +/** + * Drop comments and re-emit with 1-space indent + no line wrapping. Reduces + * the byte count fed to DEFLATE by ~20-40% on the bundled example flows. + * Falls through to the raw text if the input doesn't parse (the editor may + * call us mid-keystroke on invalid YAML). + */ +function minifyYaml(yamlText: string): string { + try { + const parsed = YAML.parse(yamlText) + if (parsed === undefined) return yamlText + return YAML.stringify(parsed, { indent: 1, lineWidth: 0, minContentWidth: 0 }) + } catch { + return yamlText + } +} + export function encodeFragment(yamlText: string): string { - return LZString.compressToEncodedURIComponent(yamlText) + const minified = minifyYaml(yamlText) + const compressed = deflateSync(new TextEncoder().encode(minified), { level: 9 }) + return V1_PREFIX + bytesToBase64Url(compressed) } export function decodeFragment(fragment: string): string | null { if (!fragment) return null + if (fragment.startsWith(V1_PREFIX)) { + try { + const bytes = base64UrlToBytes(fragment.slice(V1_PREFIX.length)) + const out = new TextDecoder().decode(inflateSync(bytes)) + return out.length > 0 ? out : null + } catch { + return null + } + } + // Legacy v0 — lz-string of the raw YAML. Keeps old share URLs working. try { const out = LZString.decompressFromEncodedURIComponent(fragment) return out && out.length > 0 ? out : null From c2755a740bd0ae32ff41d25e6f8d0eb6b29af2fc Mon Sep 17 00:00:00 2001 From: Naor Sabag <32329815+naorsabag@users.noreply.github.com> Date: Tue, 12 May 2026 10:18:08 +0000 Subject: [PATCH 2/3] fix(web): cap share-fragment size to defuse DEFLATE bombs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review on PR #144: decodeFragment was calling inflateSync on untrusted base64-decoded bytes with no size guard. A small base64url payload that compresses 1000x would let a hostile share URL allocate tens of megabytes (worst case ~64 KB input * ~1032x DEFLATE ratio). Adds two caps: - MAX_FRAGMENT_BYTES (64 KB) on the compressed input. fflate has no streaming-abort hook, so capping the input is what bounds the worst case allocation regardless of expansion ratio. - MAX_INFLATED_BYTES (1 MB) on the post-inflate output. Soft cap — by this point fflate has already allocated, but the result is bounded and the function refuses to surface the data to callers. The largest legitimate flow in examples/ compresses to ~3 KB and inflates to ~9 KB, so 64 KB / 1 MB caps leave 20-100x headroom for legitimate inputs. Adds two tests: oversized-input rejection and a real DEFLATE-bomb rejection (2 MB of zeros compressed to ~2 KB, which fits under the input cap but blows past the output cap). --- packages/web/__tests__/share-url.test.ts | 22 ++++++++++++++++++++++ packages/web/src/lib/share-url.ts | 15 ++++++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/web/__tests__/share-url.test.ts b/packages/web/__tests__/share-url.test.ts index 23b43c5..5d6ad2c 100644 --- a/packages/web/__tests__/share-url.test.ts +++ b/packages/web/__tests__/share-url.test.ts @@ -1,9 +1,17 @@ import { describe, expect, it } from 'vitest' import LZString from 'lz-string' import YAML from 'yaml' +import { deflateSync } from 'fflate' import { parseFlowYaml } from '@openhop/shared' import { buildShareUrl, decodeFragment, encodeFragment } from '../src/lib/share-url' +function toBase64Url(bytes: Uint8Array): string { + let bin = '' + for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]) + // btoa is available in vitest's jsdom env + return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + const SAMPLE_YAML = `meta: title: Round-trip path: demos @@ -69,6 +77,20 @@ describe('share-url encode/decode', () => { expect(decodeFragment('~1!!!not-base64!!!')).toBeNull() }) + it('refuses oversized v1 fragments (compressed-input cap)', () => { + // 80 KB of random-ish bytes; well above the 64 KB compressed cap. Doesn't + // even need to be a real DEFLATE stream — the length check fires first. + const bomb = new Uint8Array(80 * 1024).map((_, i) => (i * 31) & 0xff) + expect(decodeFragment('~1' + toBase64Url(bomb))).toBeNull() + }) + + it('refuses decompression bombs (inflated-output cap)', () => { + // 2 MB of zeros DEFLATE-compresses to ~2 KB — fits under the input cap + // but blows past the 1 MB inflated cap. + const oversized = deflateSync(new Uint8Array(2 * 1024 * 1024), { level: 9 }) + expect(decodeFragment('~1' + toBase64Url(oversized))).toBeNull() + }) + it('buildShareUrl uses Vite BASE_URL so dev (/) and Pages (/openhop/) both work', () => { const dev = buildShareUrl(SAMPLE_YAML, 'http://localhost:8788', '/') expect(dev).toMatch(/^http:\/\/localhost:8788\/#~1[A-Za-z0-9_-]+$/) diff --git a/packages/web/src/lib/share-url.ts b/packages/web/src/lib/share-url.ts index 006f6a4..b3de16a 100644 --- a/packages/web/src/lib/share-url.ts +++ b/packages/web/src/lib/share-url.ts @@ -26,6 +26,16 @@ import YAML from 'yaml' const V1_PREFIX = '~1' +// Decompression-bomb guards. fflate has no streaming-abort hook, so we cap +// in two places: the compressed input length (which bounds worst-case +// allocation at MAX_INFLATED_BYTES via fflate, regardless of the DEFLATE +// expansion ratio attempted), and the inflated output (post-hoc, soft cap). +// Real example flows compress to ~3 KB / inflate to ~9 KB at the high end — +// the limits below leave 20-100x headroom for legitimate inputs while +// refusing pathological share URLs. +const MAX_FRAGMENT_BYTES = 64 * 1024 +const MAX_INFLATED_BYTES = 1 * 1024 * 1024 + function bytesToBase64Url(bytes: Uint8Array): string { // btoa() needs a binary string, not a Uint8Array. let binary = '' @@ -69,7 +79,10 @@ export function decodeFragment(fragment: string): string | null { if (fragment.startsWith(V1_PREFIX)) { try { const bytes = base64UrlToBytes(fragment.slice(V1_PREFIX.length)) - const out = new TextDecoder().decode(inflateSync(bytes)) + if (bytes.length > MAX_FRAGMENT_BYTES) return null + const inflated = inflateSync(bytes) + if (inflated.length > MAX_INFLATED_BYTES) return null + const out = new TextDecoder().decode(inflated) return out.length > 0 ? out : null } catch { return null From e8c2dfdb98e5ae3050947ff1e06dab7cc6b75eb0 Mon Sep 17 00:00:00 2001 From: Naor Sabag <32329815+naorsabag@users.noreply.github.com> Date: Tue, 12 May 2026 10:28:20 +0000 Subject: [PATCH 3/3] fix(web): reject oversized v1 fragments before base64 decoding Per follow-up review on PR #144: the previous patch checked the decoded byte length, but the base64url payload was already fully decoded into a binary string by atob() before that check ran. A multi-megabyte hash could still force a sizable intermediate allocation + copy loop before being rejected. Adds MAX_FRAGMENT_BASE64URL_CHARS = ceil(MAX_FRAGMENT_BYTES / 3) * 4 and checks the encoded-payload string length first. Lets a hostile hash get refused in constant time, before any atob work runs. The byte-length check stays in as defense-in-depth (the string cap allows up to ~2 extra bytes through depending on padding). --- packages/web/src/lib/share-url.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/web/src/lib/share-url.ts b/packages/web/src/lib/share-url.ts index b3de16a..43ca1dd 100644 --- a/packages/web/src/lib/share-url.ts +++ b/packages/web/src/lib/share-url.ts @@ -35,6 +35,12 @@ const V1_PREFIX = '~1' // refusing pathological share URLs. const MAX_FRAGMENT_BYTES = 64 * 1024 const MAX_INFLATED_BYTES = 1 * 1024 * 1024 +// Pre-decode string-length cap. Base64url packs 3 bytes into 4 chars +// (no padding here), so N bytes ≤ MAX_FRAGMENT_BYTES ⇒ encoded length +// ≤ ⌈N/3⌉ × 4. Checking this first lets us reject a multi-megabyte +// hash in constant time, without atob/copy allocating the intermediate +// binary string. +const MAX_FRAGMENT_BASE64URL_CHARS = Math.ceil(MAX_FRAGMENT_BYTES / 3) * 4 function bytesToBase64Url(bytes: Uint8Array): string { // btoa() needs a binary string, not a Uint8Array. @@ -78,7 +84,9 @@ export function decodeFragment(fragment: string): string | null { if (!fragment) return null if (fragment.startsWith(V1_PREFIX)) { try { - const bytes = base64UrlToBytes(fragment.slice(V1_PREFIX.length)) + const payload = fragment.slice(V1_PREFIX.length) + if (payload.length > MAX_FRAGMENT_BASE64URL_CHARS) return null + const bytes = base64UrlToBytes(payload) if (bytes.length > MAX_FRAGMENT_BYTES) return null const inflated = inflateSync(bytes) if (inflated.length > MAX_INFLATED_BYTES) return null