From 39a8a4143e74e721dabc0d17029f9c1c7e0a9f9e Mon Sep 17 00:00:00 2001 From: fccview Date: Fri, 1 May 2026 16:28:44 +0100 Subject: [PATCH 01/28] add debugger to settings auth --- src/server/routes/settings-auth.ts | 93 +++++++++++++++++++----------- 1 file changed, 59 insertions(+), 34 deletions(-) diff --git a/src/server/routes/settings-auth.ts b/src/server/routes/settings-auth.ts index 5528b051..e85ce8c8 100644 --- a/src/server/routes/settings-auth.ts +++ b/src/server/routes/settings-auth.ts @@ -6,6 +6,7 @@ import { defaultEnginesFile } from "../utils/paths"; import { asString, getSettings, setSettings } from "../utils/plugin-settings"; import { isPublicInstance } from "../utils/public-instance"; import { getRandomUserAgent } from "../utils/user-agents"; +import { logger } from "../utils/logger"; const DEGOOG_SETTINGS_ID = "degoog-settings"; @@ -19,21 +20,47 @@ const SETTINGS_GATE_KEY = "settingsGate"; function getTokenFromCookie(c: Context): string | undefined { const raw = c.req.header("cookie"); - if (!raw) return undefined; + if (!raw) { + logger.debug("settings-auth", "no cookie header present"); + return undefined; + } const match = raw .split(";") .find((s) => s.trim().startsWith(COOKIE_NAME + "=")); - if (!match) return undefined; + if (!match) { + logger.debug("settings-auth", `cookie header present but '${COOKIE_NAME}' not found`); + return undefined; + } const value = match.split("=")[1]?.trim(); + logger.debug("settings-auth", `'${COOKIE_NAME}' cookie found (length: ${value?.length ?? 0})`); return value || undefined; } export function getSettingsTokenFromRequest(c: Context): string | undefined { - return ( - c.req.header("x-settings-token") ?? - c.req.query("token") ?? - getTokenFromCookie(c) - ); + const fromHeader = c.req.header("x-settings-token"); + if (fromHeader) { + logger.debug("settings-auth", "token source: x-settings-token header"); + return fromHeader; + } + const fromQuery = c.req.query("token"); + if (fromQuery) { + logger.debug("settings-auth", "token source: query param"); + return fromQuery; + } + return getTokenFromCookie(c); +} + +async function guardRoute( + c: Context, + route: string, +): Promise { + const token = getSettingsTokenFromRequest(c); + const valid = await validateSettingsToken(token); + if (!valid) { + logger.debug("settings-auth", `401 on ${route}`); + return c.json({ error: "Unauthorized" }, 401); + } + return null; } export async function shouldServeSettingsGate(c: Context): Promise { @@ -77,12 +104,22 @@ export async function validateSettingsToken( if (isPublicInstance()) return false; const required = await isAuthRequired(); if (!required) return true; - if (!token) return false; + if (!token) { + logger.debug("settings-auth", "token validation failed: no token provided"); + return false; + } const expiresAt = validTokens.get(token); - if (!expiresAt || Date.now() > expiresAt) { - if (expiresAt) validTokens.delete(token); + if (!expiresAt) { + logger.debug("settings-auth", `token validation failed: token not found in store (${validTokens.size} active tokens)`); return false; } + if (Date.now() > expiresAt) { + validTokens.delete(token); + logger.debug("settings-auth", "token validation failed: token expired"); + return false; + } + const ttlMs = expiresAt - Date.now(); + logger.debug("settings-auth", `token valid (expires in ${Math.round(ttlMs / 1000 / 60)}m)`); return true; } @@ -167,19 +204,15 @@ router.post("/api/settings/auth", async (c) => { }); router.get("/api/settings/general", async (c) => { - const token = getSettingsTokenFromRequest(c); - if (!(await validateSettingsToken(token))) { - return c.json({ error: "Unauthorized" }, 401); - } + const denied = await guardRoute(c, "GET /api/settings/general"); + if (denied) return denied; const settings = await getSettings(DEGOOG_SETTINGS_ID); return c.json(settings); }); router.post("/api/settings/general", async (c) => { - const token = getSettingsTokenFromRequest(c); - if (!(await validateSettingsToken(token))) { - return c.json({ error: "Unauthorized" }, 401); - } + const denied = await guardRoute(c, "POST /api/settings/general"); + if (denied) return denied; let body: Record; try { body = await c.req.json>(); @@ -275,10 +308,8 @@ const _upsertScore = ( }; router.post("/api/settings/domain-action", async (c) => { - const token = getSettingsTokenFromRequest(c); - if (!(await validateSettingsToken(token))) { - return c.json({ error: "Unauthorized" }, 401); - } + const denied = await guardRoute(c, "POST /api/settings/domain-action"); + if (denied) return denied; let body: { kind?: string; @@ -361,10 +392,8 @@ async function fetchIp(useFn: typeof fetch): Promise { } router.get("/api/settings/proxy-test", async (c) => { - const token = getSettingsTokenFromRequest(c); - if (!(await validateSettingsToken(token))) { - return c.json({ error: "Unauthorized" }, 401); - } + const denied = await guardRoute(c, "GET /api/settings/proxy-test"); + if (denied) return denied; const settings = await getSettings(DEGOOG_SETTINGS_ID); const enabled = asString(settings.proxyEnabled) === "true"; @@ -399,10 +428,8 @@ router.get("/api/settings/appearance", async (c) => { }); router.get("/api/settings/default-engines", async (c) => { - const token = getSettingsTokenFromRequest(c); - if (!(await validateSettingsToken(token))) { - return c.json({ error: "Unauthorized" }, 401); - } + const denied = await guardRoute(c, "GET /api/settings/default-engines"); + if (denied) return denied; try { const raw = await readFile(defaultEnginesFile(), "utf-8"); return c.json(JSON.parse(raw)); @@ -412,10 +439,8 @@ router.get("/api/settings/default-engines", async (c) => { }); router.post("/api/settings/default-engines", async (c) => { - const token = getSettingsTokenFromRequest(c); - if (!(await validateSettingsToken(token))) { - return c.json({ error: "Unauthorized" }, 401); - } + const denied = await guardRoute(c, "POST /api/settings/default-engines"); + if (denied) return denied; let body: Record; try { body = await c.req.json>(); From 99f110f671feffba8c102b7fd93c67642e6b739f Mon Sep 17 00:00:00 2001 From: fccview Date: Fri, 1 May 2026 16:30:44 +0100 Subject: [PATCH 02/28] add more setting auth cookies --- src/server/routes/settings-auth.ts | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/src/server/routes/settings-auth.ts b/src/server/routes/settings-auth.ts index e85ce8c8..17fa6405 100644 --- a/src/server/routes/settings-auth.ts +++ b/src/server/routes/settings-auth.ts @@ -28,11 +28,17 @@ function getTokenFromCookie(c: Context): string | undefined { .split(";") .find((s) => s.trim().startsWith(COOKIE_NAME + "=")); if (!match) { - logger.debug("settings-auth", `cookie header present but '${COOKIE_NAME}' not found`); + logger.debug( + "settings-auth", + `cookie header present but '${COOKIE_NAME}' not found`, + ); return undefined; } const value = match.split("=")[1]?.trim(); - logger.debug("settings-auth", `'${COOKIE_NAME}' cookie found (length: ${value?.length ?? 0})`); + logger.debug( + "settings-auth", + `'${COOKIE_NAME}' cookie found (length: ${value?.length ?? 0})`, + ); return value || undefined; } @@ -50,10 +56,7 @@ export function getSettingsTokenFromRequest(c: Context): string | undefined { return getTokenFromCookie(c); } -async function guardRoute( - c: Context, - route: string, -): Promise { +async function guardRoute(c: Context, route: string): Promise { const token = getSettingsTokenFromRequest(c); const valid = await validateSettingsToken(token); if (!valid) { @@ -110,7 +113,10 @@ export async function validateSettingsToken( } const expiresAt = validTokens.get(token); if (!expiresAt) { - logger.debug("settings-auth", `token validation failed: token not found in store (${validTokens.size} active tokens)`); + logger.debug( + "settings-auth", + `token validation failed: token not found in store (${validTokens.size} active tokens)`, + ); return false; } if (Date.now() > expiresAt) { @@ -119,7 +125,10 @@ export async function validateSettingsToken( return false; } const ttlMs = expiresAt - Date.now(); - logger.debug("settings-auth", `token valid (expires in ${Math.round(ttlMs / 1000 / 60)}m)`); + logger.debug( + "settings-auth", + `token valid (expires in ${Math.round(ttlMs / 1000 / 60)}m)`, + ); return true; } From ff3462b4ed013d964dbe6c4ba5b996ac0acbc761 Mon Sep 17 00:00:00 2001 From: fccview Date: Fri, 1 May 2026 19:56:24 +0100 Subject: [PATCH 03/28] harden route security and break down some routes into smaller ones or theyd spiral out of control --- docs/env.html | 106 ++++-- package.json | 2 +- src/client/modules/settings/settings.ts | 26 ++ src/client/utils/db.ts | 61 ++-- src/locales/en-US.json | 3 +- src/server/extensions/store/repo-ops.ts | 16 +- src/server/routes/extensions.ts | 18 +- src/server/routes/proxy.ts | 4 + src/server/routes/search/_search-routes.ts | 27 +- src/server/routes/settings-auth.ts | 362 ++++----------------- src/server/routes/settings.ts | 260 ++++++++++++++- src/server/routes/suggest.ts | 16 +- src/server/routes/themes.ts | 12 +- src/server/utils/extension-docs.ts | 4 +- src/server/utils/paths.ts | 3 + src/server/utils/settings-tokens.ts | 141 ++++++++ tests/routes/gated-apis.test.ts | 36 +- 17 files changed, 725 insertions(+), 372 deletions(-) create mode 100644 src/server/utils/settings-tokens.ts diff --git a/docs/env.html b/docs/env.html index d1fb2085..7fc318b9 100644 --- a/docs/env.html +++ b/docs/env.html @@ -9,17 +9,28 @@ try { var t = localStorage.getItem("ade:theme"); if (t !== "light" && t !== "dark") { - t = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + t = window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; } document.documentElement.setAttribute("data-theme", t); } catch (e) {} })(); - - + + - + @@ -37,7 +48,7 @@ src="https://github.com/fccview/degoog/raw/main/src/public/images/degoog-logo.png" alt="degoog logo" class="ade-brand-img" - onerror="this.style.display='none'" + onerror="this.style.display = 'none'" /> Degoog Docs @@ -55,25 +66,54 @@ class="doc-search-clear ade-btn" aria-label="Clear search" style="display: none" - >Clear + > + Clear +
-
@@ -102,9 +142,9 @@

General

DEGOOG_SETTINGS_PASSWORDS - Comma-separated list of passwords for the Settings page. If set, - users must enter one of these to access Settings (unless a - middleware plugin is used as the settings gate). + Comma-separated list of passwords for the Settings page. If + set, users must enter one of these to access Settings (unless + a middleware plugin is used as the settings gate). — @@ -132,8 +172,8 @@

General

DEGOOG_DEFAULT_SEARCH_LANGUAGE - Default ISO 639-1 language code applied to all searches when no - language is selected by the user (e.g. en, + Default ISO 639-1 language code applied to all searches when + no language is selected by the user (e.g. en, de, it). en-US @@ -143,8 +183,8 @@

General

Forces the UI locale for all requests, overriding the Accept-Language header (e.g. en-US, - fr-FR). When set, the same locale pipeline runs as - normal — only the source changes. Unset or empty: + fr-FR). When set, the same locale pipeline runs + as normal — only the source changes. Unset or empty: Accept-Language is used as today. — @@ -152,8 +192,8 @@

General

LOG_LEVEL - Controls the verbosity of server-side console output. Supported - levels from most to least severe: + Controls the verbosity of server-side console output. + Supported levels from most to least severe: fatal, error, warn, info, log, debug. Each level includes all levels of higher severity. Set to @@ -233,6 +273,18 @@

Plugins, themes, engines

data/plugin-settings.json + + DEGOOG_DEFAULT_ENGINES_FILE + + Path to the JSON file storing default enabled/disabled engines + + data/default-engines.json + + + DEGOOG_SETTINGS_TOKENS_FILE + Path to the JSON file storing settings tokens + data/settings-tokens.json + DEGOOG_ALIASES_FILE diff --git a/package.json b/package.json index 3f0d5a91..a6800bcb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "degoog", - "version": "0.15.0", + "version": "0.15.1-develop", "type": "module", "scripts": { "dev": "bun run --watch src/server/index.ts", diff --git a/src/client/modules/settings/settings.ts b/src/client/modules/settings/settings.ts index 11f594db..85c239c5 100644 --- a/src/client/modules/settings/settings.ts +++ b/src/client/modules/settings/settings.ts @@ -46,6 +46,7 @@ const _checkAuth = async (): Promise<{ required: boolean; valid: boolean; loginUrl?: string; + error?: string; }> => { const token = getStoredToken(); const headers = token ? { "x-settings-token": token } : {}; @@ -56,9 +57,30 @@ const _checkAuth = async (): Promise<{ required: boolean; valid: boolean; loginUrl?: string; + error?: string; }>; }; +function _showAuthMisconfigured(): void { + const page = document.querySelector(".settings-page"); + if (!page) return; + page.innerHTML = ` +
+ + + + + ${t("settings-page.back")} + +

${t("settings-page.page-title")}

+
+
+
+

${t("settings-page.gate.misconfigured")}

+
+
`; +} + function _showAuthGate(): void { const page = document.querySelector(".settings-page"); if (!page) return; @@ -244,6 +266,10 @@ async function _init(): Promise { } const auth = await _checkAuth(); if (auth.required && !auth.valid) { + if (auth.error === "auth-misconfigured") { + _showAuthMisconfigured(); + return; + } if (auth.loginUrl) { window.location.href = auth.loginUrl; return; diff --git a/src/client/utils/db.ts b/src/client/utils/db.ts index 2bbc8ce9..1ccb41fc 100644 --- a/src/client/utils/db.ts +++ b/src/client/utils/db.ts @@ -1,6 +1,14 @@ import { DB_NAME, DB_VERSION, STORE_NAME } from "../constants"; -const _openDB = (): Promise => +const _deleteDB = (): Promise => + new Promise((resolve) => { + const req = indexedDB.deleteDatabase(DB_NAME); + req.onsuccess = () => resolve(); + req.onerror = () => resolve(); + req.onblocked = () => resolve(); + }); + +const _openOnce = (): Promise => new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, DB_VERSION); request.onupgradeneeded = () => { @@ -11,34 +19,47 @@ const _openDB = (): Promise => }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); + request.onblocked = () => reject(new Error("idb open blocked")); }); -export const idbGet = async (key: string): Promise => { +const _openDB = async (): Promise => { + let db = await _openOnce(); + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.close(); + await _deleteDB(); + db = await _openOnce(); + } + return db; +}; + +const _runTx = async ( + mode: IDBTransactionMode, + exec: (store: IDBObjectStore) => IDBRequest, +): Promise => { try { const db = await _openDB(); - return new Promise((resolve, reject) => { - const tx = db.transaction(STORE_NAME, "readonly"); - const store = tx.objectStore(STORE_NAME); - const req = store.get(key); - req.onsuccess = () => resolve((req.result as T) ?? null); - req.onerror = () => reject(req.error); + return await new Promise((resolve, reject) => { + try { + const tx = db.transaction(STORE_NAME, mode); + const store = tx.objectStore(STORE_NAME); + const req = exec(store); + req.onsuccess = () => resolve((req.result as T) ?? null); + req.onerror = () => reject(req.error); + } catch (e) { + reject(e instanceof Error ? e : new Error(String(e))); + } }); } catch { return null; } }; +export const idbGet = (key: string): Promise => + _runTx("readonly", (store) => store.get(key) as IDBRequest); + export const idbSet = async (key: string, value: unknown): Promise => { - try { - const db = await _openDB(); - return new Promise((resolve, reject) => { - const tx = db.transaction(STORE_NAME, "readwrite"); - const store = tx.objectStore(STORE_NAME); - const req = store.put(value, key); - req.onsuccess = () => resolve(); - req.onerror = () => reject(req.error); - }); - } catch { - return; - } + await _runTx( + "readwrite", + (store) => store.put(value, key) as unknown as IDBRequest, + ); }; diff --git a/src/locales/en-US.json b/src/locales/en-US.json index 4332f77e..2b556af1 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -170,7 +170,8 @@ "password-placeholder": "Password", "unlock": "Unlock", "incorrect-password": "Incorrect password.", - "network-error": "Something went wrong. Please try again." + "network-error": "Something went wrong. Please try again.", + "misconfigured": "Settings authentication is misconfigured: the configured gate references a middleware that is not loaded. Contact the administrator to fix the gate setting or set DEGOOG_SETTINGS_PASSWORDS." }, "public": { "engines-heading": "Engines" diff --git a/src/server/extensions/store/repo-ops.ts b/src/server/extensions/store/repo-ops.ts index ddcf371e..3c6afd12 100644 --- a/src/server/extensions/store/repo-ops.ts +++ b/src/server/extensions/store/repo-ops.ts @@ -15,6 +15,18 @@ const FETCH_TIMEOUT_MS = 15_000; const OFFICIAL_REPO_URL = "https://github.com/degoog-org/official-extensions.git"; const OLD_OFFICIAL_REPO_URL = "https://github.com/fccview/fccview-degoog-extensions.git"; +function _sanitizeGitError(raw: string): string { + if (!raw) return raw; + const storeDir = getStoreDir(); + const cwd = process.cwd(); + return raw + .replaceAll(storeDir, "") + .replaceAll(cwd, "") + .replace(/'\/[^'\n]*'/g, "''") + .replace(/\/[A-Za-z0-9_./-]+\/(?=\.git\b)/g, "/") + .trim(); +} + export function slugFromUrl(url: string): string { const normalized = normalizeRepoUrl(url); const hash = createHash("sha256").update(normalized).digest("hex").slice(0, 8); @@ -65,7 +77,7 @@ export async function addRepo(url: string): Promise { ), ]); if (exit !== 0) { - const err = await new Response(proc.stderr).text(); + const err = _sanitizeGitError(await new Response(proc.stderr).text()); throw new Error(err || `Git clone failed with code ${exit}`); } const pkgPath = join(dest, "package.json"); @@ -126,7 +138,7 @@ export async function refreshRepo(url?: string): Promise { }); const exit = await proc.exited; if (exit !== 0) { - const err = await new Response(proc.stderr).text(); + const err = _sanitizeGitError(await new Response(proc.stderr).text()); repo.error = err || `Git pull failed (${exit})`; continue; } diff --git a/src/server/routes/extensions.ts b/src/server/routes/extensions.ts index 37afc35a..3e02e642 100644 --- a/src/server/routes/extensions.ts +++ b/src/server/routes/extensions.ts @@ -48,7 +48,9 @@ import { extensionReadmeExists } from "../utils/extension-docs"; const router = new Hono(); -async function getSlotExtensionMeta(coreT?: Translate): Promise { +async function getSlotExtensionMeta( + coreT?: Translate, +): Promise { const slots = getSlotPlugins(); const out: ExtensionMeta[] = []; for (const slot of slots) { @@ -58,11 +60,14 @@ async function getSlotExtensionMeta(coreT?: Translate): Promise if (hasPositionChoice) { fullSchema.push({ key: SLOT_POSITION_SETTING_KEY, - label: coreT ? coreT("settings-page.schema.slot-position.label") || "Position" : "Position", + label: coreT + ? coreT("settings-page.schema.slot-position.label") || "Position" + : "Position", type: "select", options: [...slot.slotPositions!], description: coreT - ? coreT("settings-page.schema.slot-position.description") || "Where the slot content appears on the page." + ? coreT("settings-page.schema.slot-position.description") || + "Where the slot content appears on the page." : "Where the slot content appears on the page.", }); } @@ -124,7 +129,12 @@ router.post("/api/extensions/:id/settings", async (c) => { if (!(await validateSettingsToken(token))) return c.json({ error: "Unauthorized" }, 401); const id = c.req.param("id"); - const body = await c.req.json>(); + let body: Record; + try { + body = await c.req.json>(); + } catch { + return c.json({ error: "Invalid JSON" }, 400); + } const locale = getLocale(c); const coreT = await getCoreTranslator(); diff --git a/src/server/routes/proxy.ts b/src/server/routes/proxy.ts index 4c823972..38792c47 100644 --- a/src/server/routes/proxy.ts +++ b/src/server/routes/proxy.ts @@ -56,6 +56,10 @@ router.get("/api/proxy/image", async (c) => { return c.body("Invalid protocol", 400); } + if (!isUrlAllowedForOutgoing(url)) { + return c.body("URL not allowed for outgoing fetch", 403); + } + const authId = c.req.query("auth_id"); const headers: Record = { "User-Agent": getRandomUserAgent(), diff --git a/src/server/routes/search/_search-routes.ts b/src/server/routes/search/_search-routes.ts index afdacd85..1cb6ecad 100644 --- a/src/server/routes/search/_search-routes.ts +++ b/src/server/routes/search/_search-routes.ts @@ -1,6 +1,15 @@ import type { Hono } from "hono"; -import type { SearchBody, SearchType, TimeFilter, RetryPostBody } from "../../types"; -import { _applyRateLimit, isValidQuery, parseEngineConfig } from "../../utils/search"; +import type { + SearchBody, + SearchType, + TimeFilter, + RetryPostBody, +} from "../../types"; +import { + _applyRateLimit, + isValidQuery, + parseEngineConfig, +} from "../../utils/search"; import { parseEnginesFromBody, parsePage } from "./_parsers"; import { handleRetry, handleSearch } from "./_search-handlers"; @@ -31,7 +40,12 @@ export function registerSearchRoutes(router: Hono): void { const limitRes = await _applyRateLimit(c); if (limitRes) return limitRes; - const body = await c.req.json(); + let body: SearchBody; + try { + body = await c.req.json(); + } catch { + return c.json({ error: "Invalid JSON" }, 400); + } const query = body.query ?? ""; if (!isValidQuery(query)) return c.json({ error: "Missing or invalid query parameter 'q'" }, 400); @@ -78,7 +92,12 @@ export function registerSearchRoutes(router: Hono): void { const limitRes = await _applyRateLimit(c); if (limitRes) return limitRes; - const body = await c.req.json(); + let body: RetryPostBody; + try { + body = await c.req.json(); + } catch { + return c.json({ error: "Invalid JSON" }, 400); + } const query = body.query ?? ""; const engineName = body.engine ?? ""; if (!query || !engineName) diff --git a/src/server/routes/settings-auth.ts b/src/server/routes/settings-auth.ts index 17fa6405..816e6597 100644 --- a/src/server/routes/settings-auth.ts +++ b/src/server/routes/settings-auth.ts @@ -1,23 +1,28 @@ -import { readFile, writeFile } from "fs/promises"; import { Hono, type Context } from "hono"; import { getMiddleware } from "../extensions/middleware/registry"; -import { outgoingFetch } from "../utils/outgoing"; -import { defaultEnginesFile } from "../utils/paths"; -import { asString, getSettings, setSettings } from "../utils/plugin-settings"; +import { asString, getSettings } from "../utils/plugin-settings"; import { isPublicInstance } from "../utils/public-instance"; -import { getRandomUserAgent } from "../utils/user-agents"; import { logger } from "../utils/logger"; - -const DEGOOG_SETTINGS_ID = "degoog-settings"; +import { + TOKEN_TTL_MS, + checkAuthRate, + generateSettingsToken, + passwordMatches, + recordAuthFailure, + tokenStore, +} from "../utils/settings-tokens"; const router = new Hono(); -const TOKEN_TTL_MS = 24 * 60 * 60 * 1000; const COOKIE_NAME = "settings-token"; -const validTokens = new Map(); const MIDDLEWARE_SETTINGS_ID = "middleware"; const SETTINGS_GATE_KEY = "settingsGate"; +const _clientIp = (c: Context): string => + c.req.header("x-forwarded-for")?.split(",")[0]?.trim() || + c.req.header("x-real-ip") || + "unknown"; + function getTokenFromCookie(c: Context): string | undefined { const raw = c.req.header("cookie"); if (!raw) { @@ -56,7 +61,10 @@ export function getSettingsTokenFromRequest(c: Context): string | undefined { return getTokenFromCookie(c); } -async function guardRoute(c: Context, route: string): Promise { +export async function guardSettingsRoute( + c: Context, + route: string, +): Promise { const token = getSettingsTokenFromRequest(c); const valid = await validateSettingsToken(token); if (!valid) { @@ -86,21 +94,6 @@ function isPasswordRequired(): boolean { return getPasswords().length > 0; } -function generateToken(): string { - const bytes = new Uint8Array(32); - crypto.getRandomValues(bytes); - return Array.from(bytes) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); -} - -function pruneExpired(): void { - const now = Date.now(); - for (const [token, expiresAt] of validTokens) { - if (now > expiresAt) validTokens.delete(token); - } -} - export async function validateSettingsToken( token: string | undefined, ): Promise { @@ -111,16 +104,16 @@ export async function validateSettingsToken( logger.debug("settings-auth", "token validation failed: no token provided"); return false; } - const expiresAt = validTokens.get(token); + const expiresAt = tokenStore.get(token); if (!expiresAt) { logger.debug( "settings-auth", - `token validation failed: token not found in store (${validTokens.size} active tokens)`, + `token validation failed: token not found in store (${tokenStore.size()} active tokens)`, ); return false; } if (Date.now() > expiresAt) { - validTokens.delete(token); + tokenStore.delete(token); logger.debug("settings-auth", "token validation failed: token expired"); return false; } @@ -150,23 +143,29 @@ async function isAuthRequired(): Promise { } router.get("/api/settings/auth", async (c) => { - const m = await getSelectedMiddlewareForSettingsGate(); - if (!m) { - if (!isPasswordRequired()) return c.json({ required: false, valid: true }); - const token = getSettingsTokenFromRequest(c); - if (await validateSettingsToken(token)) - return c.json({ required: true, valid: true }); - return c.json({ required: true, valid: false }); - } + const required = await isAuthRequired(); + if (!required) return c.json({ required: false, valid: true }); + const token = getSettingsTokenFromRequest(c); if (await validateSettingsToken(token)) return c.json({ required: true, valid: true }); + + const m = await getSelectedMiddlewareForSettingsGate(); + if (!m) { + if (isPasswordRequired()) return c.json({ required: true, valid: false }); + logger.warn( + "settings-auth", + "settingsGate references a middleware that is not loaded; refusing to grant access", + ); + return c.json({ + required: true, + valid: false, + error: "auth-misconfigured", + }); + } + const result = await m.handle(c.req.raw, { route: "settings-auth" }); if (result instanceof Response) return result; - if (result === null) { - if (!isPasswordRequired()) return c.json({ required: false, valid: true }); - return c.json({ required: true, valid: false }); - } return c.json({ required: true, valid: false }); }); @@ -179,9 +178,9 @@ router.get("/api/settings/auth/callback", async (c) => { !(result instanceof Response) && "redirect" in result ) { - pruneExpired(); - const sessionToken = generateToken(); - validTokens.set(sessionToken, Date.now() + TOKEN_TTL_MS); + tokenStore.pruneExpired(); + const sessionToken = generateSettingsToken(); + tokenStore.set(sessionToken, Date.now() + TOKEN_TTL_MS); const sep = result.redirect.includes("?") ? "&" : "?"; return c.redirect(`${result.redirect}${sep}token=${sessionToken}`); } @@ -191,6 +190,17 @@ router.get("/api/settings/auth/callback", async (c) => { router.post("/api/settings/auth", async (c) => { if (isPublicInstance()) return c.json({ error: "Unauthorized" }, 401); + const ip = _clientIp(c); + const rate = checkAuthRate(ip); + if (!rate.allowed) { + logger.warn( + "settings-auth", + `auth rate-limited for ${ip} (retry in ${rate.retryAfter}s)`, + ); + return c.json({ ok: false, error: "Too many attempts" }, 429, { + "Retry-After": String(rate.retryAfter), + }); + } const m = await getSelectedMiddlewareForSettingsGate(); if (m) { const result = await m.handle(c.req.raw, { route: "settings-auth-post" }); @@ -198,266 +208,26 @@ router.post("/api/settings/auth", async (c) => { return c.json({ ok: false, error: "Use the login flow" }, 400); } if (!isPasswordRequired()) return c.json({ ok: true, token: null }); - const body = await c.req.json<{ password?: string }>(); + let body: { password?: string }; + try { + body = await c.req.json<{ password?: string }>(); + } catch { + recordAuthFailure(ip); + return c.json({ ok: false }, 400); + } const passwords = getPasswords(); - if (!body.password || !passwords.includes(body.password)) { + const candidate = typeof body.password === "string" ? body.password : ""; + if (!candidate || !passwordMatches(candidate, passwords)) { + recordAuthFailure(ip); return c.json({ ok: false }, 401); } - pruneExpired(); - const token = generateToken(); - validTokens.set(token, Date.now() + TOKEN_TTL_MS); + tokenStore.pruneExpired(); + const token = generateSettingsToken(); + tokenStore.set(token, Date.now() + TOKEN_TTL_MS); const cookie = `${COOKIE_NAME}=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${TOKEN_TTL_MS / 1000}`; return c.json({ ok: true, token }, 200, { "Set-Cookie": cookie, }); }); -router.get("/api/settings/general", async (c) => { - const denied = await guardRoute(c, "GET /api/settings/general"); - if (denied) return denied; - const settings = await getSettings(DEGOOG_SETTINGS_ID); - return c.json(settings); -}); - -router.post("/api/settings/general", async (c) => { - const denied = await guardRoute(c, "POST /api/settings/general"); - if (denied) return denied; - let body: Record; - try { - body = await c.req.json>(); - } catch { - return c.json({ error: "Invalid JSON" }, 400); - } - const existing = await getSettings(DEGOOG_SETTINGS_ID); - const allowed = [ - "proxyEnabled", - "proxyUrls", - "rateLimitEnabled", - "rateLimitBurstWindow", - "rateLimitBurstMax", - "rateLimitLongWindow", - "rateLimitLongMax", - "languagesEnabled", - "languages", - "streamingEnabled", - "streamingAutoRetry", - "streamingMaxRetries", - "postMethodEnabled", - "defaultTheme", - "domainBlockEnabled", - "domainBlockList", - "domainBlockUiEnabled", - "domainReplaceEnabled", - "domainReplaceList", - "domainReplaceUiEnabled", - "domainScoreEnabled", - "domainScoreList", - "domainScoreUiEnabled", - "customCss", - ]; - const updates: Record = {}; - for (const key of allowed) { - if (key in body && typeof body[key] === "string") { - updates[key] = body[key]; - } - } - await setSettings(DEGOOG_SETTINGS_ID, { ...existing, ...updates }); - return c.json({ ok: true }); -}); - -const _normalizeHostname = (raw: string): string => - raw - .trim() - .toLowerCase() - .replace(/^https?:\/\//, "") - .replace(/\/.*$/, ""); - -const _appendBlock = (existing: string, source: string): string => { - const lines = existing - .split("\n") - .map((l) => l.trim()) - .filter((l) => l.length > 0); - if (lines.includes(source)) return existing; - lines.push(source); - return lines.join("\n"); -}; - -const _appendReplace = ( - existing: string, - source: string, - target: string, -): string => { - const lines = existing - .split("\n") - .map((l) => l.trim()) - .filter((l) => l.length > 0); - const next = lines.filter((l) => { - const [src] = l.split("->").map((s) => s.trim()); - return src !== source; - }); - next.push(`${source} -> ${target}`); - return next.join("\n"); -}; - -const _upsertScore = ( - existing: string, - source: string, - score: number, -): string => { - const lines = existing - .split("\n") - .map((l) => l.trim()) - .filter((l) => l.length > 0); - const next = lines.filter((l) => { - const [src] = l.split("|").map((s) => s.trim()); - return src !== source; - }); - next.push(`${source}|${score}`); - return next.join("\n"); -}; - -router.post("/api/settings/domain-action", async (c) => { - const denied = await guardRoute(c, "POST /api/settings/domain-action"); - if (denied) return denied; - - let body: { - kind?: string; - source?: string; - target?: string; - score?: number; - }; - try { - body = await c.req.json(); - } catch { - return c.json({ error: "Invalid JSON" }, 400); - } - - const kind = body.kind; - const source = _normalizeHostname(body.source ?? ""); - if (!source) return c.json({ error: "Missing source" }, 400); - - const existing = await getSettings(DEGOOG_SETTINGS_ID); - const updates: Record = {}; - - if (kind === "block") { - if (asString(existing.domainBlockUiEnabled) !== "true") { - return c.json({ error: "Forbidden" }, 403); - } - updates.domainBlockList = _appendBlock( - asString(existing.domainBlockList), - source, - ); - } else if (kind === "replace") { - if (asString(existing.domainReplaceUiEnabled) !== "true") { - return c.json({ error: "Forbidden" }, 403); - } - const target = _normalizeHostname(body.target ?? ""); - if (!target) return c.json({ error: "Missing target" }, 400); - updates.domainReplaceList = _appendReplace( - asString(existing.domainReplaceList), - source, - target, - ); - } else if (kind === "score") { - if (asString(existing.domainScoreUiEnabled) !== "true") { - return c.json({ error: "Forbidden" }, 403); - } - const score = Number(body.score); - if (!Number.isFinite(score)) { - return c.json({ error: "Invalid score" }, 400); - } - updates.domainScoreList = _upsertScore( - asString(existing.domainScoreList), - source, - Math.trunc(score), - ); - } else { - return c.json({ error: "Invalid kind" }, 400); - } - - await setSettings(DEGOOG_SETTINGS_ID, { ...existing, ...updates }); - return c.json({ ok: true }); -}); - -const IP_CHECK_URL = "https://api.ipify.org?format=json"; -const IP_CHECK_TIMEOUT_MS = 8_000; - -async function fetchIp(useFn: typeof fetch): Promise { - try { - const res = await useFn(IP_CHECK_URL, { - signal: AbortSignal.timeout(IP_CHECK_TIMEOUT_MS), - headers: { - "User-Agent": getRandomUserAgent(), - Accept: "application/json,text/plain,*/*;q=0.8", - "Accept-Language": "en-US,en;q=0.9", - }, - }); - if (!res.ok) return null; - const data = (await res.json()) as { ip?: string }; - return data.ip ?? null; - } catch { - return null; - } -} - -router.get("/api/settings/proxy-test", async (c) => { - const denied = await guardRoute(c, "GET /api/settings/proxy-test"); - if (denied) return denied; - - const settings = await getSettings(DEGOOG_SETTINGS_ID); - const enabled = asString(settings.proxyEnabled) === "true"; - const proxyUrls = asString(settings.proxyUrls); - - const directIp = await fetchIp(fetch); - - if (!enabled || !proxyUrls.trim()) { - return c.json({ - enabled: false, - directIp, - proxyIp: null, - match: null, - }); - } - - const proxyIp = await fetchIp(outgoingFetch as typeof fetch); - - return c.json({ - enabled: true, - directIp, - proxyIp, - match: directIp !== null && proxyIp !== null && directIp === proxyIp, - }); -}); - -router.get("/api/settings/appearance", async (c) => { - const settings = await getSettings(DEGOOG_SETTINGS_ID); - return c.json({ - theme: asString(settings.defaultTheme) || "system", - }); -}); - -router.get("/api/settings/default-engines", async (c) => { - const denied = await guardRoute(c, "GET /api/settings/default-engines"); - if (denied) return denied; - try { - const raw = await readFile(defaultEnginesFile(), "utf-8"); - return c.json(JSON.parse(raw)); - } catch { - return c.json({}); - } -}); - -router.post("/api/settings/default-engines", async (c) => { - const denied = await guardRoute(c, "POST /api/settings/default-engines"); - if (denied) return denied; - let body: Record; - try { - body = await c.req.json>(); - } catch { - return c.json({ error: "Invalid JSON" }, 400); - } - await writeFile(defaultEnginesFile(), JSON.stringify(body, null, 2), "utf-8"); - return c.json({ ok: true }); -}); - export default router; diff --git a/src/server/routes/settings.ts b/src/server/routes/settings.ts index e03f9cb5..4455a210 100644 --- a/src/server/routes/settings.ts +++ b/src/server/routes/settings.ts @@ -1,9 +1,108 @@ +import { readFile, writeFile } from "fs/promises"; import { Hono } from "hono"; -import { asString, getSettings } from "../utils/plugin-settings"; +import { outgoingFetch } from "../utils/outgoing"; +import { defaultEnginesFile } from "../utils/paths"; +import { asString, getSettings, setSettings } from "../utils/plugin-settings"; +import { getRandomUserAgent } from "../utils/user-agents"; import { DEFAULT_LANGUAGES, DEGOOG_SETTINGS_ID } from "../utils/search"; +import { guardSettingsRoute } from "./settings-auth"; const router = new Hono(); +const GENERAL_ALLOWED_KEYS = [ + "proxyEnabled", + "proxyUrls", + "rateLimitEnabled", + "rateLimitBurstWindow", + "rateLimitBurstMax", + "rateLimitLongWindow", + "rateLimitLongMax", + "languagesEnabled", + "languages", + "streamingEnabled", + "streamingAutoRetry", + "streamingMaxRetries", + "postMethodEnabled", + "defaultTheme", + "domainBlockEnabled", + "domainBlockList", + "domainBlockUiEnabled", + "domainReplaceEnabled", + "domainReplaceList", + "domainReplaceUiEnabled", + "domainScoreEnabled", + "domainScoreList", + "domainScoreUiEnabled", + "customCss", +] as const; + +const _normalizeHostname = (raw: string): string => + raw + .trim() + .toLowerCase() + .replace(/^https?:\/\//, "") + .replace(/\/.*$/, ""); + +const _splitLines = (raw: string): string[] => + raw + .split("\n") + .map((l) => l.trim()) + .filter((l) => l.length > 0); + +const _appendBlock = (existing: string, source: string): string => { + const lines = _splitLines(existing); + if (lines.includes(source)) return existing; + lines.push(source); + return lines.join("\n"); +}; + +const _appendReplace = ( + existing: string, + source: string, + target: string, +): string => { + const next = _splitLines(existing).filter((l) => { + const [src] = l.split("->").map((s) => s.trim()); + return src !== source; + }); + next.push(`${source} -> ${target}`); + return next.join("\n"); +}; + +const _upsertScore = ( + existing: string, + source: string, + score: number, +): string => { + const next = _splitLines(existing).filter((l) => { + const [src] = l.split("|").map((s) => s.trim()); + return src !== source; + }); + next.push(`${source}|${score}`); + return next.join("\n"); +}; + +const IP_CHECK_URL = "https://api.ipify.org?format=json"; +const IP_CHECK_TIMEOUT_MS = 8_000; + +const fetchIp = async (useFn: typeof fetch): Promise => { + try { + const res = await useFn(IP_CHECK_URL, { + signal: AbortSignal.timeout(IP_CHECK_TIMEOUT_MS), + headers: { + "User-Agent": getRandomUserAgent(), + Accept: "application/json,text/plain,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.9", + }, + }); + if (!res.ok) return null; + const data = (await res.json()) as { ip?: string }; + return data.ip ?? null; + } catch { + return null; + } +}; + router.get("/api/settings/streaming", async (c) => { const settings = await getSettings(DEGOOG_SETTINGS_ID); return c.json({ @@ -26,4 +125,163 @@ router.get("/api/settings/languages", async (c) => { return c.json({ languages: codes.length > 0 ? codes : DEFAULT_LANGUAGES }); }); +router.get("/api/settings/general", async (c) => { + const denied = await guardSettingsRoute(c, "GET /api/settings/general"); + if (denied) return denied; + const settings = await getSettings(DEGOOG_SETTINGS_ID); + return c.json(settings); +}); + +router.post("/api/settings/general", async (c) => { + const denied = await guardSettingsRoute(c, "POST /api/settings/general"); + if (denied) return denied; + let body: Record; + try { + body = await c.req.json>(); + } catch { + return c.json({ error: "Invalid JSON" }, 400); + } + const existing = await getSettings(DEGOOG_SETTINGS_ID); + const updates: Record = {}; + for (const key of GENERAL_ALLOWED_KEYS) { + if (key in body && typeof body[key] === "string") { + updates[key] = body[key]; + } + } + await setSettings(DEGOOG_SETTINGS_ID, { ...existing, ...updates }); + return c.json({ ok: true }); +}); + +router.post("/api/settings/domain-action", async (c) => { + const denied = await guardSettingsRoute( + c, + "POST /api/settings/domain-action", + ); + if (denied) return denied; + + let body: { + kind?: string; + source?: string; + target?: string; + score?: number; + }; + try { + body = await c.req.json(); + } catch { + return c.json({ error: "Invalid JSON" }, 400); + } + + const kind = body.kind; + const source = _normalizeHostname(body.source ?? ""); + if (!source) return c.json({ error: "Missing source" }, 400); + + const existing = await getSettings(DEGOOG_SETTINGS_ID); + const updates: Record = {}; + + if (kind === "block") { + if (asString(existing.domainBlockUiEnabled) !== "true") { + return c.json({ error: "Forbidden" }, 403); + } + updates.domainBlockList = _appendBlock( + asString(existing.domainBlockList), + source, + ); + } else if (kind === "replace") { + if (asString(existing.domainReplaceUiEnabled) !== "true") { + return c.json({ error: "Forbidden" }, 403); + } + const target = _normalizeHostname(body.target ?? ""); + if (!target) return c.json({ error: "Missing target" }, 400); + updates.domainReplaceList = _appendReplace( + asString(existing.domainReplaceList), + source, + target, + ); + } else if (kind === "score") { + if (asString(existing.domainScoreUiEnabled) !== "true") { + return c.json({ error: "Forbidden" }, 403); + } + const score = Number(body.score); + if (!Number.isFinite(score)) { + return c.json({ error: "Invalid score" }, 400); + } + updates.domainScoreList = _upsertScore( + asString(existing.domainScoreList), + source, + Math.trunc(score), + ); + } else { + return c.json({ error: "Invalid kind" }, 400); + } + + await setSettings(DEGOOG_SETTINGS_ID, { ...existing, ...updates }); + return c.json({ ok: true }); +}); + +router.get("/api/settings/proxy-test", async (c) => { + const denied = await guardSettingsRoute(c, "GET /api/settings/proxy-test"); + if (denied) return denied; + + const settings = await getSettings(DEGOOG_SETTINGS_ID); + const enabled = asString(settings.proxyEnabled) === "true"; + const proxyUrls = asString(settings.proxyUrls); + + const directIp = await fetchIp(fetch); + + if (!enabled || !proxyUrls.trim()) { + return c.json({ + enabled: false, + directIp, + proxyIp: null, + match: null, + }); + } + + const proxyIp = await fetchIp(outgoingFetch as typeof fetch); + + return c.json({ + enabled: true, + directIp, + proxyIp, + match: directIp !== null && proxyIp !== null && directIp === proxyIp, + }); +}); + +router.get("/api/settings/appearance", async (c) => { + const settings = await getSettings(DEGOOG_SETTINGS_ID); + return c.json({ + theme: asString(settings.defaultTheme) || "system", + }); +}); + +router.get("/api/settings/default-engines", async (c) => { + const denied = await guardSettingsRoute( + c, + "GET /api/settings/default-engines", + ); + if (denied) return denied; + try { + const raw = await readFile(defaultEnginesFile(), "utf-8"); + return c.json(JSON.parse(raw)); + } catch { + return c.json({}); + } +}); + +router.post("/api/settings/default-engines", async (c) => { + const denied = await guardSettingsRoute( + c, + "POST /api/settings/default-engines", + ); + if (denied) return denied; + let body: Record; + try { + body = await c.req.json>(); + } catch { + return c.json({ error: "Invalid JSON" }, 400); + } + await writeFile(defaultEnginesFile(), JSON.stringify(body, null, 2), "utf-8"); + return c.json({ ok: true }); +}); + export default router; diff --git a/src/server/routes/suggest.ts b/src/server/routes/suggest.ts index 9f1837c1..798c2b1c 100644 --- a/src/server/routes/suggest.ts +++ b/src/server/routes/suggest.ts @@ -1,5 +1,6 @@ import { Hono } from "hono"; import type { SuggestPostBody } from "../types/search"; +import { _applyRateLimit } from "../utils/search"; const router = new Hono(); @@ -40,16 +41,27 @@ async function getSuggestions(query: string): Promise { } router.get("/api/suggest", async (c) => { + const limitRes = await _applyRateLimit(c); + if (limitRes) return limitRes; const query = c.req.query("q") ?? ""; return c.json(await getSuggestions(query)); }); router.post("/api/suggest", async (c) => { - const { query } = await c.req.json(); - return c.json(await getSuggestions(query ?? "")); + const limitRes = await _applyRateLimit(c); + if (limitRes) return limitRes; + let body: SuggestPostBody; + try { + body = await c.req.json(); + } catch { + return c.json({ error: "Invalid JSON" }, 400); + } + return c.json(await getSuggestions(body.query ?? "")); }); router.get("/api/suggest/opensearch", async (c) => { + const limitRes = await _applyRateLimit(c); + if (limitRes) return limitRes; const query = c.req.query("q") ?? ""; const suggestions = await getSuggestions(query); return c.json([query, suggestions], 200, { diff --git a/src/server/routes/themes.ts b/src/server/routes/themes.ts index 839b5e6f..91b60024 100644 --- a/src/server/routes/themes.ts +++ b/src/server/routes/themes.ts @@ -5,7 +5,10 @@ import { getActiveThemeId, setActiveTheme, } from "../extensions/themes/registry"; -import { getSettingsTokenFromRequest, validateSettingsToken } from "./settings-auth"; +import { + getSettingsTokenFromRequest, + validateSettingsToken, +} from "./settings-auth"; const router = new Hono(); @@ -27,7 +30,12 @@ router.post("/api/theme/active", async (c) => { const token = getSettingsTokenFromRequest(c); if (!(await validateSettingsToken(token))) return c.json({ error: "Unauthorized" }, 401); - const body = await c.req.json<{ id: string | null }>(); + let body: { id: string | null }; + try { + body = await c.req.json<{ id: string | null }>(); + } catch { + return c.json({ error: "Invalid JSON" }, 400); + } const ok = await setActiveTheme(body.id ?? null); if (!ok) return c.json({ error: "Theme not found" }, 400); return c.json({ ok: true, activeId: body.id }); diff --git a/src/server/utils/extension-docs.ts b/src/server/utils/extension-docs.ts index 99ae5a4e..67edf742 100644 --- a/src/server/utils/extension-docs.ts +++ b/src/server/utils/extension-docs.ts @@ -12,11 +12,13 @@ const _destDirFromId = (id: string): string | null => { return null; }; +const _SAFE_FOLDER = /^[A-Za-z0-9._-]+$/; + export const getExtensionReadmePath = (id: string): string | null => { const base = _destDirFromId(id); if (!base) return null; const folder = id.replace(/^[a-z-]+-/, ""); - if (!folder.trim()) return null; + if (!folder.trim() || !_SAFE_FOLDER.test(folder)) return null; return join(base, folder, "README.md"); }; diff --git a/src/server/utils/paths.ts b/src/server/utils/paths.ts index b29f7add..d915d2ef 100644 --- a/src/server/utils/paths.ts +++ b/src/server/utils/paths.ts @@ -23,3 +23,6 @@ export const pluginSettingsFile = (): string => export const defaultEnginesFile = (): string => process.env.DEGOOG_DEFAULT_ENGINES_FILE ?? join(_dataDir(), "default-engines.json"); + +export const settingsTokensFile = (): string => + process.env.DEGOOG_SETTINGS_TOKENS_FILE ?? join(_dataDir(), "settings-tokens.json"); diff --git a/src/server/utils/settings-tokens.ts b/src/server/utils/settings-tokens.ts new file mode 100644 index 00000000..ee1eaa38 --- /dev/null +++ b/src/server/utils/settings-tokens.ts @@ -0,0 +1,141 @@ +import { readFile, writeFile, rename } from "fs/promises"; +import { timingSafeEqual } from "crypto"; +import { logger } from "./logger"; +import { settingsTokensFile } from "./paths"; + +export const TOKEN_TTL_MS = 24 * 60 * 60 * 1000; + +const _validTokens = new Map(); + +let _persistTimer: ReturnType | null = null; +let _persisting = false; + +const _persistNow = async (): Promise => { + if (_persisting) { + if (!_persistTimer) schedulePersist(); + return; + } + _persisting = true; + const file = settingsTokensFile(); + const tmp = `${file}.tmp`; + try { + const snapshot = Object.fromEntries(_validTokens); + await writeFile(tmp, JSON.stringify(snapshot), "utf-8"); + await rename(tmp, file); + } catch (e) { + logger.warn( + "settings-auth", + `failed to persist tokens: ${e instanceof Error ? e.message : String(e)}`, + ); + } finally { + _persisting = false; + } +}; + +export const schedulePersist = (): void => { + if (_persistTimer) return; + _persistTimer = setTimeout(() => { + _persistTimer = null; + void _persistNow(); + }, 200); +}; + +const _loadPersistedTokens = async (): Promise => { + try { + const raw = await readFile(settingsTokensFile(), "utf-8"); + const data = JSON.parse(raw) as Record; + const now = Date.now(); + let loaded = 0; + for (const [token, expiresAt] of Object.entries(data)) { + if (typeof expiresAt === "number" && expiresAt > now) { + _validTokens.set(token, expiresAt); + loaded++; + } + } + if (loaded > 0) { + logger.debug("settings-auth", `restored ${loaded} persisted token(s)`); + } + } catch {} +}; + +void _loadPersistedTokens(); + +export const tokenStore = { + get: (token: string): number | undefined => _validTokens.get(token), + set: (token: string, expiresAt: number): void => { + _validTokens.set(token, expiresAt); + schedulePersist(); + }, + delete: (token: string): void => { + if (_validTokens.delete(token)) schedulePersist(); + }, + size: (): number => _validTokens.size, + pruneExpired: (): void => { + const now = Date.now(); + let pruned = 0; + for (const [token, expiresAt] of _validTokens) { + if (now > expiresAt) { + _validTokens.delete(token); + pruned++; + } + } + if (pruned > 0) schedulePersist(); + }, +}; + +export const generateSettingsToken = (): string => { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +}; + +const _safeEqual = (a: string, b: string): boolean => { + const aBuf = Buffer.from(a); + const bBuf = Buffer.from(b); + if (aBuf.length !== bBuf.length) { + timingSafeEqual(aBuf, aBuf); + return false; + } + return timingSafeEqual(aBuf, bBuf); +}; + +export const passwordMatches = ( + candidate: string, + allowed: string[], +): boolean => { + let matched = false; + for (const p of allowed) { + if (_safeEqual(candidate, p)) matched = true; + } + return matched; +}; + +const AUTH_RATE_WINDOW_MS = 60_000; +const AUTH_RATE_MAX_FAILURES = 10; +const _authAttempts = new Map(); + +export const checkAuthRate = ( + ip: string, +): { allowed: boolean; retryAfter: number } => { + const now = Date.now(); + const cutoff = now - AUTH_RATE_WINDOW_MS; + const attempts = (_authAttempts.get(ip) ?? []).filter((t) => t >= cutoff); + _authAttempts.set(ip, attempts); + if (attempts.length >= AUTH_RATE_MAX_FAILURES) { + const retryAfter = Math.ceil( + (attempts[0] + AUTH_RATE_WINDOW_MS - now) / 1000, + ); + return { allowed: false, retryAfter: Math.max(1, retryAfter) }; + } + return { allowed: true, retryAfter: 0 }; +}; + +export const recordAuthFailure = (ip: string): void => { + const now = Date.now(); + const cutoff = now - AUTH_RATE_WINDOW_MS; + const attempts = (_authAttempts.get(ip) ?? []).filter((t) => t >= cutoff); + attempts.push(now); + _authAttempts.set(ip, attempts); +}; diff --git a/tests/routes/gated-apis.test.ts b/tests/routes/gated-apis.test.ts index b4ae8840..3074636b 100644 --- a/tests/routes/gated-apis.test.ts +++ b/tests/routes/gated-apis.test.ts @@ -7,14 +7,20 @@ type Router = { const GATED_APIS: Array<{ method: "GET" | "POST" | "DELETE"; path: string; - routerKey: "store" | "settings-auth" | "themes" | "extensions" | "pages"; + routerKey: + | "store" + | "settings-auth" + | "settings" + | "themes" + | "extensions" + | "pages"; body?: string; }> = [ - { method: "GET", path: "/api/settings/general", routerKey: "settings-auth" }, + { method: "GET", path: "/api/settings/general", routerKey: "settings" }, { method: "POST", path: "/api/settings/general", - routerKey: "settings-auth", + routerKey: "settings", body: "{}", }, { @@ -83,17 +89,25 @@ let envRestore: string | undefined; beforeAll(async () => { envRestore = process.env.DEGOOG_PUBLIC_INSTANCE; process.env.DEGOOG_PUBLIC_INSTANCE = "true"; - const [storeMod, settingsAuthMod, themesMod, extensionsMod, pagesMod] = - await Promise.all([ - import("../../src/server/routes/store"), - import("../../src/server/routes/settings-auth"), - import("../../src/server/routes/themes"), - import("../../src/server/routes/extensions"), - import("../../src/server/routes/pages"), - ]); + const [ + storeMod, + settingsAuthMod, + settingsMod, + themesMod, + extensionsMod, + pagesMod, + ] = await Promise.all([ + import("../../src/server/routes/store"), + import("../../src/server/routes/settings-auth"), + import("../../src/server/routes/settings"), + import("../../src/server/routes/themes"), + import("../../src/server/routes/extensions"), + import("../../src/server/routes/pages"), + ]); routers = { store: storeMod.default, "settings-auth": settingsAuthMod.default, + settings: settingsMod.default, themes: themesMod.default, extensions: extensionsMod.default, pages: pagesMod.default, From 290c8f28a693416ff45d13773f7972ff92e61580 Mon Sep 17 00:00:00 2001 From: fccview Date: Fri, 1 May 2026 20:03:07 +0100 Subject: [PATCH 04/28] make extension descriptions scrollable --- src/styles/components/_store.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/src/styles/components/_store.scss b/src/styles/components/_store.scss index 04874e33..6ce90829 100644 --- a/src/styles/components/_store.scss +++ b/src/styles/components/_store.scss @@ -406,6 +406,7 @@ overflow: hidden; flex: 1; min-height: 0; + overflow-y: auto; } .store-card-version { From 85266fed61db01a474a96f09a997b6e68fb00faf Mon Sep 17 00:00:00 2001 From: fccview Date: Fri, 1 May 2026 20:05:28 +0100 Subject: [PATCH 05/28] make extension descriptions scrollable --- src/styles/components/_store.scss | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/styles/components/_store.scss b/src/styles/components/_store.scss index 6ce90829..c96dbfa5 100644 --- a/src/styles/components/_store.scss +++ b/src/styles/components/_store.scss @@ -400,12 +400,9 @@ color: var(--text-secondary); line-height: 1.45; display: -webkit-box; - line-clamp: 3; - -webkit-line-clamp: 3; - -webkit-box-orient: vertical; - overflow: hidden; flex: 1; min-height: 0; + max-height: 100px; overflow-y: auto; } From a8c80316e21641f9f626078fd9b824603e827341 Mon Sep 17 00:00:00 2001 From: fccview Date: Fri, 1 May 2026 20:19:50 +0100 Subject: [PATCH 06/28] store was using auth slightly different for no real reason just because I have done it before the rest --- src/server/routes/store.ts | 39 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/src/server/routes/store.ts b/src/server/routes/store.ts index fa6a81db..0465e466 100644 --- a/src/server/routes/store.ts +++ b/src/server/routes/store.ts @@ -1,6 +1,9 @@ -import { Context, Hono } from "hono"; +import { Hono } from "hono"; import { existsSync } from "fs"; -import { validateSettingsToken } from "./settings-auth"; +import { + getSettingsTokenFromRequest, + validateSettingsToken, +} from "./settings-auth"; import { resolve, relative } from "path"; import { @@ -24,10 +27,6 @@ import { ExtensionStoreType } from "../types"; const router = new Hono(); -function getStoreToken(c: Context): string | undefined { - return c.req.header("x-settings-token") ?? c.req.query("token"); -} - const VALID_TYPES: ExtensionStoreType[] = Object.values(ExtensionStoreType); function isValidType(type: string): type is ExtensionStoreType { @@ -35,14 +34,14 @@ function isValidType(type: string): type is ExtensionStoreType { } router.get("/api/store/repos", async (c) => { - if (!(await validateSettingsToken(getStoreToken(c)))) + if (!(await validateSettingsToken(getSettingsTokenFromRequest(c)))) return c.json({ error: "Unauthorized" }, 401); const repos = await getRepos(); return c.json({ repos }); }); router.get("/api/store/repos/:repoSlug/asset", async (c) => { - if (!(await validateSettingsToken(getStoreToken(c)))) + if (!(await validateSettingsToken(getSettingsTokenFromRequest(c)))) return c.json({ error: "Unauthorized" }, 401); const repoSlug = c.req.param("repoSlug"); const pathParam = c.req.query("path"); @@ -67,14 +66,14 @@ router.get("/api/store/repos/:repoSlug/asset", async (c) => { }); router.get("/api/store/repos/status", async (c) => { - if (!(await validateSettingsToken(getStoreToken(c)))) + if (!(await validateSettingsToken(getSettingsTokenFromRequest(c)))) return c.json({ error: "Unauthorized" }, 401); const statuses = await getReposStatus(); return c.json({ statuses }); }); router.post("/api/store/repos", async (c) => { - if (!(await validateSettingsToken(getStoreToken(c)))) + if (!(await validateSettingsToken(getSettingsTokenFromRequest(c)))) return c.json({ error: "Unauthorized" }, 401); const body = await c.req.json<{ url?: string }>(); const url = body?.url?.trim(); @@ -89,7 +88,7 @@ router.post("/api/store/repos", async (c) => { }); router.delete("/api/store/repos", async (c) => { - if (!(await validateSettingsToken(getStoreToken(c)))) + if (!(await validateSettingsToken(getSettingsTokenFromRequest(c)))) return c.json({ error: "Unauthorized" }, 401); const body = (await c.req.json<{ url?: string }>().catch(() => ({}))) as { url?: string; @@ -107,7 +106,7 @@ router.delete("/api/store/repos", async (c) => { }); router.post("/api/store/repos/refresh", async (c) => { - if (!(await validateSettingsToken(getStoreToken(c)))) + if (!(await validateSettingsToken(getSettingsTokenFromRequest(c)))) return c.json({ error: "Unauthorized" }, 401); const body = (await c.req.json<{ url?: string }>().catch(() => ({}))) as { url?: string; @@ -127,14 +126,14 @@ router.post("/api/store/repos/refresh", async (c) => { }); router.get("/api/store/items", async (c) => { - if (!(await validateSettingsToken(getStoreToken(c)))) + if (!(await validateSettingsToken(getSettingsTokenFromRequest(c)))) return c.json({ error: "Unauthorized" }, 401); const items = await listRepoItems(); return c.json({ items }); }); router.get("/api/store/items/:repoSlug", async (c) => { - if (!(await validateSettingsToken(getStoreToken(c)))) + if (!(await validateSettingsToken(getSettingsTokenFromRequest(c)))) return c.json({ error: "Unauthorized" }, 401); const repoSlug = c.req.param("repoSlug"); const repos = await getRepos(); @@ -145,7 +144,7 @@ router.get("/api/store/items/:repoSlug", async (c) => { }); router.post("/api/store/install", async (c) => { - if (!(await validateSettingsToken(getStoreToken(c)))) + if (!(await validateSettingsToken(getSettingsTokenFromRequest(c)))) return c.json({ error: "Unauthorized" }, 401); const body = await c.req.json<{ repoUrl?: string; @@ -169,7 +168,7 @@ router.post("/api/store/install", async (c) => { }); router.post("/api/store/uninstall", async (c) => { - if (!(await validateSettingsToken(getStoreToken(c)))) + if (!(await validateSettingsToken(getSettingsTokenFromRequest(c)))) return c.json({ error: "Unauthorized" }, 401); const body = await c.req.json<{ repoUrl?: string; @@ -193,7 +192,7 @@ router.post("/api/store/uninstall", async (c) => { }); router.post("/api/store/update", async (c) => { - if (!(await validateSettingsToken(getStoreToken(c)))) + if (!(await validateSettingsToken(getSettingsTokenFromRequest(c)))) return c.json({ error: "Unauthorized" }, 401); const body = await c.req.json<{ repoUrl?: string; @@ -217,7 +216,7 @@ router.post("/api/store/update", async (c) => { }); router.post("/api/store/update-all", async (c) => { - if (!(await validateSettingsToken(getStoreToken(c)))) + if (!(await validateSettingsToken(getSettingsTokenFromRequest(c)))) return c.json({ error: "Unauthorized" }, 401); try { const result = await updateAllItems(); @@ -229,7 +228,7 @@ router.post("/api/store/update-all", async (c) => { }); router.get("/api/store/installed", async (c) => { - if (!(await validateSettingsToken(getStoreToken(c)))) + if (!(await validateSettingsToken(getSettingsTokenFromRequest(c)))) return c.json({ error: "Unauthorized" }, 401); const installed = await getInstalledItems(); return c.json({ installed }); @@ -238,7 +237,7 @@ router.get("/api/store/installed", async (c) => { router.get( "/api/store/screenshots/:repoSlug/:type/:item/:filename", async (c) => { - if (!(await validateSettingsToken(getStoreToken(c)))) + if (!(await validateSettingsToken(getSettingsTokenFromRequest(c)))) return c.json({ error: "Unauthorized" }, 401); const repoSlug = c.req.param("repoSlug"); const typeParam = c.req.param("type"); From f2411705bc8ae8944eff7b444879e6bcccd6fa62 Mon Sep 17 00:00:00 2001 From: fccview Date: Sat, 2 May 2026 13:35:08 +0100 Subject: [PATCH 07/28] fix positioning of types --- src/client/{easter-eggs/types.ts => types/uovadipasqua.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/client/{easter-eggs/types.ts => types/uovadipasqua.ts} (100%) diff --git a/src/client/easter-eggs/types.ts b/src/client/types/uovadipasqua.ts similarity index 100% rename from src/client/easter-eggs/types.ts rename to src/client/types/uovadipasqua.ts From 3f7799a8718da5d7083c6d818842b9be93d5c3de Mon Sep 17 00:00:00 2001 From: lausten Date: Sat, 2 May 2026 15:59:35 +0200 Subject: [PATCH 08/28] Add proxy auth when using fetch --- src/public/settings.html | 2 +- src/server/utils/http-proxy-fetch.ts | 29 ++++++++++++++++++++++++---- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/public/settings.html b/src/public/settings.html index f5db6b6d..b73e6872 100644 --- a/src/public/settings.html +++ b/src/public/settings.html @@ -633,7 +633,7 @@

id="settings-proxy-urls" class="settings-proxy-urls" rows="4" - placeholder="http://proxy1:8080 socks5://proxy2:1080" + placeholder="http://proxy1:8080 http://user:pass@proxy2:8080 socks5://proxy3:1080" >