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
+15 -17
View File
@@ -1,7 +1,6 @@
import browser from "webextension-polyfill";
import { clearCloudPfpCache } from "@/seqta/utils/cloudPfpCache";
import { clearLastUploadedSnapshot } from "@/seqta/utils/cloudSettingsSync";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
const REDIRECT_URI = "https://accounts.betterseqta.org/auth/bsplus/callback";
@@ -87,11 +86,10 @@ class CloudAuthService {
}
/** Pull cloud settings backup after a fresh sign-in (matches manual “Download from cloud”). */
private triggerCloudSettingsDownloadAfterLogin(accessToken: string): void {
private triggerCloudSettingsDownloadAfterLogin(): void {
void browser.runtime
.sendMessage({
type: "cloudSettingsDownload",
token: accessToken,
})
.then((res: unknown) => {
const r = res as { success?: boolean; notFound?: boolean; error?: string } | undefined;
@@ -112,7 +110,6 @@ class CloudAuthService {
/** Persist an updated user object (e.g. after cloud profile picture sync). */
public async setUser(user: CloudUser | null): Promise<void> {
(settingsState as any).setKey(STORAGE_KEYS.user, user);
await browser.storage.local.set({ [STORAGE_KEYS.user]: user });
this._state = {
isLoggedIn: this._state.isLoggedIn,
@@ -122,11 +119,8 @@ class CloudAuthService {
}
private async getClientId(): Promise<string> {
let clientId = (settingsState as any)[STORAGE_KEYS.clientId] as string | undefined;
if (!clientId) {
const stored = await browser.storage.local.get(STORAGE_KEYS.clientId);
clientId = stored[STORAGE_KEYS.clientId] as string | undefined;
}
const stored = await browser.storage.local.get(STORAGE_KEYS.clientId);
let clientId = stored[STORAGE_KEYS.clientId] as string | undefined;
if (!clientId) {
const reserveResult = (await browser.runtime.sendMessage({
type: "cloudReserveClient",
@@ -136,7 +130,7 @@ class CloudAuthService {
throw new Error(reserveResult?.error ?? "Failed to reserve client");
}
clientId = reserveResult.client_id;
(settingsState as any).setKey(STORAGE_KEYS.clientId, clientId);
await browser.storage.local.set({ [STORAGE_KEYS.clientId]: clientId });
}
return clientId;
}
@@ -180,15 +174,17 @@ class CloudAuthService {
error?: string;
};
if (result?.access_token && result?.refresh_token) {
(settingsState as any).setKey(STORAGE_KEYS.accessToken, result.access_token);
(settingsState as any).setKey(STORAGE_KEYS.refreshToken, result.refresh_token);
(settingsState as any).setKey(STORAGE_KEYS.user, result.user ?? null);
await browser.storage.local.set({
[STORAGE_KEYS.accessToken]: result.access_token,
[STORAGE_KEYS.refreshToken]: result.refresh_token,
[STORAGE_KEYS.user]: result.user ?? null,
});
this._state = {
isLoggedIn: true,
user: result.user ?? null,
};
this.notify();
this.triggerCloudSettingsDownloadAfterLogin(result.access_token);
this.triggerCloudSettingsDownloadAfterLogin();
return { success: true };
}
return {
@@ -239,9 +235,11 @@ class CloudAuthService {
};
if (refreshResult?.access_token && refreshResult?.refresh_token) {
(settingsState as any).setKey(STORAGE_KEYS.accessToken, refreshResult.access_token);
(settingsState as any).setKey(STORAGE_KEYS.refreshToken, refreshResult.refresh_token);
(settingsState as any).setKey(STORAGE_KEYS.user, refreshResult.user ?? null);
await browser.storage.local.set({
[STORAGE_KEYS.accessToken]: refreshResult.access_token,
[STORAGE_KEYS.refreshToken]: refreshResult.refresh_token,
[STORAGE_KEYS.user]: refreshResult.user ?? null,
});
this._state = {
isLoggedIn: true,
user: refreshResult.user ?? null,
@@ -1,13 +1,28 @@
import stringToHTML from "../stringToHTML";
function isSafeShortcutHref(url: string): boolean {
if (typeof url !== "string" || !url.trim()) return false;
try {
const parsed = new URL(url, window.location.href);
return ["http:", "https:", "mailto:"].includes(parsed.protocol);
} catch {
return false;
}
}
export function CreateCustomShortcutDiv(element: any) {
// Creates the stucture and element information for each seperate shortcut
const container = document.getElementById("shortcuts");
if (!container) return;
var shortcut = document.createElement("a");
shortcut.setAttribute("href", element.url);
shortcut.setAttribute("target", "_blank");
if (isSafeShortcutHref(element.url)) {
shortcut.setAttribute("href", element.url);
shortcut.setAttribute("target", "_blank");
} else {
shortcut.setAttribute("href", "#");
shortcut.setAttribute("aria-disabled", "true");
}
var shortcutdiv = document.createElement("div");
shortcutdiv.classList.add("shortcut");
shortcutdiv.classList.add("customshortcut");
+1
View File
@@ -52,6 +52,7 @@ export function OpenAboutPage() {
</a>
<a class="socials" href="https://www.youtube.com/@BetterSEQTAPlus" style="background: none !important; margin: 0 5px; padding: 0; display: flex; align-items: center;">
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50" width="50px" height="50px"><path d="M 44.898438 14.5 C 44.5 12.300781 42.601563 10.699219 40.398438 10.199219 C 37.101563 9.5 31 9 24.398438 9 C 17.800781 9 11.601563 9.5 8.300781 10.199219 C 6.101563 10.699219 4.199219 12.199219 3.800781 14.5 C 3.398438 17 3 20.5 3 25 C 3 29.5 3.398438 33 3.898438 35.5 C 4.300781 37.699219 6.199219 39.300781 8.398438 39.800781 C 11.898438 40.5 17.898438 41 24.5 41 C 31.101563 41 37.101563 40.5 40.601563 39.800781 C 42.800781 39.300781 44.699219 37.800781 45.101563 35.5 C 45.5 33 46 29.398438 46.101563 25 C 45.898438 20.5 45.398438 17 44.898438 14.5 Z M 19 32 L 19 18 L 31.199219 25 Z"/></svg>
</a>
<a class="socials" href="https://chromewebstore.google.com/detail/betterseqta+/afdgaoaclhkhemfkkkonemoapeinchel" style="background: none !important; margin: 0 5px; padding: 0; display: flex; align-items: center;">
<svg style="width:25px; height:25px; vertical-align: middle;" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,20L15.46,14H15.45C15.79,13.4 16,12.73 16,12C16,10.8 15.46,9.73 14.62,9H19.41C19.79,9.93 20,10.94 20,12A8,8 0 0,1 12,20M4,12C4,10.54 4.39,9.18 5.07,8L8.54,14H8.55C9.24,15.19 10.5,16 12,16C12.45,16 12.88,15.91 13.29,15.77L10.89,19.91C7,19.37 4,16.04 4,12M15,12A3,3 0 0,1 12,15A3,3 0 0,1 9,12A3,3 0 0,1 12,9A3,3 0 0,1 15,12M12,4C14.96,4 17.54,5.61 18.92,8H12C10.06,8 8.45,9.38 8.08,11.21L5.7,7.08C7.16,5.21 9.44,4 12,4M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" />
@@ -61,11 +61,12 @@ export function showBsCloudAutoSyncAnnouncement(onDismissed?: () => void) {
</div>
`).firstChild as HTMLElement;
settingsState.bsCloudAutoSyncAnnouncementShown = true;
openPopup({
header,
content: [imageContainer, text],
afterClose: onDismissed,
afterClose: () => {
settingsState.bsCloudAutoSyncAnnouncementShown = true;
onDismissed?.();
},
});
}
+25 -9
View File
@@ -5,7 +5,18 @@ import Sortable from "sortablejs";
export let MenuOptionsOpen = false;
function escapeHtmlAttr(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
export function OpenMenuOptions() {
if (MenuOptionsOpen) return;
var container = document.getElementById("container");
var menu = document.getElementById("menu");
@@ -23,9 +34,9 @@ export function OpenMenuOptions() {
for (let i = 0; i < childnodes.length; i++) {
const element = childnodes[i];
if (
!settingsState.defaultmenuorder.indexOf(
settingsState.defaultmenuorder.indexOf(
(element as HTMLElement).dataset.key,
)
) === -1
) {
let newdefaultmenuorder = settingsState.defaultmenuorder;
newdefaultmenuorder.push((element as HTMLElement).dataset.key);
@@ -53,7 +64,7 @@ export function OpenMenuOptions() {
var savebutton = document.createElement("div");
savebutton.classList.add("editmenuoption");
savebutton.innerText = "Save";
savebutton.id = "restoredefaultoption";
savebutton.id = "savemenuoption";
menusettings.appendChild(defaultbutton);
menusettings.appendChild(savebutton);
@@ -71,15 +82,18 @@ export function OpenMenuOptions() {
(element.firstChild as HTMLElement).classList.remove("noscroll");
}
const menuKey = escapeHtmlAttr((element as HTMLElement).dataset.key ?? "");
let MenuItemToggle = stringToHTML(
`<div class="onoffswitch" style="margin: auto 0;"><input class="onoffswitch-checkbox notification menuitem" type="checkbox" id="${(element as HTMLElement).dataset.key}"><label for="${(element as HTMLElement).dataset.key}" class="onoffswitch-label"></label>`,
`<div class="onoffswitch" style="margin: auto 0;"><input class="onoffswitch-checkbox notification menuitem" type="checkbox" id="${menuKey}"><label for="${menuKey}" class="onoffswitch-label"></label>`,
).firstChild;
(element as HTMLElement).append(MenuItemToggle!);
if (!element.dataset.betterseqta) {
const a = document.createElement("section");
a.innerHTML = element.innerHTML;
cloneAttributes(a, element);
while (element.firstChild) {
a.appendChild(element.firstChild);
}
menu!.firstChild!.insertBefore(a, element);
element.remove();
}
@@ -109,12 +123,12 @@ export function OpenMenuOptions() {
} else {
(buttons[i] as HTMLInputElement).checked = true;
}
(buttons[i] as HTMLInputElement).checked = true;
}
let sortable: Sortable | undefined;
try {
var el = document.querySelector("#menu > ul");
var sortable = Sortable.create(el as HTMLElement, {
sortable = Sortable.create(el as HTMLElement, {
draggable: ".draggable",
dataIdAttr: "data-key",
animation: 150,
@@ -178,8 +192,10 @@ export function OpenMenuOptions() {
if (!element.dataset.betterseqta) {
const a = document.createElement("li");
a.innerHTML = element.innerHTML;
cloneAttributes(a, element);
while (element.firstChild) {
a.appendChild(element.firstChild);
}
menu!.firstChild!.insertBefore(a, element);
element.remove();
}
@@ -209,7 +225,7 @@ export function OpenMenuOptions() {
"important",
);
}
saveNewOrder(sortable);
if (sortable) saveNewOrder(sortable);
});
}
@@ -105,6 +105,7 @@ export function OpenMinecraftServerPopup() {
</a>
<a class="socials" href="https://www.youtube.com/@BetterSEQTAPlus" style="background: none !important; margin: 0 5px; padding: 0; display: flex; align-items: center;">
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50" width="50px" height="50px"><path d="M 44.898438 14.5 C 44.5 12.300781 42.601563 10.699219 40.398438 10.199219 C 37.101563 9.5 31 9 24.398438 9 C 17.800781 9 11.601563 9.5 8.300781 10.199219 C 6.101563 10.699219 4.199219 12.199219 3.800781 14.5 C 3.398438 17 3 20.5 3 25 C 3 29.5 3.398438 33 3.898438 35.5 C 4.300781 37.699219 6.199219 39.300781 8.398438 39.800781 C 11.898438 40.5 17.898438 41 24.5 41 C 31.101563 41 37.101563 40.5 40.601563 39.800781 C 42.800781 39.300781 44.699219 37.800781 45.101563 35.5 C 45.5 33 46 29.398438 46.101563 25 C 45.898438 20.5 45.398438 17 44.898438 14.5 Z M 19 32 L 19 18 L 31.199219 25 Z"/></svg>
</a>
<a class="socials" href="https://chromewebstore.google.com/detail/betterseqta+/afdgaoaclhkhemfkkkonemoapeinchel" style="background: none !important; margin: 0 5px; padding: 0; display: flex; align-items: center;">
<svg style="width:25px; height:25px; vertical-align: middle;" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,20L15.46,14H15.45C15.79,13.4 16,12.73 16,12C16,10.8 15.46,9.73 14.62,9H19.41C19.79,9.93 20,10.94 20,12A8,8 0 0,1 12,20M4,12C4,10.54 4.39,9.18 5.07,8L8.54,14H8.55C9.24,15.19 10.5,16 12,16C12.45,16 12.88,15.91 13.29,15.77L10.89,19.91C7,19.37 4,16.04 4,12M15,12A3,3 0 0,1 12,15A3,3 0 0,1 9,12A3,3 0 0,1 12,9A3,3 0 0,1 15,12M12,4C14.96,4 17.54,5.61 18.92,8H12C10.06,8 8.45,9.38 8.08,11.21L5.7,7.08C7.16,5.21 9.44,4 12,4M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" />
@@ -62,12 +62,13 @@ export function showPrivacyNotification(onDismissed?: () => void) {
attachPopupMediaFullscreenIfPresent(text, "img.aboutImg");
settingsState.privacyStatementLastUpdated = "2025-12-20";
settingsState.privacyStatementShown = true;
openPopup({
header,
content: [text],
afterClose: onDismissed,
afterClose: () => {
settingsState.privacyStatementLastUpdated = "2025-12-20";
settingsState.privacyStatementShown = true;
onDismissed?.();
},
});
}
@@ -31,12 +31,12 @@ export function OpenPrivacyStatement() {
<p>BetterSEQTA+ uses your browser's local storage to save your preferences and settings. This data remains on your device and is never transmitted anywhere. You can clear this data at any time through your browser's settings.</p>
<h3>Open Source</h3>
<p>BetterSEQTA+ is an open-source project. You can review our code on <a href="https://github.com/BetterSEQTA/BetterSEQTA-Plus" target="_blank" style="color: inherit; text-decoration: underline;">GitHub</a> to verify our privacy practices. We believe in transparency and encourage you to inspect the code yourself.</p>
<p>BetterSEQTA+ is an open-source project. You can review our code on <a href="https://github.com/BetterSEQTA/BetterSEQTA-Plus" target="_blank" rel="noopener noreferrer" style="color: inherit; text-decoration: underline;">GitHub</a> to verify our privacy practices. We believe in transparency and encourage you to inspect the code yourself.</p>
<h3>Our Commitment</h3>
<p>We are committed to providing the best features possible while respecting your privacy. We understand that schools and students have concerns about data privacy, and we want to assure you that BetterSEQTA+ is designed with privacy as a core principle.</p>
<p style="margin-top: 20px; font-weight: bold;">If you have any questions or concerns about our privacy practices, please reach out to us through our <a href="https://github.com/BetterSEQTA/BetterSEQTA-Plus" target="_blank" style="color: inherit; text-decoration: underline;">GitHub repository</a>.</p>
<p style="margin-top: 20px; font-weight: bold;">If you have any questions or concerns about our privacy practices, please reach out to us through our <a href="https://github.com/BetterSEQTA/BetterSEQTA-Plus" target="_blank" rel="noopener noreferrer" style="color: inherit; text-decoration: underline;">GitHub repository</a>.</p>
</div>
`).firstChild as HTMLElement;
@@ -4,13 +4,13 @@ import { settingsState } from "../listeners/SettingsState";
import { closePopup } from "./PopupManager";
import { getApiBase } from "../DevApiBase";
import { openThemeStoreWithHighlight } from "../openThemeStoreWithHighlight";
import { cloudAuth } from "../CloudAuth";
import type { Theme } from "@/interface/types/Theme";
import {
buildModalHeroSlides,
normalizeStoreTheme,
} from "@/interface/utils/themeStoreFlavours";
import { attachPopupMediaFullscreen } from "./attachPopupMediaFullscreen";
import { allowedPopupImageUrl } from "./allowedPopupImageUrl";
export interface ThemeOfTheMonthEntry {
id: string;
@@ -67,17 +67,15 @@ function heroUrlFromStoreTheme(theme: {
coverImage?: string | null;
}): string | null {
const url = (theme.marqueeImage || theme.coverImage || "").trim();
return url || null;
return allowedPopupImageUrl(url);
}
export async function fetchThemeStoreTheme(themeId: string): Promise<Theme | null> {
try {
const token = await cloudAuth.getStoredToken();
const res = (await browser.runtime.sendMessage({
type: "fetchThemeDetails",
themeId,
token: token ?? undefined,
})) as { success?: boolean; data?: { theme?: Record<string, unknown> } };
try {
const res = (await browser.runtime.sendMessage({
type: "fetchThemeDetails",
themeId,
})) as { success?: boolean; data?: { theme?: Record<string, unknown> } };
if (!res?.success || !res?.data?.theme) return null;
return normalizeStoreTheme(res.data.theme);
@@ -100,7 +98,12 @@ function buildPopupGallerySlides(
heroUrl: string | null,
): PopupGallerySlide[] {
if (storeTheme) {
return buildModalHeroSlides(storeTheme).filter((s) => s.imageUrl.trim());
return buildModalHeroSlides(storeTheme)
.map((s) => {
const imageUrl = allowedPopupImageUrl(s.imageUrl);
return imageUrl ? { imageUrl, caption: s.caption } : null;
})
.filter((s): s is PopupGallerySlide => s !== null);
}
if (heroUrl) {
return [{ imageUrl: heroUrl, caption: entry.title }];
@@ -642,7 +645,7 @@ export async function OpenThemeOfTheMonthPopup(
const storeTheme = linkedThemeId ? await fetchThemeStoreTheme(linkedThemeId) : null;
const heroUrl =
(storeTheme ? heroUrlFromStoreTheme(storeTheme) : null) ??
entry.cover_image?.trim() ??
allowedPopupImageUrl(entry.cover_image) ??
null;
const gallerySlides = buildPopupGallerySlides(entry, storeTheme, heroUrl);
const hasExpandableContent = gallerySlides.length > 0 || entry.description.trim().length > 0;
+2 -1
View File
@@ -396,6 +396,7 @@ export function OpenWhatsNewPopup(onDismissed?: () => void) {
</a>
<a class="socials" href="https://www.youtube.com/@BetterSEQTAPlus" style="background: none !important; margin: 0 5px; padding: 0; display: flex; align-items: center;">
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50" width="50px" height="50px"><path d="M 44.898438 14.5 C 44.5 12.300781 42.601563 10.699219 40.398438 10.199219 C 37.101563 9.5 31 9 24.398438 9 C 17.800781 9 11.601563 9.5 8.300781 10.199219 C 6.101563 10.699219 4.199219 12.199219 3.800781 14.5 C 3.398438 17 3 20.5 3 25 C 3 29.5 3.398438 33 3.898438 35.5 C 4.300781 37.699219 6.199219 39.300781 8.398438 39.800781 C 11.898438 40.5 17.898438 41 24.5 41 C 31.101563 41 37.101563 40.5 40.601563 39.800781 C 42.800781 39.300781 44.699219 37.800781 45.101563 35.5 C 45.5 33 46 29.398438 46.101563 25 C 45.898438 20.5 45.398438 17 44.898438 14.5 Z M 19 32 L 19 18 L 31.199219 25 Z"/></svg>
</a>
<a class="socials" href="https://chromewebstore.google.com/detail/betterseqta+/afdgaoaclhkhemfkkkonemoapeinchel" style="background: none !important; margin: 0 5px; padding: 0; display: flex; align-items: center;">
<svg style="width:25px; height:25px; vertical-align: middle;" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,20L15.46,14H15.45C15.79,13.4 16,12.73 16,12C16,10.8 15.46,9.73 14.62,9H19.41C19.79,9.93 20,10.94 20,12A8,8 0 0,1 12,20M4,12C4,10.54 4.39,9.18 5.07,8L8.54,14H8.55C9.24,15.19 10.5,16 12,16C12.45,16 12.88,15.91 13.29,15.77L10.89,19.91C7,19.37 4,16.04 4,12M15,12A3,3 0 0,1 12,15A3,3 0 0,1 9,12A3,3 0 0,1 12,9A3,3 0 0,1 15,12M12,4C14.96,4 17.54,5.61 18.92,8H12C10.06,8 8.45,9.38 8.08,11.21L5.7,7.08C7.16,5.21 9.44,4 12,4M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" />
@@ -403,7 +404,7 @@ export function OpenWhatsNewPopup(onDismissed?: () => void) {
</a>
</div>
<div>
<a href="https://ko-fi.com/sethburkart" target="_blank" style="background: none !important; margin:0;margin-left:6px;padding:0; display: flex; align-items: center;">
<a href="https://ko-fi.com/sethburkart" target="_blank" rel="noopener noreferrer" style="background: none !important; margin:0;margin-left:6px;padding:0; display: flex; align-items: center;">
<img height="25" style="border:0px; height:25px; margin-right: -6px;" src="${kofi}" border="0" alt="Buy Me a Coffee at ko-fi.com" />
</a>
</div>
+18 -2
View File
@@ -57,6 +57,15 @@ interface OpenPopupOptions {
containerClass?: string;
}
function chainAfterClose(next?: () => void) {
if (!next) return;
const previous = pendingAfterClose;
pendingAfterClose = () => {
next();
previous?.();
};
}
export function openPopup({
header,
content = [],
@@ -65,7 +74,12 @@ export function openPopup({
clearJustUpdated = false,
containerClass,
}: OpenPopupOptions = {}) {
pendingAfterClose = afterClose;
if (document.getElementById("whatsnewbk")) {
chainAfterClose(afterClose);
return;
}
chainAfterClose(afterClose);
const background = document.createElement("div");
background.id = "whatsnewbk";
@@ -87,7 +101,9 @@ export function openPopup({
container.append(closeButton);
background.append(container);
document.getElementById("container")!.append(background);
const appContainer = document.getElementById("container");
if (!appContainer) return;
appContainer.append(background);
if (settingsState.animations) {
(motionAnimate as any)(
@@ -0,0 +1,16 @@
/**
* Minimal allowlist for remote popup/API image (and media) URLs.
* Only https: URLs are accepted; everything else is rejected.
*/
export function allowedPopupImageUrl(url: string | null | undefined): string | null {
if (!url) return null;
const trimmed = url.trim();
if (!trimmed) return null;
try {
const parsed = new URL(trimmed);
if (parsed.protocol !== "https:") return null;
return parsed.href;
} catch {
return null;
}
}
@@ -5,6 +5,7 @@
*/
import { settingsState } from "../listeners/SettingsState";
import { allowedPopupImageUrl } from "./allowedPopupImageUrl";
const FULLSCREENABLE_CLASS = "popup-media-fullscreenable";
const OVERLAY_VISIBLE_CLASS = "bsplus-popup-media-overlay-backdrop--visible";
@@ -56,13 +57,22 @@ function openMediaOverlayViewer(source: HTMLImageElement | HTMLVideoElement) {
nv.loop = v.loop;
nv.muted = v.muted;
nv.volume = v.volume;
let hasValidSource = false;
for (const s of v.querySelectorAll("source")) {
const src = allowedPopupImageUrl((s as HTMLSourceElement).src);
if (!src) continue;
hasValidSource = true;
const ns = document.createElement("source");
ns.src = (s as HTMLSourceElement).src;
ns.src = src;
const t = (s as HTMLSourceElement).type;
if (t) ns.type = t;
nv.appendChild(ns);
}
if (!hasValidSource) {
const directSrc = allowedPopupImageUrl(v.currentSrc || v.src);
if (!directSrc) return;
nv.src = directSrc;
}
nv.addEventListener(
"loadeddata",
() => {
@@ -79,9 +89,12 @@ function openMediaOverlayViewer(source: HTMLImageElement | HTMLVideoElement) {
nv.load();
media = nv;
} else {
const rawSrc = source.currentSrc || source.src;
const safeSrc = allowedPopupImageUrl(rawSrc);
if (!safeSrc) return;
const img = document.createElement("img");
img.classList.add("bsplus-popup-media-overlay-media");
img.src = source.currentSrc || source.src;
img.src = safeSrc;
img.alt = source.alt || "";
media = img;
}
+20 -10
View File
@@ -22,6 +22,16 @@ class ReactFiber {
return new ReactFiber(selector, options);
}
private getTargetOrigin(): string {
return window.location.origin;
}
private isTrustedMessage(event: MessageEvent): boolean {
return (
event.source === window && event.origin === this.getTargetOrigin()
);
}
private async sendMessage(action: string, payload: any = {}): Promise<any> {
return new Promise((resolve, _) => {
const messageId = this.messageIdCounter++;
@@ -34,7 +44,8 @@ class ReactFiber {
messageId,
};
const listener = (response: any) => {
const listener = (response: MessageEvent) => {
if (!this.isTrustedMessage(response)) return;
if (
response.data?.type === "reactFiberResponse" &&
response.data?.messageId === messageId
@@ -47,7 +58,7 @@ class ReactFiber {
}
};
window.addEventListener("message", listener);
window.postMessage(message, "*");
window.postMessage(message, this.getTargetOrigin());
});
}
@@ -57,15 +68,14 @@ class ReactFiber {
});
}
async setState(update: any | ((prevState: any) => any)): Promise<ReactFiber> {
const updateFnString =
typeof update === "function" ? update.toString() : null;
const updateObject = typeof update !== "function" ? update : null;
async setState(update: Record<string, unknown>): Promise<ReactFiber> {
if (typeof update !== "object" || update === null || Array.isArray(update)) {
throw new TypeError(
"ReactFiber.setState only accepts plain JSON-serializable objects",
);
}
await this.sendMessage("setState", {
updateFn: updateFnString,
updateObject,
});
await this.sendMessage("setState", { updateObject: update });
return this;
}
+1 -1
View File
@@ -100,7 +100,7 @@ export async function SendNewsPage() {
? article.description
: "No description available.";
description.innerHTML =
description.textContent =
articleDescription.length > 400
? articleDescription.substring(0, 400) + "..."
: articleDescription;
+50
View File
@@ -0,0 +1,50 @@
const ALLOWED_HOST_SUFFIXES = [
"betterseqta.org",
"accounts.betterseqta.org",
"raw.githubusercontent.com",
"github.com",
] as const;
function isPrivateOrLocalHost(hostname: string): boolean {
const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
if (host === "localhost" || host.endsWith(".localhost")) return true;
if (host === "127.0.0.1" || host.startsWith("127.")) return true;
if (host === "::1" || host === "0.0.0.0") return true;
const ipv4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (ipv4) {
const [a, b] = [Number(ipv4[1]), Number(ipv4[2])];
if (a === 10) return true;
if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 192 && b === 168) return true;
if (a === 169 && b === 254) return true;
if (a === 127 || a === 0) return true;
}
if (host.includes(":")) {
const h = host.split("%")[0];
if (h.startsWith("fe80") || h.startsWith("fc") || h.startsWith("fd")) return true;
}
return false;
}
function isAllowedHost(hostname: string): boolean {
const host = hostname.toLowerCase();
return ALLOWED_HOST_SUFFIXES.some(
(suffix) => host === suffix || host.endsWith(`.${suffix}`),
);
}
/** HTTPS-only fetch allowlist for background `fetchFromUrl`. */
export function isAllowedFetchUrl(urlString: string): boolean {
let parsed: URL;
try {
parsed = new URL(urlString);
} catch {
return false;
}
if (parsed.protocol !== "https:") return false;
if (isPrivateOrLocalHost(parsed.hostname)) return false;
return isAllowedHost(parsed.hostname);
}
+13 -1
View File
@@ -73,14 +73,23 @@ const OMIT_FROM_UPLOAD_EXACT = new Set<string>([
...KEYS_OMITTED_FROM_CLOUD_UPLOAD,
...SENSITIVE_DEVICE_STORAGE_KEYS_EXACT,
...CLIENT_ONLY_CLOUD_KEYS_EXACT,
"devMode",
"devGhReleaseVersionOverride",
]);
const UNSAFE_STORAGE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
function isUnsafeStorageKey(key: string): boolean {
return UNSAFE_STORAGE_KEYS.has(key);
}
/** True if a storage key is part of the upload payload (and should trigger auto-upload when changed). */
export function isKeyIncludedInCloudUploadPayload(key: string): boolean {
return !shouldOmitKeyFromCloudPayload(key);
}
function shouldOmitKeyFromCloudPayload(key: string): boolean {
if (isUnsafeStorageKey(key)) return true;
if (OMIT_FROM_UPLOAD_EXACT.has(key)) return true;
for (const prefix of SENSITIVE_DEVICE_STORAGE_KEY_PREFIXES) {
if (key.startsWith(prefix)) return true;
@@ -115,6 +124,7 @@ function collectLocalKeysToPreserve(local: Record<string, unknown>): Record<stri
function stripExcludedKeysFromRemoteData(remote: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(remote)) {
if (isUnsafeStorageKey(k)) continue;
if (shouldOmitKeyFromCloudPayload(k)) continue;
out[k] = v;
}
@@ -336,5 +346,7 @@ export async function applyDownloadedEnvelope(envelope: unknown): Promise<void>
const migrated = migrateLegacyToPluginSettings(remoteFlat);
const remoteSanitized = stripExcludedKeysFromRemoteData(migrated);
await browser.storage.local.set(remoteSanitized);
const local = (await browser.storage.local.get()) as Record<string, unknown>;
const preserve = collectLocalKeysToPreserve(local);
await browser.storage.local.set({ ...remoteSanitized, ...preserve });
}
@@ -22,6 +22,8 @@ const handleNotificationClick = async (target: HTMLElement) => {
(item: any) => item.notificationID === parseInt(buttonId),
);
if (!matchingNotification?.message?.messageID) return;
await waitForElm('[class*="Viewer__Viewer___"] > div', true, 20);
// Select the specific direct message
+4 -4
View File
@@ -61,19 +61,19 @@ export class MessageHandler {
themeManager.setTheme(request.body.themeID).then(() => {
sendResponse({ status: "success" });
});
break;
return true;
case "DisableTheme":
themeManager.disableTheme().then(() => {
sendResponse({ status: "success" });
});
break;
return true;
case "DeleteTheme":
themeManager.deleteTheme(request.body.themeID).then(() => {
sendResponse({ status: "success" });
});
break;
return true;
case "ListThemes":
themeManager.getAvailableThemes().then((themes) => {
@@ -97,11 +97,11 @@ export class MessageHandler {
case "CloseThemeCreator":
try {
CloseThemeCreator();
sendResponse({ status: "success" });
} catch (error) {
console.error("Error closing theme creator:", error);
sendResponse({ status: "error" });
}
sendResponse({ status: "success" });
break;
case "HideSensitive":
@@ -2,6 +2,20 @@ import browser from "webextension-polyfill";
import type { SettingsState } from "@/types/storage";
import type { Subscriber, Unsubscriber } from "svelte/store";
/** Auth/session keys live in `chrome.storage.local` only — not on the settingsState proxy. */
const EXCLUDED_FROM_SETTINGS_SURFACE = new Set([
"bsplus_token",
"bsplus_refresh_token",
"bsplus_client_id",
"bsplus_user",
"cloudAccessToken",
"cloudUsername",
]);
function isExcludedSettingsKey(key: string): boolean {
return EXCLUDED_FROM_SETTINGS_SURFACE.has(key);
}
type ChangeListener = (newValue: any, oldValue: any) => void;
type GlobalChangeListener = (newValue: any, oldValue: any, key: string) => void;
@@ -26,9 +40,16 @@ class StorageManager {
if (prop in target) {
return (target as any)[prop];
}
if (typeof prop === "string" && isExcludedSettingsKey(prop)) {
return undefined;
}
return Reflect.get(target.data, prop);
},
set: (target, prop: keyof SettingsState, value) => {
if (typeof prop === "string" && isExcludedSettingsKey(prop)) {
void browser.storage.local.set({ [prop]: value });
return true;
}
const oldValue = target.data[prop];
// Only save if the reference actually changed
@@ -95,6 +116,10 @@ class StorageManager {
key: K,
value: SettingsState[K],
): void {
if (typeof key === "string" && isExcludedSettingsKey(key)) {
void browser.storage.local.set({ [key]: value });
return;
}
const oldValue = this.data[key];
if (oldValue !== value) {
this.data[key] = value;
@@ -121,6 +146,7 @@ class StorageManager {
private async loadFromStorage(): Promise<void> {
const result = await browser.storage.local.get();
Object.entries(result).forEach(([key, value]) => {
if (isExcludedSettingsKey(key)) return;
Reflect.set(this.data, key, value);
});
}
@@ -137,6 +163,7 @@ class StorageManager {
: Object.keys(this.data);
for (const key of keys) {
if (isExcludedSettingsKey(key)) continue;
const value = (this.data as Record<string, unknown>)[key];
if (value !== undefined) {
payload[key] = value;
@@ -163,6 +190,7 @@ class StorageManager {
for (const [key, { oldValue, newValue }] of Object.entries(changes)) {
if (JSON.stringify(oldValue) === JSON.stringify(newValue)) continue;
if (isExcludedSettingsKey(key)) continue;
if (newValue !== undefined) {
(this.data as Record<string, unknown>)[key] = newValue;
+2 -3
View File
@@ -4,7 +4,7 @@ import DOMPurify from "dompurify";
* Converts an HTML string into a DOM element, with sanitization and optional styling.
*
* This function first sanitizes the input HTML string using DOMPurify to prevent XSS attacks.
* The sanitization process allows 'onclick' attributes and specific URI schemes.
* The sanitization process allows only safe URI schemes in links and media.
* Then, it parses the sanitized string into an HTML document and returns its body.
* Optionally, it can apply predefined CSS styles to the body element.
*
@@ -16,9 +16,8 @@ export default function stringToHTML(str: string, styles = false) {
const parser = new DOMParser();
str = DOMPurify.sanitize(str, {
ADD_ATTR: ["onclick"],
ALLOWED_URI_REGEXP:
/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|chrome-extension):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
/^(?:(?:https?|mailto|tel):|\/|#|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
});
const doc = parser.parseFromString(str, "text/html");