fix(anime): harden the extension bridge against untrusted repos and hangs

Addresses CodeRabbit review feedback on the Anime Browser:

- reject repository package/apk names that are not plain identifiers, and
  verify the install target resolves inside the extensions directory
- count mpv's %n% option escape in UTF-8 bytes, and escape backslashes in
  header values so a trailing one cannot eat the list separator
- key the bridge extension-id cache by APK content, so an in-place upgrade
  re-uploads instead of running the previous build
- bound every bridge, release-listing, and download request with a timeout
- enforce the APK size limit while streaming rather than after buffering
- read APK bytes on demand instead of holding a base64 copy per extension
  for the lifetime of the browser
- serialize preference mutations and write the file atomically
- handle the sidecar spawn error event, and wait for the child to exit in
  stop() before returning
- report a failed Anime Browser bootstrap instead of showing the starting
  banner forever
- keep the preferences panel's save confirmation and in-flight multi-select
  edits by re-rendering only on a structural schema change
This commit is contained in:
2026-07-31 17:57:01 -07:00
parent ec48f90cd4
commit a612d44535
19 changed files with 567 additions and 81 deletions
@@ -138,6 +138,49 @@ test('an oversized download is refused even when the length header lies', async
assert.equal(existsSync(path.join(dir, `${PKG}.apk`)), false);
});
test('the byte limit stops the read instead of buffering the whole body', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
let pushed = 0;
// Endless body: if the limit were only checked after buffering, this hangs.
const body = new ReadableStream<Uint8Array>({
pull(controller) {
pushed += 1;
controller.enqueue(new Uint8Array(512));
},
});
const fetchImpl = (async () => new Response(body, { status: 200 })) as typeof fetch;
await assert.rejects(
() =>
installExtension({
extensionsDir: dir,
extension: repoExtension(),
fetchImpl,
maxBytes: 1024,
}),
/larger than the 1024 byte limit/,
);
// Only enough chunks to cross the limit were ever read.
assert.ok(pushed <= 4, `read ${pushed} chunks before aborting`);
});
test('a package name carrying path separators cannot escape the extensions dir', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
const dir = path.join(root, 'extensions');
const escaping = `eu.kanade.tachiyomi.animeextension${path.sep}..${path.sep}..${path.sep}pwned`;
await assert.rejects(
() =>
installExtension({
extensionsDir: dir,
extension: repoExtension({ pkg: escaping }),
fetchImpl: respondWith(apkBytes()),
}),
/not a valid file name/,
);
assert.equal(existsSync(path.join(root, 'pwned.apk')), false);
});
test('removeExtension deletes the file and tolerates a missing one', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
const file = path.join(dir, `${PKG}.apk`);