From 3bd58f805246ccd5e28ba09883e7331b404a40a7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 18 Sep 2026 08:28:32 +0000 Subject: [PATCH] feat: support configurable self-hosted API URLs Add a Self-hosted environment in extension options so a custom Curate host can be used without editing the manifest or rebuilding. URLs are validated and sanitized, and extra host access is requested at save time through optional_host_permissions. Fixes #14 Co-authored-by: David --- CHANGELOG.md | 1 + CONTRIBUTING.md | 4 +- README.md | 2 +- docs/architecture.md | 12 +- docs/development.md | 9 +- docs/roadmap.md | 2 +- docs/security-audit.md | 10 +- docs/store-readiness.md | 3 +- extension/README.md | 28 ++++- extension/manifest.json | 12 +- extension/options/options.css | 6 + extension/options/options.html | 19 ++- extension/options/options.js | 148 ++++++++++++++++++++-- scripts/build-extension.js | 10 ++ src/shared/api.js | 32 ++++- src/shared/apiUrl.js | 223 +++++++++++++++++++++++++++++++++ src/shared/browser.js | 22 ++++ src/shared/config.js | 97 +++++++------- src/shared/package.json | 3 + tests/unit/apiUrl.test.js | 205 ++++++++++++++++++++++++++++++ 20 files changed, 769 insertions(+), 79 deletions(-) create mode 100644 src/shared/apiUrl.js create mode 100644 src/shared/package.json create mode 100644 tests/unit/apiUrl.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index e057c21..f94a3a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ extension (`extension/manifest.json`). ### Added - Open-source contributor docs and GitHub community files (issues, PRs, CI, code of conduct) +- Self-hosted API base URL in extension options, with validation and optional host permissions ### Changed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4d76452..cb349bd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -61,7 +61,7 @@ npm run dev npm run build:extension ``` -Load `dist/extension/` as an unpacked extension. Point it at `http://localhost:3000` from the developer options page. +Load `dist/extension/` as an unpacked extension. Point it at `http://localhost:3000` (Development) or your own `https://` host (Self-hosted) from the developer options page. ### 4. Find or claim an issue @@ -73,7 +73,7 @@ Load `dist/extension/` as an unpacked extension. Point it at `http://localhost:3 Keep the PR to one change. Match the style of nearby files. Do not commit `.env`, secrets, or `node_modules`. -Do not widen extension permissions (`storage`, host permissions) without an issue that explains why. +Do not widen default extension permissions (`storage`, `host_permissions`) without an issue that explains why. Self-hosted API hosts must stay on `optional_host_permissions`. ### 6. Test diff --git a/README.md b/README.md index ff901b3..e70eb95 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ npm run dev npm run build:extension ``` -Load `dist/extension/` as unpacked in Chrome or Edge. In **Details → Extension options**, set environment to Development (`http://localhost:3000`). +Load `dist/extension/` as unpacked in Chrome or Edge. In **Details → Extension options**, set environment to Development (`http://localhost:3000`) or Self-hosted with your own `https://` API URL. Full walkthrough: [docs/development.md](docs/development.md). How to send a PR: [CONTRIBUTING.md](CONTRIBUTING.md). diff --git a/docs/architecture.md b/docs/architecture.md index d7915e5..782d017 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -48,7 +48,17 @@ Popup --Bearer JWT--> /api/v1 --> MongoDB Options page (same API, developer host override) ``` -There are **no content scripts**. The extension does not inject into web pages and does not read browsing history. Permissions are `storage` plus host access to the Curate API (production and localhost). +There are **no content scripts**. The extension does not inject into web pages and does not read browsing history. Default permissions are `storage` plus host access to the hosted Curate API and `http://localhost:3000`. A self-hosted API URL is opt-in: the options page validates the URL, then `chrome.permissions.request()` asks for that origin only via `optional_host_permissions`. Default `host_permissions` stay narrow on purpose. + +### Self-hosted API URLs + +| Environment | URL | Host access | +|-------------|-----|-------------| +| Production | Hosted Render URL (fixed) | Default `host_permissions` | +| Development | Loopback only (`localhost`, `127.0.0.1`, `[::1]`) | Default for port 3000; optional grant for other loopback ports | +| Self-hosted | Caller-supplied `https://` URL (no wildcards, credentials, query, or fragment) | Optional grant for that origin | + +`http://` is rejected except on loopback so `connect-src` can stay `https:` plus localhost variants instead of a blanket `*`. Path prefixes are kept (`https://example.com/curate` → `/curate/api/v1/...`). Validation lives in `src/shared/apiUrl.js`. ## Auth in brief diff --git a/docs/development.md b/docs/development.md index ba9ae04..7c9eacc 100644 --- a/docs/development.md +++ b/docs/development.md @@ -53,7 +53,13 @@ In Chrome (`chrome://extensions`) or Edge (`edge://extensions`): 2. Load unpacked. 3. Select `dist/extension/`. -Open **Details → Extension options** and set environment to **Development** (`http://localhost:3000`). That page is for developers. It is not in the popup. +Open **Details → Extension options** and choose an environment: + +- **Development** — `http://localhost:3000` (other loopback ports are allowed) +- **Production** — the hosted Curate API (fixed) +- **Self-hosted** — your own `https://` Curate URL. Chrome will prompt for access to that host only. + +That page is for developers and self-hosters. It is not in the popup. After you change popup, options, or `src/shared/` code: @@ -121,6 +127,7 @@ database). It covers: - `tests/api/bookmarks.test.js` - bookmark CRUD, ownership, validation - `tests/api/collections.test.js` - collection CRUD, ownership, validation - `tests/unit/validators.test.js` - `normalizeUrl`, `parseTags`, Joi schemas +- `tests/unit/apiUrl.test.js` - self-hosted API URL validation, storage resolution, manifest permissions Tests run against a throwaway MongoDB started in memory by `mongodb-memory-server` - no local MongoDB, `MONGO_URI`, or other setup is diff --git a/docs/roadmap.md b/docs/roadmap.md index cb0ed26..661da92 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -9,6 +9,7 @@ This is the direction of the project, not a contract. Features start as [issues] - Accounts in the popup (register, sign in, profile, password, delete) - `/api/v1` JSON API with Bearer JWT - Automated API tests (`node --test`) for auth, bookmarks, and collections, running in CI +- Configurable self-hosted API URLs via optional host permissions (no rebuild) - Light and dark theme - Product landing page and privacy policy - [Chrome Web Store](https://chromewebstore.google.com/detail/curate/nlkfmdiphacjgicdcagonbfnpcdjfapo) @@ -21,7 +22,6 @@ Work that would help the project most, in no strict order: - **Test coverage reporting** — a baseline and a documented way to run coverage locally - **Clearer empty and error states** in the popup, labeled as `good first issue` when they are small enough - **Token handling** — shorter-lived JWTs or a revocation path (see [security-audit.md](security-audit.md)) -- **Self-hosted API URLs** — `optional_host_permissions` so a custom host does not require a rebuild - **Contributor onboarding** — keep `good first issue` items specific (file paths and acceptance criteria) ## Considering diff --git a/docs/security-audit.md b/docs/security-audit.md index 297c359..193ca06 100644 --- a/docs/security-audit.md +++ b/docs/security-audit.md @@ -7,7 +7,7 @@ | Area | Status | Notes | |------|--------|-------| -| Manifest permissions | Pass | Only `storage` + scoped host permissions | +| Manifest permissions | Pass | `storage` + scoped default host permissions; self-hosted hosts are optional | | Secrets in bundle | Pass | No `.env`, JWT secret, or DB credentials in extension | | CSP | Pass | `script-src 'self'`, no inline scripts | | XSS / innerHTML | Mitigated | User content escaped before DOM insertion in popup/options | @@ -24,12 +24,13 @@ | `storage` | Persist auth token, theme, API URL preference | | `host_permissions` (production URL) | HTTPS API calls to deployed Curate backend | | `host_permissions` (localhost) | Local development only | +| `optional_host_permissions` | User-granted access to one self-hosted API origin | ## Findings -### Low - Static host permissions for custom API URLs +### Resolved - Custom API URLs no longer need a rebuild -Users who set a custom API base URL in options must also add that origin to `host_permissions` in `manifest.json` and rebuild. Documented in options UI and store readiness doc. +Self-hosted hosts are requested at runtime with `optional_host_permissions`. Default `host_permissions` stay limited to the hosted API and localhost. URLs are validated in `src/shared/apiUrl.js` (scheme required, no wildcards, `http://` only on loopback). ### Low - JWT in local storage @@ -50,8 +51,7 @@ Users re-authenticate after JWT expiry. ## Recommendations (future, not blocking) 1. Optional refresh tokens + server revocation list. -2. `optional_host_permissions` workflow for self-hosted API URLs. -3. Automated extension E2E tests in CI. +2. Automated extension E2E tests in CI. ## Web application diff --git a/docs/store-readiness.md b/docs/store-readiness.md index cc961c9..204efbd 100644 --- a/docs/store-readiness.md +++ b/docs/store-readiness.md @@ -33,7 +33,8 @@ The extension **does not**: Copy into store submission: > **storage** - Saves your login token and extension preferences on your device. -> **host_permissions** - Allows the extension to sync bookmarks with the Curate server. +> **host_permissions** - Allows the extension to sync bookmarks with the hosted Curate server and with localhost during development. +> **optional host permissions** - If you point the extension at your own Curate server, Chrome asks for access to that host only. Everyday installs never receive that grant. ## Privacy policy requirements diff --git a/extension/README.md b/extension/README.md index e4f20b7..48222d4 100644 --- a/extension/README.md +++ b/extension/README.md @@ -56,10 +56,29 @@ Output: `dist/extension/` 1. Copy `.env.example` → `.env` and set `JWT_SECRET`. 2. Start the web/API server: `npm run dev` -3. Open extension options from `chrome://extensions` (Details, then Extension options) and set environment to **Development** (`http://localhost:3000`). +3. Open extension options from `chrome://extensions` (Details, then Extension options) and set environment to **Development** (`http://localhost:3000`), or **Self-hosted** with your own `https://` API URL. 4. Rebuild after source changes: `npm run build:extension`, then reload the extension. -API connection (environment and base URL) is developer-only. Open it from `chrome://extensions` (Details, then Extension options). It is not shown in the popup. +API connection (environment and base URL) is for developers and self-hosters. Open it from `chrome://extensions` (Details, then Extension options). It is not shown in the popup. + +### Self-hosted API + +You do not need to edit `manifest.json` or rebuild to point the extension at your own Curate server. + +1. Load the unpacked extension (store build or `dist/extension/`). +2. Open **Details → Extension options**. +3. Choose **Self-hosted**. +4. Enter a full URL with a scheme, for example `https://curate.example.com`. +5. Save. Chrome or Edge will ask for permission to reach that host only. + +Rules the options page enforces: + +- Scheme is required (`https://`, or `http://` only for localhost / `127.0.0.1` / `[::1]`). +- No `*` wildcards, credentials, query strings, or fragments. +- A path prefix is kept (`https://example.com/curate` calls `/curate/api/v1/...`). +- Production stays locked to the hosted API so everyday installs do not silently switch hosts. + +Default `host_permissions` remain the hosted URL and `http://localhost:3000`. Extra hosts use `optional_host_permissions` plus `chrome.permissions.request()` at save time. The self-hosted server must allow `chrome-extension://` origins (the bundled CORS config already does). ## Scripts @@ -76,6 +95,7 @@ See [docs/architecture.md](../docs/architecture.md) and [docs/development.md](.. ## Permissions - `storage` - auth token and preferences -- Host permissions - Curate API (production + localhost for dev) +- Host permissions - hosted Curate API and `http://localhost:3000` +- Optional host permissions - a self-hosted origin the user grants at runtime -No content scripts. No broad site access. +No content scripts. No `` or other broad default host access. diff --git a/extension/manifest.json b/extension/manifest.json index 8ae54c7..bc0d08a 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -35,7 +35,17 @@ "http://localhost:3000/*", "https://curate-h0ga.onrender.com/*" ], + "optional_host_permissions": [ + "https://*/*", + "https://*:*/*", + "http://localhost/*", + "http://localhost:*/*", + "http://127.0.0.1/*", + "http://127.0.0.1:*/*", + "http://[::1]/*", + "http://[::1]:*/*" + ], "content_security_policy": { - "extension_pages": "script-src 'self'; object-src 'self'; style-src 'self' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; connect-src 'self' https://curate-h0ga.onrender.com http://localhost:3000" + "extension_pages": "script-src 'self'; object-src 'self'; style-src 'self' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; connect-src 'self' https: http://localhost:* http://127.0.0.1:* http://[::1]:*" } } \ No newline at end of file diff --git a/extension/options/options.css b/extension/options/options.css index 73143f8..7080137 100644 --- a/extension/options/options.css +++ b/extension/options/options.css @@ -52,6 +52,12 @@ body { margin: 0; } +.field input[readonly] { + background: var(--paper); + color: var(--muted); + cursor: default; +} + .hint code { background: var(--paper); padding: 2px 6px; diff --git a/extension/options/options.html b/extension/options/options.html index b74b7ea..4828f12 100644 --- a/extension/options/options.html +++ b/extension/options/options.html @@ -14,7 +14,7 @@

