Fix AniList URL guard

This commit is contained in:
2026-02-16 01:56:21 -08:00
parent faf82fa3ed
commit 107971f151
14 changed files with 1010 additions and 18 deletions

View File

@@ -0,0 +1,37 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
isAllowedAnilistExternalUrl,
isAllowedAnilistSetupNavigationUrl,
} from "./anilist-url-guard";
test("allows only AniList https URLs for external opens", () => {
assert.equal(isAllowedAnilistExternalUrl("https://anilist.co"), true);
assert.equal(
isAllowedAnilistExternalUrl("https://www.anilist.co/settings/developer"),
true,
);
assert.equal(isAllowedAnilistExternalUrl("http://anilist.co"), false);
assert.equal(isAllowedAnilistExternalUrl("https://example.com"), false);
assert.equal(isAllowedAnilistExternalUrl("file:///tmp/test"), false);
assert.equal(isAllowedAnilistExternalUrl("not a url"), false);
});
test("allows only AniList https or data URLs for setup navigation", () => {
assert.equal(
isAllowedAnilistSetupNavigationUrl("https://anilist.co/api/v2/oauth/authorize"),
true,
);
assert.equal(
isAllowedAnilistSetupNavigationUrl(
"data:text/html;charset=utf-8,%3Chtml%3E%3C%2Fhtml%3E",
),
true,
);
assert.equal(
isAllowedAnilistSetupNavigationUrl("https://example.com/redirect"),
false,
);
assert.equal(isAllowedAnilistSetupNavigationUrl("javascript:alert(1)"), false);
});

View File

@@ -0,0 +1,25 @@
const ANILIST_ALLOWED_HOSTS = new Set(["anilist.co", "www.anilist.co"]);
export function isAllowedAnilistExternalUrl(rawUrl: string): boolean {
try {
const parsedUrl = new URL(rawUrl);
return (
parsedUrl.protocol === "https:" &&
ANILIST_ALLOWED_HOSTS.has(parsedUrl.hostname.toLowerCase())
);
} catch {
return false;
}
}
export function isAllowedAnilistSetupNavigationUrl(rawUrl: string): boolean {
if (isAllowedAnilistExternalUrl(rawUrl)) {
return true;
}
try {
const parsedUrl = new URL(rawUrl);
return parsedUrl.protocol === "data:";
} catch {
return false;
}
}

View File

@@ -18,10 +18,19 @@ import type { RuntimeOptionsManager } from "../runtime-options";
import type { MecabTokenizer } from "../mecab-tokenizer";
import type { BaseWindowTracker } from "../window-trackers";
export interface AnilistSecretResolutionState {
status: "not_checked" | "resolved" | "error";
source: "none" | "literal" | "command";
message: string | null;
resolvedAt: number | null;
errorAt: number | null;
}
export interface AppState {
yomitanExt: Extension | null;
yomitanSettingsWindow: BrowserWindow | null;
yomitanParserWindow: BrowserWindow | null;
anilistSetupWindow: BrowserWindow | null;
yomitanParserReadyPromise: Promise<void> | null;
yomitanParserInitPromise: Promise<boolean> | null;
mpvClient: MpvIpcClient | null;
@@ -33,6 +42,7 @@ export interface AppState {
currentMediaPath: string | null;
currentMediaTitle: string | null;
pendingSubtitlePosition: SubtitlePosition | null;
anilistClientSecretState: AnilistSecretResolutionState;
mecabTokenizer: MecabTokenizer | null;
keybindings: Keybinding[];
subtitleTimingTracker: SubtitleTimingTracker | null;
@@ -57,6 +67,7 @@ export interface AppState {
texthookerOnlyMode: boolean;
jlptLevelLookup: (term: string) => JlptLevel | null;
frequencyRankLookup: FrequencyDictionaryLookup;
anilistSetupPageOpened: boolean;
}
export interface AppStateInitialValues {
@@ -81,6 +92,7 @@ export function createAppState(values: AppStateInitialValues): AppState {
yomitanExt: null,
yomitanSettingsWindow: null,
yomitanParserWindow: null,
anilistSetupWindow: null,
yomitanParserReadyPromise: null,
yomitanParserInitPromise: null,
mpvClient: null,
@@ -92,6 +104,13 @@ export function createAppState(values: AppStateInitialValues): AppState {
currentMediaPath: null,
currentMediaTitle: null,
pendingSubtitlePosition: null,
anilistClientSecretState: {
status: "not_checked",
source: "none",
message: null,
resolvedAt: null,
errorAt: null,
},
mecabTokenizer: null,
keybindings: [],
subtitleTimingTracker: null,
@@ -118,6 +137,7 @@ export function createAppState(values: AppStateInitialValues): AppState {
texthookerOnlyMode: values.texthookerOnlyMode ?? false,
jlptLevelLookup: () => null,
frequencyRankLookup: () => null,
anilistSetupPageOpened: false,
};
}