From efa53ef726b5b84d28e8cdfe91eb185f1d8c1739 Mon Sep 17 00:00:00 2001 From: snakajima Date: Tue, 6 Oct 2026 06:29:24 -0700 Subject: [PATCH] 0.3.2: follow an avatar URL's redirects only within its origin The package-folder check covers the paths a package declares, not where a redirect goes, so a public host could redirect requests to an internal one. Redirects are now followed manually, at most 5, and only within the same origin. github.com/.../raw/... links, which redirect to raw.githubusercontent.com, get an error naming the final URL. Found in review of receptron/mulmocast-cli#1598. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 ++- package-lock.json | 4 ++-- package.json | 2 +- src/avatar.ts | 22 +++++++++++++++++++++- tests/avatar.test.ts | 11 +++++++++++ 5 files changed, 37 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 9778c04..b01e1c0 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,8 @@ npx avatarscript make --avatar https://raw.githubusercontent.com/receptron/mulmo From a URL, the package's small JSON files are fetched each time and its images are cached in `~/.cache/avatarscript/avatars/` (or `$AVATARSCRIPT_AVATAR_DIR`), per build of the avatar, so an updated avatar is fetched again. Only files inside the package's folder are fetched, each at most -32 MB, and images must be PNGs. Put a commit in the URL instead of `main` to pin a version. Only the text is sent to ElevenLabs; avatar images stay local, and the +32 MB, and images must be PNGs. Redirects are followed only within the same origin, so use direct +file URLs (`raw.githubusercontent.com`, not `github.com/.../raw/...`). Put a commit in the URL instead of `main` to pin a version. Only the text is sent to ElevenLabs; avatar images stay local, and the render page has no network access. ## Library diff --git a/package-lock.json b/package-lock.json index cf736d7..460d638 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "avatarscript", - "version": "0.3.1", + "version": "0.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "avatarscript", - "version": "0.3.1", + "version": "0.3.2", "license": "MIT", "dependencies": { "kuromoji": "^0.1.2", diff --git a/package.json b/package.json index 6a13e77..af9bd4f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "avatarscript", - "version": "0.3.1", + "version": "0.3.2", "description": "Turn an avatar and text into a lip-synced, acted video.", "keywords": [ "avatar", diff --git a/src/avatar.ts b/src/avatar.ts index 91f92ef..941f6ed 100644 --- a/src/avatar.ts +++ b/src/avatar.ts @@ -75,6 +75,26 @@ function diskStore(dir: string): Store { }; } +const MAX_REDIRECTS = 5; + +/** + * Follows redirects only within the URL's origin, so a public host cannot send these requests to + * another one (an internal service, say). GitHub's github.com/…/raw/… links redirect to + * raw.githubusercontent.com: use the latter. + */ +async function fetchSameOrigin(url: URL): Promise { + let location = url; + for (let hops = 0; hops <= MAX_REDIRECTS; hops++) { + const response = await fetch(location, { redirect: "manual", signal: AbortSignal.timeout(120_000) }); + const next = response.status >= 300 && response.status < 400 ? response.headers.get("location") : null; + if (!next) return response; + const target = new URL(next, location); + if (target.origin !== url.origin) throw new Error(`${url.href} redirects to another host (${target.origin}); use the final URL`); + location = target; + } + throw new Error(`${url.href}: too many redirects`); +} + function urlStore(url: string, cacheDir: string): Store { const parsed = new URL(url); if (parsed.protocol !== "https:" && parsed.protocol !== "http:") throw new Error(`${url}: only http and https avatar URLs are supported`); @@ -87,7 +107,7 @@ function urlStore(url: string, cacheDir: string): Store { const download = async (location: string, kind: "json" | "png") => { const request = new URL(location); if (!request.search) request.search = parsed.search; - const response = await fetch(request, { signal: AbortSignal.timeout(120_000) }); + const response = await fetchSameOrigin(request); if (response.status === 404) return undefined; if (!response.ok) throw new Error(`${location}: HTTP ${response.status}`); if (Number(response.headers.get("content-length") ?? 0) > MAX_DOWNLOAD) throw new Error(`${location}: larger than ${MAX_DOWNLOAD} bytes`); diff --git a/tests/avatar.test.ts b/tests/avatar.test.ts index a9e5a71..57f8eb8 100644 --- a/tests/avatar.test.ts +++ b/tests/avatar.test.ts @@ -21,6 +21,8 @@ beforeAll(async () => { const path = decodeURIComponent((req.url ?? "/").split("?")[0]); requests.push(path); queries.push((req.url ?? "").split("?")[1] ?? ""); + if (path === "/moved/avatar.json") return void res.writeHead(302, { location: "/ani/avatar.json" }).end(); + if (path === "/away/avatar.json") return void res.writeHead(302, { location: "https://example.com/avatar.json" }).end(); const override = overrides.get(path); if (override === null) return void res.writeHead(404).end(); if (override !== undefined) return void res.end(override); @@ -127,6 +129,15 @@ describe("loadAvatar", () => { await expect(loadAvatar(`${base}/ani/`, { cacheDir: join(cacheDir, "empty") })).rejects.toThrow("not a PNG"); }); + it("follows redirects only within the same origin", async () => { + fresh(); + // the manifest moved; its files resolve against the requested folder, so only the manifest is found + await expect(loadAvatar(`${base}/moved/`, { cacheDir })).rejects.toThrow("/moved/"); + expect(requests).toContain("/ani/avatar.json"); + fresh(); + await expect(loadAvatar(`${base}/away/`, { cacheDir })).rejects.toThrow("redirects to another host"); + }); + it("rejects what is not an avatar or not http(s)", async () => { fresh(); await expect(loadAvatar(`${base}/nothing/`, { cacheDir })).rejects.toThrow("is not an avatar");