Developer

API connection

-

Point the extension at a local or custom Curate server. Users never see this page.

+

Point the extension at the hosted API, localhost, or your own Curate server. Users never see this page.

@@ -24,15 +24,26 @@

API connection

-

Custom URLs must be listed in host_permissions inside manifest.json.

+

diff --git a/extension/options/options.js b/extension/options/options.js index a31abe4..164dfe3 100644 --- a/extension/options/options.js +++ b/extension/options/options.js @@ -1,16 +1,35 @@ -import { api } from '../shared/api.js'; +import { probeApiHealth } from '../shared/api.js'; import { - getApiBaseUrl, + getApiConfig, setApiBaseUrl, - getEnvironment, DEFAULT_API_URLS, + sanitizeApiBaseUrl, + toHostPermissionPattern, + needsOptionalHostPermission, } from '../shared/config.js'; +import { + requestHostPermission, + removeHostPermission, +} from '../shared/browser.js'; import { getTheme } from '../shared/storage.js'; +const HINTS = { + production: + 'Uses the public Curate API. This URL is fixed so everyday installs stay on the hosted server.', + development: + 'Local Curate server. http:// is allowed only for localhost, 127.0.0.1, and [::1]. Other ports are fine; Chrome will ask for permission if the port is not 3000.', + selfhosted: + 'Your Curate instance. Use a full https:// URL with no wildcards. Chrome will prompt for access to that host only. Default permissions stay limited to the hosted API and localhost.', +}; + const statusEl = document.getElementById('status'); const settingsForm = document.getElementById('settings-form'); const environmentSelect = document.getElementById('environment'); const apiBaseUrlInput = document.getElementById('apiBaseUrl'); +const hintEl = document.getElementById('api-url-hint'); + +let lastSelfhostedUrl = ''; +let savedApiBaseUrl = ''; function showStatus(message, type = 'error') { statusEl.hidden = false; @@ -18,28 +37,131 @@ function showStatus(message, type = 'error') { statusEl.className = `status ${type}`; } +function resolveFormUrl() { + const environment = environmentSelect.value; + if (environment === 'production') { + return { ok: true, url: DEFAULT_API_URLS.production, environment }; + } + const sanitized = sanitizeApiBaseUrl(apiBaseUrlInput.value, { environment }); + if (!sanitized.ok) { + return sanitized; + } + return { ok: true, url: sanitized.url, environment }; +} + +function syncUrlField({ preserveTyped = true } = {}) { + const environment = environmentSelect.value; + apiBaseUrlInput.readOnly = environment === 'production'; + apiBaseUrlInput.required = environment !== 'production'; + apiBaseUrlInput.setAttribute('aria-readonly', String(environment === 'production')); + hintEl.textContent = HINTS[environment] || HINTS.production; + + if (environment === 'production') { + apiBaseUrlInput.value = DEFAULT_API_URLS.production; + return; + } + + if (environment === 'development') { + const current = apiBaseUrlInput.value.trim(); + const sanitized = sanitizeApiBaseUrl(current, { environment: 'development' }); + if (!preserveTyped || !sanitized.ok) { + apiBaseUrlInput.value = DEFAULT_API_URLS.development; + } + return; + } + + if (!preserveTyped || !apiBaseUrlInput.value.trim() || apiBaseUrlInput.readOnly) { + apiBaseUrlInput.value = lastSelfhostedUrl; + } +} + +async function ensureHostPermission(url) { + if (!needsOptionalHostPermission(url)) return true; + return requestHostPermission(toHostPermissionPattern(url)); +} + +async function releasePreviousHost(previousUrl, nextUrl) { + if (!previousUrl || previousUrl === nextUrl) return; + if (!needsOptionalHostPermission(previousUrl)) return; + if (toHostPermissionPattern(previousUrl) === toHostPermissionPattern(nextUrl)) return; + try { + await removeHostPermission(toHostPermissionPattern(previousUrl)); + } catch { + // Optional cleanup; keep going if Chrome still holds the old grant. + } +} + async function loadSettings() { - const env = await getEnvironment(); - environmentSelect.value = env; - apiBaseUrlInput.value = await getApiBaseUrl(); + const { environment, apiBaseUrl } = await getApiConfig(); + savedApiBaseUrl = apiBaseUrl; + if (environment === 'selfhosted') { + lastSelfhostedUrl = apiBaseUrl; + } + environmentSelect.value = environment; + apiBaseUrlInput.value = apiBaseUrl; + syncUrlField({ preserveTyped: true }); } environmentSelect.addEventListener('change', () => { - const env = environmentSelect.value; - apiBaseUrlInput.value = DEFAULT_API_URLS[env]; + if (environmentSelect.value !== 'selfhosted') { + const typed = apiBaseUrlInput.value.trim(); + const sanitized = sanitizeApiBaseUrl(typed); + if (sanitized.ok && !Object.values(DEFAULT_API_URLS).includes(sanitized.url)) { + lastSelfhostedUrl = sanitized.url; + } + } + syncUrlField({ preserveTyped: false }); +}); + +apiBaseUrlInput.addEventListener('input', () => { + if (environmentSelect.value === 'selfhosted') { + lastSelfhostedUrl = apiBaseUrlInput.value.trim(); + } }); settingsForm.addEventListener('submit', async (event) => { event.preventDefault(); - const env = environmentSelect.value; - const url = apiBaseUrlInput.value.trim(); - await setApiBaseUrl(url, env); - showStatus('Settings saved.', 'success'); + const resolved = resolveFormUrl(); + if (!resolved.ok) { + showStatus(resolved.error); + return; + } + + const granted = await ensureHostPermission(resolved.url); + if (!granted) { + showStatus('Permission denied. Chrome must allow this host before the extension can use it.'); + return; + } + + try { + const saved = await setApiBaseUrl(resolved.url, resolved.environment); + await releasePreviousHost(savedApiBaseUrl, saved); + savedApiBaseUrl = saved; + apiBaseUrlInput.value = saved; + if (resolved.environment === 'selfhosted') { + lastSelfhostedUrl = saved; + } + showStatus('Settings saved.', 'success'); + } catch (err) { + showStatus(err.message); + } }); document.getElementById('test-connection').addEventListener('click', async () => { + const resolved = resolveFormUrl(); + if (!resolved.ok) { + showStatus(resolved.error); + return; + } + + const granted = await ensureHostPermission(resolved.url); + if (!granted) { + showStatus('Permission denied. Allow this host to test the connection.'); + return; + } + try { - await api.health(); + await probeApiHealth(resolved.url); showStatus('Connection successful.', 'success'); } catch (err) { showStatus(err.message); diff --git a/scripts/build-extension.js b/scripts/build-extension.js index 35d8102..3274e45 100644 --- a/scripts/build-extension.js +++ b/scripts/build-extension.js @@ -14,6 +14,7 @@ const EXCLUDE = new Set([ '.env', '.env.local', 'node_modules', + 'package.json', ]); function copyEntry(src, dest) { @@ -49,6 +50,15 @@ function validateManifest(dir) { } }); + const hosts = manifest.host_permissions || []; + const broad = ['', '*://*/*', 'http://*/*', 'https://*/*']; + if (hosts.some((pattern) => broad.includes(pattern))) { + throw new Error('host_permissions must not use a broad wildcard'); + } + if (!Array.isArray(manifest.optional_host_permissions)) { + throw new Error('manifest.json missing optional_host_permissions'); + } + for (const size of ['16', '32', '48', '128']) { const iconPath = path.join(dir, manifest.icons[size]); if (!fs.existsSync(iconPath)) { diff --git a/src/shared/api.js b/src/shared/api.js index 93c7f38..2d2df2a 100644 --- a/src/shared/api.js +++ b/src/shared/api.js @@ -65,7 +65,9 @@ export async function apiRequest(path, options = {}) { return apiRequest(path, { ...fetchOptions, _retried: true }); } throw new ApiError( - `Could not reach ${baseUrl}. Open extension options, set Production (${baseUrl || 'https://curate-h0ga.onrender.com'}), save, then reload the extension.`, + `Could not reach ${baseUrl}. Open extension options, confirm the API URL` + + `${baseUrl ? '' : ' (Production is https://curate-h0ga.onrender.com)'} ` + + 'and host permission if you are self-hosting, save, then reload the extension.', 0 ); } finally { @@ -73,6 +75,34 @@ export async function apiRequest(path, options = {}) { } } +export async function probeApiHealth(baseUrl) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + + try { + const response = await fetch(`${baseUrl}${API_PREFIX}/health`, { + headers: { Accept: 'application/json' }, + credentials: 'omit', + signal: controller.signal, + }); + const data = await parseJson(response); + if (!response.ok) { + throw new ApiError(data.error || 'Request failed', response.status, data); + } + return data; + } catch (err) { + if (err.name === 'AbortError') { + throw new ApiError('Request timed out. The server may still be waking up. Try again.', 408); + } + if (err instanceof ApiError) { + throw err; + } + throw new ApiError(`Could not reach ${baseUrl}.`, 0); + } finally { + clearTimeout(timeout); + } +} + export const api = { health: () => apiRequest('/health'), login: (username, password) => diff --git a/src/shared/apiUrl.js b/src/shared/apiUrl.js new file mode 100644 index 0000000..34a2d4b --- /dev/null +++ b/src/shared/apiUrl.js @@ -0,0 +1,223 @@ +/** Default API base URLs - no secrets. Overridable in options. */ +export const DEFAULT_API_URLS = { + development: 'http://localhost:3000', + production: 'https://curate-h0ga.onrender.com', +}; + +export const API_PREFIX = '/api/v1'; + +export const ENVIRONMENTS = ['production', 'development', 'selfhosted']; + +const LEGACY_HOSTS = new Set([ + 'https://developer-bookmark-vault-5.onrender.com', +]); + +const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']); + +export class ApiUrlError extends Error { + constructor(message) { + super(message); + this.name = 'ApiUrlError'; + } +} + +export function normalizeEnvironment(value) { + if (value === 'development' || value === 'selfhosted') return value; + return 'production'; +} + +export function normalizeBaseUrl(url) { + return String(url || '').trim().replace(/\/+$/, ''); +} + +export function canonicalizeHostname(hostname) { + return String(hostname || '') + .toLowerCase() + .replace(/^\[|\]$/g, '') + .replace(/\.$/, ''); +} + +export function isLocalHostname(hostname) { + return LOCAL_HOSTNAMES.has(canonicalizeHostname(hostname)); +} + +export function isLocalApiUrl(url) { + try { + return isLocalHostname(new URL(normalizeBaseUrl(url)).hostname); + } catch { + return false; + } +} + +export function isLegacyApiUrl(url) { + return LEGACY_HOSTS.has(normalizeBaseUrl(url)); +} + +export function isProductionApiUrl(url) { + return normalizeBaseUrl(url) === DEFAULT_API_URLS.production; +} + +export function builtinApiOrigins() { + return Object.values(DEFAULT_API_URLS).map((url) => new URL(url).origin); +} + +/** + * Parse, validate, and sanitize an API base URL. + * http:// is only allowed for loopback hosts so CSP and optional + * permissions can stay scoped. + * + * @param {unknown} raw + * @param {{ environment?: string }} [options] + * @returns {{ ok: true, url: string, origin: string } | { ok: false, error: string }} + */ +export function sanitizeApiBaseUrl(raw, options = {}) { + const environment = options.environment + ? normalizeEnvironment(options.environment) + : undefined; + const trimmed = String(raw ?? '').trim(); + + if (!trimmed) { + return { ok: false, error: 'Enter an API base URL, including https:// or http://.' }; + } + + if (trimmed.includes('*')) { + return { ok: false, error: 'API URL cannot contain * wildcards. Use a specific hostname.' }; + } + + if (/[\s<>\\]/.test(trimmed)) { + return { ok: false, error: 'API URL cannot contain spaces or angle brackets.' }; + } + + let parsed; + try { + parsed = new URL(trimmed); + } catch { + return { + ok: false, + error: 'Enter a full URL with a scheme, for example https://curate.example.com.', + }; + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return { ok: false, error: 'API URL must use http:// or https://.' }; + } + + if (parsed.username || parsed.password) { + return { ok: false, error: 'API URL cannot include a username or password.' }; + } + + if (parsed.hash) { + return { ok: false, error: 'API URL cannot include a #fragment.' }; + } + + if (parsed.search) { + return { ok: false, error: 'API URL cannot include query parameters.' }; + } + + const hostname = parsed.hostname; + if (!hostname) { + return { ok: false, error: 'API URL must include a hostname.' }; + } + + if (hostname.includes('*')) { + return { ok: false, error: 'API URL must include a specific hostname, not a wildcard.' }; + } + + if (parsed.protocol === 'http:' && !isLocalHostname(hostname)) { + return { + ok: false, + error: 'http:// is only allowed for localhost, 127.0.0.1, or [::1]. Use https:// for a self-hosted server.', + }; + } + + if (environment === 'production') { + const production = new URL(DEFAULT_API_URLS.production); + return { ok: true, url: DEFAULT_API_URLS.production, origin: production.origin }; + } + + if (environment === 'development' && !isLocalHostname(hostname)) { + return { ok: false, error: 'Development must point at localhost, 127.0.0.1, or [::1].' }; + } + + const path = parsed.pathname === '/' ? '' : parsed.pathname.replace(/\/+$/, ''); + const url = `${parsed.origin}${path}`; + return { ok: true, url, origin: parsed.origin }; +} + +export function toHostPermissionPattern(url) { + return `${new URL(url).origin}/*`; +} + +export function needsOptionalHostPermission(url) { + try { + return !builtinApiOrigins().includes(new URL(url).origin); + } catch { + return true; + } +} + +/** + * Resolve stored environment + URL without touching chrome.storage. + * Callers persist `persist` when `shouldPersist` is true. + */ +export function resolveStoredApiConfig(stored = {}) { + const persist = {}; + const set = (patch) => Object.assign(persist, patch); + + if (!stored.apiHostMigratedToRender) { + set({ apiHostMigratedToRender: true }); + } + + const raw = typeof stored.apiBaseUrl === 'string' ? stored.apiBaseUrl : ''; + const sanitized = raw ? sanitizeApiBaseUrl(raw) : { ok: false }; + + if (!raw || isLegacyApiUrl(raw) || (sanitized.ok && isLegacyApiUrl(sanitized.url))) { + return finish(stored, persist, 'production', DEFAULT_API_URLS.production); + } + + let environment = stored.environment; + if (environment !== 'development' && environment !== 'selfhosted') { + if (sanitized.ok && isLocalApiUrl(sanitized.url)) { + environment = 'development'; + } else if (sanitized.ok && !isProductionApiUrl(sanitized.url)) { + environment = 'selfhosted'; + } else { + environment = 'production'; + } + } + + if (environment === 'development') { + const url = sanitized.ok && isLocalApiUrl(sanitized.url) + ? sanitized.url + : DEFAULT_API_URLS.development; + return finish(stored, persist, 'development', url); + } + + if (environment === 'selfhosted') { + if (sanitized.ok && !isLegacyApiUrl(sanitized.url)) { + return finish(stored, persist, 'selfhosted', sanitized.url); + } + return finish(stored, persist, 'production', DEFAULT_API_URLS.production); + } + + return finish(stored, persist, 'production', DEFAULT_API_URLS.production); +} + +function finish(stored, persist, environment, apiBaseUrl) { + persist.environment = environment; + persist.apiBaseUrl = apiBaseUrl; + + const next = {}; + for (const [key, value] of Object.entries(persist)) { + if (stored[key] !== value) { + next[key] = value; + } + } + + return { + environment, + apiBaseUrl, + persist: next, + shouldPersist: Object.keys(next).length > 0, + }; +} diff --git a/src/shared/browser.js b/src/shared/browser.js index 03bf76c..fd04a2c 100644 --- a/src/shared/browser.js +++ b/src/shared/browser.js @@ -19,3 +19,25 @@ export function getRuntime() { export function getStorageArea(area = 'local') { return getBrowser().storage[area]; } + +export function getPermissionsApi() { + return getBrowser().permissions || null; +} + +export async function requestHostPermission(originPattern) { + const permissions = getPermissionsApi(); + if (!permissions?.request) return true; + return permissions.request({ origins: [originPattern] }); +} + +export async function hasHostPermission(originPattern) { + const permissions = getPermissionsApi(); + if (!permissions?.contains) return false; + return permissions.contains({ origins: [originPattern] }); +} + +export async function removeHostPermission(originPattern) { + const permissions = getPermissionsApi(); + if (!permissions?.remove) return false; + return permissions.remove({ origins: [originPattern] }); +} diff --git a/src/shared/config.js b/src/shared/config.js index 1e315f7..50e1d86 100644 --- a/src/shared/config.js +++ b/src/shared/config.js @@ -1,25 +1,27 @@ -/** Default API base URLs - no secrets. Overridable in options. */ -export const DEFAULT_API_URLS = { - development: 'http://localhost:3000', - production: 'https://curate-h0ga.onrender.com', -}; +import { + DEFAULT_API_URLS, + normalizeEnvironment, + sanitizeApiBaseUrl, + resolveStoredApiConfig, + ApiUrlError, +} from './apiUrl.js'; -export const API_PREFIX = '/api/v1'; -export const REQUEST_TIMEOUT_MS = 30000; - -const LEGACY_HOSTS = [ - 'https://developer-bookmark-vault-5.onrender.com', -]; +export { + DEFAULT_API_URLS, + API_PREFIX, + ENVIRONMENTS, + ApiUrlError, + normalizeEnvironment, + normalizeBaseUrl, + sanitizeApiBaseUrl, + resolveStoredApiConfig, + toHostPermissionPattern, + needsOptionalHostPermission, +} from './apiUrl.js'; -function normalizeBaseUrl(url) { - return String(url || '').replace(/\/$/, ''); -} - -function isLocalHost(url) { - return /^(https?:\/\/)?(localhost|127\.0\.0\.1)(:\d+)?$/i.test(normalizeBaseUrl(url)); -} +export const REQUEST_TIMEOUT_MS = 30000; -export async function getApiBaseUrl() { +async function loadStoredApiConfig() { const { getStorageArea } = await import('./browser.js'); const storage = getStorageArea(); const stored = await storage.get([ @@ -27,42 +29,49 @@ export async function getApiBaseUrl() { 'environment', 'apiHostMigratedToRender', ]); - - if (!stored.apiHostMigratedToRender) { - await storage.set({ - apiBaseUrl: DEFAULT_API_URLS.production, - environment: 'production', - apiHostMigratedToRender: true, - }); - return DEFAULT_API_URLS.production; + const resolved = resolveStoredApiConfig(stored); + if (resolved.shouldPersist) { + await storage.set(resolved.persist); } + return resolved; +} - const env = stored.environment === 'development' ? 'development' : 'production'; - let url = normalizeBaseUrl(stored.apiBaseUrl); - - if (env === 'production') { - if (!url || isLocalHost(url) || LEGACY_HOSTS.includes(url)) { - url = DEFAULT_API_URLS.production; - await storage.set({ apiBaseUrl: url, environment: 'production' }); - } - return url; - } +export async function getApiConfig() { + const resolved = await loadStoredApiConfig(); + return { + apiBaseUrl: resolved.apiBaseUrl, + environment: resolved.environment, + }; +} - return url || DEFAULT_API_URLS.development; +export async function getApiBaseUrl() { + const { apiBaseUrl } = await getApiConfig(); + return apiBaseUrl; } export async function setApiBaseUrl(url, environment = 'production') { const { getStorageArea } = await import('./browser.js'); const storage = getStorageArea(); + const env = normalizeEnvironment(environment); + + let nextUrl = DEFAULT_API_URLS.production; + if (env !== 'production') { + const sanitized = sanitizeApiBaseUrl(url, { environment: env }); + if (!sanitized.ok) { + throw new ApiUrlError(sanitized.error); + } + nextUrl = sanitized.url; + } + await storage.set({ - apiBaseUrl: normalizeBaseUrl(url), - environment, + apiBaseUrl: nextUrl, + environment: env, + apiHostMigratedToRender: true, }); + return nextUrl; } export async function getEnvironment() { - const { getStorageArea } = await import('./browser.js'); - const storage = getStorageArea(); - const { environment } = await storage.get(['environment']); - return environment === 'development' ? 'development' : 'production'; + const { environment } = await getApiConfig(); + return environment; } diff --git a/src/shared/package.json b/src/shared/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/src/shared/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/tests/unit/apiUrl.test.js b/tests/unit/apiUrl.test.js new file mode 100644 index 0000000..7e7b248 --- /dev/null +++ b/tests/unit/apiUrl.test.js @@ -0,0 +1,205 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const PRODUCTION = 'https://curate-h0ga.onrender.com'; +const DEVELOPMENT = 'http://localhost:3000'; +const LEGACY = 'https://developer-bookmark-vault-5.onrender.com'; + +let apiUrl; + +test.before(async () => { + apiUrl = await import('../../src/shared/apiUrl.js'); +}); + +test('sanitizeApiBaseUrl rejects an empty value', () => { + const result = apiUrl.sanitizeApiBaseUrl(' '); + assert.equal(result.ok, false); + assert.match(result.error, /https:\/\/ or http:\/\//); +}); + +test('sanitizeApiBaseUrl rejects a URL without a scheme', () => { + const result = apiUrl.sanitizeApiBaseUrl('curate.example.com'); + assert.equal(result.ok, false); + assert.match(result.error, /full URL with a scheme/); +}); + +test('sanitizeApiBaseUrl rejects javascript: and other schemes', () => { + assert.equal(apiUrl.sanitizeApiBaseUrl('javascript:alert(1)').ok, false); + assert.equal(apiUrl.sanitizeApiBaseUrl('data:text/plain,hi').ok, false); + assert.equal(apiUrl.sanitizeApiBaseUrl('file:///etc/passwd').ok, false); +}); + +test('sanitizeApiBaseUrl rejects credentials, fragments, queries, and wildcards', () => { + assert.equal(apiUrl.sanitizeApiBaseUrl('https://user:pass@example.com').ok, false); + assert.equal(apiUrl.sanitizeApiBaseUrl('https://example.com/#frag').ok, false); + assert.equal(apiUrl.sanitizeApiBaseUrl('https://example.com/?q=1').ok, false); + assert.equal(apiUrl.sanitizeApiBaseUrl('https://*.example.com').ok, false); + assert.equal(apiUrl.sanitizeApiBaseUrl('https://example.com/*').ok, false); +}); + +test('sanitizeApiBaseUrl rejects http:// for non-loopback hosts', () => { + const result = apiUrl.sanitizeApiBaseUrl('http://curate.example.com'); + assert.equal(result.ok, false); + assert.match(result.error, /https:\/\//); +}); + +test('sanitizeApiBaseUrl accepts loopback http and any https host', () => { + assert.deepEqual(apiUrl.sanitizeApiBaseUrl('http://localhost:4000'), { + ok: true, + url: 'http://localhost:4000', + origin: 'http://localhost:4000', + }); + assert.equal(apiUrl.sanitizeApiBaseUrl('http://127.0.0.1:3000').ok, true); + assert.equal(apiUrl.sanitizeApiBaseUrl('http://[::1]:3000').ok, true); + assert.deepEqual(apiUrl.sanitizeApiBaseUrl('https://curate.example.com/'), { + ok: true, + url: 'https://curate.example.com', + origin: 'https://curate.example.com', + }); +}); + +test('sanitizeApiBaseUrl keeps a path prefix and strips a trailing slash', () => { + const result = apiUrl.sanitizeApiBaseUrl('https://example.com/curate/'); + assert.deepEqual(result, { + ok: true, + url: 'https://example.com/curate', + origin: 'https://example.com', + }); +}); + +test('sanitizeApiBaseUrl locks production to the hosted default', () => { + const result = apiUrl.sanitizeApiBaseUrl('https://curate.example.com', { + environment: 'production', + }); + assert.equal(result.ok, true); + assert.equal(result.url, PRODUCTION); +}); + +test('sanitizeApiBaseUrl rejects a remote host in development', () => { + const result = apiUrl.sanitizeApiBaseUrl('https://curate.example.com', { + environment: 'development', + }); + assert.equal(result.ok, false); + assert.match(result.error, /localhost/); +}); + +test('toHostPermissionPattern is origin-scoped', () => { + assert.equal( + apiUrl.toHostPermissionPattern('https://curate.example.com/curate'), + 'https://curate.example.com/*' + ); + assert.equal( + apiUrl.toHostPermissionPattern('http://127.0.0.1:4000'), + 'http://127.0.0.1:4000/*' + ); +}); + +test('needsOptionalHostPermission is false only for builtin origins', () => { + assert.equal(apiUrl.needsOptionalHostPermission(PRODUCTION), false); + assert.equal(apiUrl.needsOptionalHostPermission(DEVELOPMENT), false); + assert.equal(apiUrl.needsOptionalHostPermission('http://localhost:4000'), true); + assert.equal(apiUrl.needsOptionalHostPermission('https://curate.example.com'), true); +}); + +test('resolveStoredApiConfig defaults an empty store to production and persists the migration flag', () => { + const resolved = apiUrl.resolveStoredApiConfig({}); + assert.equal(resolved.environment, 'production'); + assert.equal(resolved.apiBaseUrl, PRODUCTION); + assert.equal(resolved.shouldPersist, true); + assert.equal(resolved.persist.apiHostMigratedToRender, true); + assert.equal(resolved.persist.apiBaseUrl, PRODUCTION); +}); + +test('resolveStoredApiConfig migrates a legacy host to production', () => { + const resolved = apiUrl.resolveStoredApiConfig({ + apiHostMigratedToRender: false, + environment: 'production', + apiBaseUrl: LEGACY, + }); + assert.equal(resolved.environment, 'production'); + assert.equal(resolved.apiBaseUrl, PRODUCTION); +}); + +test('resolveStoredApiConfig promotes a custom production URL to selfhosted', () => { + const resolved = apiUrl.resolveStoredApiConfig({ + apiHostMigratedToRender: true, + environment: 'production', + apiBaseUrl: 'https://curate.example.com/', + }); + assert.equal(resolved.environment, 'selfhosted'); + assert.equal(resolved.apiBaseUrl, 'https://curate.example.com'); + assert.equal(resolved.persist.environment, 'selfhosted'); +}); + +test('resolveStoredApiConfig promotes a localhost production URL to development', () => { + const resolved = apiUrl.resolveStoredApiConfig({ + apiHostMigratedToRender: true, + environment: 'production', + apiBaseUrl: 'http://127.0.0.1:3000', + }); + assert.equal(resolved.environment, 'development'); + assert.equal(resolved.apiBaseUrl, 'http://127.0.0.1:3000'); +}); + +test('resolveStoredApiConfig keeps a valid self-hosted URL', () => { + const resolved = apiUrl.resolveStoredApiConfig({ + apiHostMigratedToRender: true, + environment: 'selfhosted', + apiBaseUrl: 'https://curate.example.com', + }); + assert.equal(resolved.shouldPersist, false); + assert.equal(resolved.environment, 'selfhosted'); + assert.equal(resolved.apiBaseUrl, 'https://curate.example.com'); +}); + +test('resolveStoredApiConfig falls back when a self-hosted URL is invalid', () => { + const resolved = apiUrl.resolveStoredApiConfig({ + apiHostMigratedToRender: true, + environment: 'selfhosted', + apiBaseUrl: 'http://192.168.1.10:3000', + }); + assert.equal(resolved.environment, 'production'); + assert.equal(resolved.apiBaseUrl, PRODUCTION); +}); + +test('resolveStoredApiConfig does not rewrite a settled production install', () => { + const resolved = apiUrl.resolveStoredApiConfig({ + apiHostMigratedToRender: true, + environment: 'production', + apiBaseUrl: PRODUCTION, + }); + assert.equal(resolved.shouldPersist, false); + assert.deepEqual(resolved.persist, {}); +}); + +test('resolveStoredApiConfig resets a remote URL stored as development', () => { + const resolved = apiUrl.resolveStoredApiConfig({ + apiHostMigratedToRender: true, + environment: 'development', + apiBaseUrl: 'https://curate.example.com', + }); + assert.equal(resolved.environment, 'development'); + assert.equal(resolved.apiBaseUrl, DEVELOPMENT); +}); + +test('manifest keeps default host access narrow and lists optional hosts', () => { + const manifestPath = path.join(__dirname, '../../extension/manifest.json'); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + const broad = ['', '*://*/*', 'http://*/*', 'https://*/*']; + + assert.ok(Array.isArray(manifest.host_permissions)); + assert.ok(manifest.host_permissions.every((pattern) => !broad.includes(pattern))); + assert.ok(manifest.host_permissions.includes('https://curate-h0ga.onrender.com/*')); + assert.ok(manifest.host_permissions.includes('http://localhost:3000/*')); + + assert.ok(Array.isArray(manifest.optional_host_permissions)); + assert.ok(manifest.optional_host_permissions.includes('https://*/*')); + assert.ok(manifest.optional_host_permissions.includes('http://localhost:*/*')); + + const csp = manifest.content_security_policy.extension_pages; + assert.match(csp, /script-src 'self'/); + assert.match(csp, /connect-src[^;]*https:/); + assert.doesNotMatch(csp, /connect-src \*/); +});