Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
22 changes: 21 additions & 1 deletion src/avatar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response> {
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`);
Expand All @@ -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`);
Expand Down
11 changes: 11 additions & 0 deletions tests/avatar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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");
Expand Down
Loading