fix: harden extension security and plugin reliability

Address audit findings across background handlers, openers,
plugins, and UI: URL allowlists, XSS reductions, popup lifecycle
fixes, plugin dispose/cleanup, cloud sync hardening, global search
mathjs sandbox, and settings storage fixes.
This commit is contained in:
2026-06-17 10:50:26 +09:30
parent 0e696e0175
commit 8a5424c5a4
70 changed files with 1229 additions and 430 deletions
+1 -1
View File
@@ -92,7 +92,7 @@
bind:this={background}
class="flex absolute top-0 left-0 z-50 justify-center items-center w-full h-full cursor-pointer bg-black/50"
onclick={handleBackgroundClick}
onkeydown={(e) => { if (e.key === "Enter") handleBackgroundClick; }}
onkeydown={(e) => { if (e.key === "Enter") handleBackgroundClick(e as unknown as MouseEvent) }}
>
<div
bind:this={content}
@@ -22,15 +22,13 @@
});
async function upload() {
const token = await cloudAuth.getStoredToken();
if (!token) return;
if (!cloudState.isLoggedIn) return;
busy = true;
statusError = null;
statusMessage = null;
try {
const res = (await browser.runtime.sendMessage({
type: "cloudSettingsUpload",
token,
})) as { success?: boolean; error?: string };
if (res?.success) {
statusMessage = "Settings uploaded.";
@@ -49,15 +47,13 @@
}
async function confirmDownload() {
const token = await cloudAuth.getStoredToken();
if (!token) return;
if (!cloudState.isLoggedIn) return;
busy = true;
statusError = null;
statusMessage = null;
try {
const res = (await browser.runtime.sendMessage({
type: "cloudSettingsDownload",
token,
})) as { success?: boolean; error?: string; notFound?: boolean };
if (res?.success) {
statusMessage = "Settings restored.";
+3 -1
View File
@@ -19,6 +19,7 @@
let editor = $state<HTMLDivElement | null>(null)
let view: EditorView | null = null;
let unsubSettings: (() => void) | undefined;
let editorTheme = new Compartment();
let { value, onChange, className } = $props<{value: string, onChange: (value: string) => void, className?: string}>()
@@ -73,7 +74,7 @@
view = createEditorView(state, editor as HTMLElement);
}
settingsState.subscribe((settings) => {
unsubSettings = settingsState.subscribe((settings) => {
if (view) {
view.dispatch({
effects: editorTheme.reconfigure(
@@ -85,6 +86,7 @@
});
onDestroy(() => {
unsubSettings?.();
if (view) {
view.destroy();
}
+1 -1
View File
@@ -90,7 +90,7 @@
bind:this={background}
class="flex absolute top-0 left-0 z-50 justify-center items-center w-full h-full shadow-2xl cursor-pointer bg-black/20 border border-[#DDDDDD]/30 dark:border-[#38373D]/30"
onclick={handleBackgroundClick}
onkeydown={(e) => { e.key === 'Enter' && handleBackgroundClick }}
onkeydown={(e) => { if (e.key === 'Enter') handleBackgroundClick(e as unknown as MouseEvent) }}
>
<div
bind:this={content}
+14 -8
View File
@@ -78,7 +78,7 @@
const startRecording = () => {
isRecording = true;
recordedKeys.clear();
recordedKeys = new Set();
inputElement?.focus();
};
@@ -87,7 +87,7 @@
if (recordedKeys.has('esc')) {
onChange('');
isRecording = false;
recordedKeys.clear();
recordedKeys = new Set();
inputElement?.blur();
return;
}
@@ -113,10 +113,16 @@
}
isRecording = false;
recordedKeys.clear();
recordedKeys = new Set();
inputElement?.blur();
};
const addRecordedKey = (key: string) => {
const next = new Set(recordedKeys);
next.add(key);
recordedKeys = next;
};
const handleKeyDown = (e: KeyboardEvent) => {
if (!isRecording) return;
@@ -126,14 +132,14 @@
const key = formatKeyForHotkey(e.key);
// Add modifiers
if (e.ctrlKey) recordedKeys.add('ctrl');
if (e.metaKey) recordedKeys.add('cmd');
if (e.altKey) recordedKeys.add('alt');
if (e.shiftKey) recordedKeys.add('shift');
if (e.ctrlKey) addRecordedKey('ctrl');
if (e.metaKey) addRecordedKey('cmd');
if (e.altKey) addRecordedKey('alt');
if (e.shiftKey) addRecordedKey('shift');
// Add the main key (ignore modifier keys themselves)
if (!['ctrl', 'cmd', 'alt', 'shift'].includes(key)) {
recordedKeys.add(key);
addRecordedKey(key);
}
// Auto-stop recording if we have a main key
+1 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts">
let { state, onChange, min = 0, max = 100, step = 1 } = $props<{
let { state = $bindable(), onChange, min = 0, max = 100, step = 1 } = $props<{
state: number,
onChange: (value: number) => void,
min?: number,
@@ -39,7 +39,7 @@
</script>
<div class="flex flex-col h-full">
<div class="top-0 z-10 text-[0.875rem] pb-0.5 mx-4 px-2 tab-width-container">
<div class="top-0 z-10 text-[0.875rem] pb-0.5 mx-4 px-2 tab-width-container" role="tablist">
<div bind:this={containerRef} class="flex relative">
<MotionDiv
class="absolute top-0 left-0 z-0 h-full bg-gradient-to-tr dark:from-[#38373D]/80 dark:to-[#38373D] from-[#DDDDDD]/80 to-[#DDDDDD] rounded-full opacity-40 tab-width"
@@ -48,6 +48,8 @@
/>
{#each tabs as { title }, index}
<button
role="tab"
aria-selected={activeTab === index}
class="relative z-10 flex-1 px-4 py-2 focus-visible:outline-none"
onclick={() => activeTab = index}
>
@@ -64,7 +66,10 @@
>
<div class="flex">
{#each tabs as { Content, props }, index}
<div class="absolute focus:outline-none w-full pt-2 transition-opacity duration-300 overflow-y-scroll no-scrollbar pb-2 h-full tab {activeTab === index ? 'opacity-100 active' : 'opacity-0'}"
<div
role="tabpanel"
aria-hidden={activeTab !== index}
class="absolute focus:outline-none w-full pt-2 transition-opacity duration-300 overflow-y-scroll no-scrollbar pb-2 h-full tab {activeTab === index ? 'opacity-100 active' : 'opacity-0'}"
style="left: {index * 100}%;">
<div style="left: {index * 100}%;" class="fixed top-0 w-full h-8 bg-gradient-to-b to-transparent pointer-events-none z-[100] from-white dark:from-zinc-800 dark:to-transparent"></div>
<Content {...props} />
@@ -12,18 +12,18 @@
<div
onclick={onClick}
onkeydown={onClick}
tabindex="-1"
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') onClick() }}
tabindex="0"
role="button"
class="relative w-16 h-16 cursor-pointer rounded-xl transition ring-3 dark:ring-zinc-500/50 ring-zinc-300 {isEditMode ? 'animate-shake' : ''} {isSelected ? 'dark:ring-4 ring-4' : 'ring-0'}"
>
{#if isEditMode}
<div
tabindex="-1"
tabindex="0"
role="button"
class="absolute top-0 right-0 z-10 flex w-6 h-6 p-2 text-white translate-x-1/2 -translate-y-1/2 bg-red-600 rounded-full place-items-center"
onclick={onDelete}
onkeydown={onDelete}
onclick={(e) => { e.stopPropagation(); onDelete() }}
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.stopPropagation(); onDelete() } }}
>
<div class="w-4 h-0.5 bg-white"></div>
</div>
@@ -174,18 +174,19 @@
if (parentElement) {
observer = new MutationObserver(checkActiveClass);
observer.observe(parentElement, { attributes: true, attributeFilter: ['class'] });
return () => {
observer.disconnect();
backgroundUpdates.removeListener(syncBackgrounds);
};
}
return () => {
observer?.disconnect();
backgroundUpdates.removeListener(syncBackgrounds);
};
});
onDestroy(() => {
if (observer) {
observer.disconnect();
}
observer?.disconnect();
backgrounds.forEach((bg) => {
if (bg.url) URL.revokeObjectURL(bg.url);
});
});
</script>
@@ -21,11 +21,14 @@
let prevLoggedIn = $state(false);
let showSignInModal = $state(false);
cloudAuth.subscribe((s) => {
const now = s.isLoggedIn;
if (now && !prevLoggedIn && themes) void fetchThemes();
prevLoggedIn = now;
cloudLoggedIn = now;
$effect(() => {
const unsub = cloudAuth.subscribe((s) => {
const now = s.isLoggedIn;
if (now && !prevLoggedIn && themes) void fetchThemes();
prevLoggedIn = now;
cloudLoggedIn = now;
});
return unsub;
});
const handleThemeClick = async (theme: CustomTheme, e: MouseEvent) => {
@@ -102,17 +105,14 @@
selectedTheme: themeManager.getSelectedThemeId() || '',
}
if (themes && cloudLoggedIn) {
const token = await cloudAuth.getStoredToken();
if (token) {
const status: Record<string, boolean> = {};
await Promise.all(
themes.themes.map(async (t) => {
try {
const res = (await browser.runtime.sendMessage({
type: 'fetchThemeDetails',
themeId: t.id,
token,
})) as { success?: boolean; data?: { theme?: { is_favorited?: boolean } } };
const status: Record<string, boolean> = {};
await Promise.all(
themes.themes.map(async (t) => {
try {
const res = (await browser.runtime.sendMessage({
type: 'fetchThemeDetails',
themeId: t.id,
})) as { success?: boolean; data?: { theme?: { is_favorited?: boolean } } };
if (res?.success && res?.data?.theme) {
status[t.id] = !!res.data.theme.is_favorited;
}
@@ -122,7 +122,6 @@
})
);
favoriteStatus = status;
}
} else {
favoriteStatus = {};
}
@@ -134,13 +133,10 @@
showSignInModal = true;
return;
}
const token = await cloudAuth.getStoredToken();
if (!token) return;
const isFavorite = !favoriteStatus[theme.id];
const result = (await browser.runtime.sendMessage({
type: 'cloudFavorite',
themeId: theme.id,
token,
action: isFavorite ? 'favorite' : 'unfavorite',
})) as { success?: boolean };
if (result?.success) {
@@ -1,21 +1,25 @@
<script lang="ts">
import React from "react";
import ReactDOM from "react-dom";
import { onDestroy, onMount } from "svelte";
import { onDestroy } from "svelte";
const e = React.createElement;
let container: HTMLDivElement;
let adapterProps = $props();
let container = $state<HTMLDivElement | null>(null);
onMount(() => {
const { el, children, class: _, ...props } = $$props;
$effect(() => {
if (!container) return;
const { el, children, class: className, ...rest } = adapterProps;
try {
ReactDOM.render(e(el, props, children), container);
ReactDOM.render(e(el, rest, children), container);
} catch (err) {
console.warn(`react-adapter failed to mount.`, { err });
}
});
onDestroy(() => {
if (!container) return;
try {
ReactDOM.unmountComponentAtNode(container);
} catch (err) {
@@ -24,4 +28,4 @@
});
</script>
<div bind:this={container} class={$$props.class}></div>
<div bind:this={container} class={adapterProps.class}></div>
+12 -6
View File
@@ -6,7 +6,7 @@
import browser from "webextension-polyfill";
import { standalone as StandaloneStore } from "../utils/standalone.svelte";
import { onMount } from "svelte";
import { onMount, onDestroy } from "svelte";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup";
@@ -108,12 +108,14 @@
showDisclaimerModal = true;
};
const closePopupsOnSettingsClose = () => {
showColourPicker = false;
showFontPicker = false;
showCloudPanel = false;
};
onMount(() => {
settingsPopup.addListener(() => {
showColourPicker = false;
showFontPicker = false;
showCloudPanel = false;
});
settingsPopup.addListener(closePopupsOnSettingsClose);
if (standalone) {
StandaloneStore.setStandalone(true);
@@ -125,6 +127,10 @@
});
}
});
onDestroy(() => {
settingsPopup.removeListener(closePopupsOnSettingsClose);
});
</script>
<div
+4 -3
View File
@@ -19,6 +19,7 @@
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup"
import { getSnapshotForUpload } from "@/seqta/utils/cloudSettingsSync"
import { getStoredOverride, setApiBase } from "@/seqta/utils/DevApiBase"
import { onMount } from "svelte"
let devApiBaseInput = $state<string>(getStoredOverride() ?? "")
let devApiBaseActive = $state<string | null>(getStoredOverride())
@@ -128,9 +129,9 @@
await browser.storage.local.set({ [storageKey]: currentSettings });
}
$effect(() => {
loadPluginSettings();
})
onMount(() => {
void loadPluginSettings();
});
const { showColourPicker, showFontPicker, showDisclaimer, showCloudPanel } = $props<{
showColourPicker: () => void;
+5 -6
View File
@@ -23,7 +23,10 @@
const themeManager = ThemeManager.getInstance();
let cloudLoggedIn = $state(cloudAuth.state.isLoggedIn);
cloudAuth.subscribe((s) => { cloudLoggedIn = s.isLoggedIn; });
$effect(() => {
const unsub = cloudAuth.subscribe((s) => { cloudLoggedIn = s.isLoggedIn; });
return unsub;
});
// State variables
let searchTerm = $state('');
@@ -86,13 +89,11 @@
}
const toggleFavorite = async (theme: Theme) => {
const token = await cloudAuth.getStoredToken();
if (!token) return;
if (!cloudLoggedIn) return;
const isFavorite = !theme.is_favorited;
const result = (await browser.runtime.sendMessage({
type: 'cloudFavorite',
themeId: theme.id,
token,
action: isFavorite ? 'favorite' : 'unfavorite',
})) as { success?: boolean };
if (result?.success) {
@@ -119,14 +120,12 @@
error = null;
}
try {
const token = await cloudAuth.getStoredToken();
const data = await sendMessageWithTimeout<{
success?: boolean;
data?: { themes: unknown[] };
error?: string;
}>({
type: 'fetchThemes',
token: token ?? undefined,
});
if (!data?.success || !Array.isArray(data?.data?.themes)) {
throw new Error(data?.error || 'Failed to fetch themes');