From fef83241b1025559097845c8603e6309f4abea96 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:26:40 +0800 Subject: [PATCH] fix(setup): validate Hopper download channel URLs --- src/application/HopperReleaseFeed.test.ts | 32 +++++++++++++++++++++++ src/application/HopperReleaseFeed.ts | 15 +++++++---- src/application/MacHopper.test.ts | 16 ++++++++++++ 3 files changed, 58 insertions(+), 5 deletions(-) diff --git a/src/application/HopperReleaseFeed.test.ts b/src/application/HopperReleaseFeed.test.ts index 663e6304c1..c5d878899a 100644 --- a/src/application/HopperReleaseFeed.test.ts +++ b/src/application/HopperReleaseFeed.test.ts @@ -121,3 +121,35 @@ it("distinguishes malformed UTF-8 from a genuine replacement character and toler parseHopperReleaseJson(new TextEncoder().encode(bom + '{"x":"雪�"}')), ).toMatchObject({ ok: true, value: { x: "雪�" } }); }); + +describe("macOS Hopper public download channel", () => { + const macRelease = { + filename: "https://www.hopperapp.com:443/downloader/public/Hopper-demo.dmg", + file_length: "123", + file_hash: "a".repeat(40), + }; + + it.each([ + "https://www.hopperapp.com/downloader/public/Hopper-demo.dmg", + "https://WWW.HOPPERAPP.COM:443/downloader/public/Hopper-demo.dmg", + "https://www.hopperapp.com:443/downloader/public/Hopper%20demo.dmg", + ])("preserves the admitted source spelling %s", (filename) => { + expect( + parseMacHopperReleases({ "OS X": { ...macRelease, filename } }), + ).toEqual({ ok: true, value: { ...macRelease, filename } }); + }); + + it.each([ + "https://www.hopperapp.com:443/downloader/public/../private/Hopper-demo.dmg", + "https://www.hopperapp.com:443/downloader/public/%2e%2e/private/Hopper-demo.dmg", + "https://www.hopperapp.com:443/downloader/public/Hopper-demo.dmg?", + "https://www.hopperapp.com:443/downloader/public/Hopper-demo.dmg#", + "https://user:password@www.hopperapp.com:443/downloader/public/Hopper-demo.dmg", + "https://other.example/downloader/public/Hopper-demo.dmg", + "http://www.hopperapp.com/downloader/public/Hopper-demo.dmg", + ])("rejects a URL outside the public channel: %s", (filename) => { + expect( + parseMacHopperReleases({ "OS X": { ...macRelease, filename } }), + ).toMatchObject({ ok: false, error: { reason: "release_metadata" } }); + }); +}); diff --git a/src/application/HopperReleaseFeed.ts b/src/application/HopperReleaseFeed.ts index b11e400c3e..dae500bb04 100644 --- a/src/application/HopperReleaseFeed.ts +++ b/src/application/HopperReleaseFeed.ts @@ -52,6 +52,14 @@ const selectedPlatformsSchema = z.object({ Arch: z.unknown().optional(), }); +/** Interpret channel identity once using the same URL semantics as the downloader. */ +const isPublicHopperDownload = (url: URL): boolean => + url.origin === "https://www.hopperapp.com" && + url.username === "" && + url.password === "" && + !urlDeclaresQueryOrFragment(url) && + url.pathname.startsWith("/downloader/public/"); + /** Admit the selected macOS row without projecting away vendor release metadata. */ export const parseMacHopperReleases = ( raw: unknown, @@ -68,7 +76,7 @@ export const parseMacHopperReleases = ( ), ); const retained = release.data as MacHopperRelease; - if (!retained.filename.startsWith(HOPPER_DOWNLOAD_PREFIX)) + if (!isPublicHopperDownload(new URL(retained.filename))) return err( new HopperReleaseError( "release_metadata", @@ -154,10 +162,7 @@ export const parseLinuxHopperRelease = ( ); const url = new URL(parsed.data.filename); if ( - url.origin !== "https://www.hopperapp.com" || - url.username !== "" || - url.password !== "" || - urlDeclaresQueryOrFragment(url) || + !isPublicHopperDownload(url) || !url.pathname.startsWith("/downloader/public/Hopper-") || !url.pathname.endsWith(`-Linux-demo${suffixes[family]}`) ) diff --git a/src/application/MacHopper.test.ts b/src/application/MacHopper.test.ts index a9606020f5..11e4aab67f 100644 --- a/src/application/MacHopper.test.ts +++ b/src/application/MacHopper.test.ts @@ -307,3 +307,19 @@ it.each([false, true])( expect(host.installs).toBe(1); }, ); + +it("refuses release URLs that normalize outside the public channel before package download", async () => { + const host = new FakeMacHopperHost(); + const feed = await host.download("files-api"); + const row = JSON.parse(new TextDecoder().decode(feed.bytes)); + row["OS X"].filename = + "https://www.hopperapp.com:443/downloader/public/%2e%2e/private/Hopper-demo.dmg"; + host.feedBytes = new TextEncoder().encode(JSON.stringify(row)); + + expect(await installMacHopper({}, host)).toMatchObject({ + status: "failed", + reason: "release_metadata", + }); + expect(host.packageDownloads).toBe(0); + expect(host.mounts).toBe(0); +});