diff --git a/CHANGELOG.md b/CHANGELOG.md index 2096f28..ade9f73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.9.0] - 2026-07-20 + +### Security + +- The Wiki.js integration buttons no longer embed an API key in the browser. The re-index and entity-link endpoints now authenticate via the NPM `/library-desk/` proxy (Authentik session for external users, LAN bypass for internal), verified by a trusted proxy marker header. The previously-embedded key was a full-privilege key served to every wiki visitor; it has been rotated out of service. +- `verify_api_key` now uses a constant-time comparison. + +### Changed + +- `static/wikijs-integration.js` calls library-desk same-origin (`/library-desk/...`) with `credentials: same-origin` and no `Authorization` header. Update the Wiki.js code-injection snippet to ` + * Usage: Add to Wiki.js Code Injection (served same-origin behind Authentik): + * + * + * Auth: none in the browser. Requests go same-origin through the NPM + * /library-desk/ location, which is gated by Authentik forward-auth with the + * LAN bypass — external users are authenticated, LAN users pass through, and + * library-desk trusts the proxy marker header. No API key is embedded here. */ (function() { 'use strict'; - // Auto-detect Library Desk URL + // Same-origin base: the script is served from /library-desk/static/..., + // so strip '/static/...' to get the library-desk mount point on this origin. const scriptTag = document.currentScript; const scriptUrl = scriptTag ? scriptTag.src : ''; - const libraryDeskUrl = scriptUrl ? scriptUrl.split('/static/')[0] : 'http://192.168.86.149:8089'; + const libraryDeskUrl = scriptUrl + ? scriptUrl.replace(/^https?:\/\/[^/]+/, '').split('/static/')[0] + : '/library-desk'; // Shared configuration const CONFIG = window.LIBRARY_DESK_CONFIG || { libraryDeskUrl: libraryDeskUrl, - apiKey: 'af88ed8f44bed81bdb20d0534f1c4547340b29e2aba4963f61a71b993d7eb6e5', user: 'jpmschweitzer', buttonPosition: 'toolbar', // 'toolbar' or 'floating' debug: true @@ -229,8 +236,8 @@ // Re-index directly const response = await fetch(CONFIG.libraryDeskUrl + '/ingest/page', { method: 'POST', + credentials: 'same-origin', headers: { - 'Authorization': 'Bearer ' + CONFIG.apiKey, 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -334,8 +341,8 @@ // Call entity linking endpoint const response = await fetch(CONFIG.libraryDeskUrl + '/entity-linking/link-page', { method: 'POST', + credentials: 'same-origin', headers: { - 'Authorization': 'Bearer ' + CONFIG.apiKey, 'Content-Type': 'application/json' }, body: JSON.stringify({ diff --git a/tests/test_browser_auth.py b/tests/test_browser_auth.py new file mode 100644 index 0000000..3657175 --- /dev/null +++ b/tests/test_browser_auth.py @@ -0,0 +1,57 @@ +""" +Tests for verify_browser_request — the session/proxy auth used by the +Wiki.js integration endpoints (no secret in the browser). +""" +import pytest +from types import SimpleNamespace +from fastapi import HTTPException +from starlette.requests import Request + +from src.core.dependencies import verify_browser_request + + +def _request(headers: dict) -> Request: + raw = [(k.lower().encode(), v.encode()) for k, v in headers.items()] + return Request({"type": "http", "method": "POST", "path": "/ingest/page", "headers": raw}) + + +SETTINGS = SimpleNamespace(library_api_key="server-secret-key") + + +@pytest.mark.asyncio +async def test_proxy_marker_with_authentik_identity_is_accepted(): + req = _request({"X-Library-Desk-Proxy": "1", "X-Authentik-Email": "user@example.com"}) + assert await verify_browser_request(req, SETTINGS) == "user@example.com" + + +@pytest.mark.asyncio +async def test_proxy_marker_on_lan_bypass_falls_back_to_lan(): + # LAN bypass: proxy marker present, no Authentik identity headers. + req = _request({"X-Library-Desk-Proxy": "1"}) + assert await verify_browser_request(req, SETTINGS) == "lan" + + +@pytest.mark.asyncio +async def test_valid_api_key_is_accepted_for_machine_callers(): + req = _request({"Authorization": "Bearer server-secret-key"}) + assert await verify_browser_request(req, SETTINGS) == "server-secret-key" + + +@pytest.mark.asyncio +async def test_no_marker_and_no_key_is_rejected(): + with pytest.raises(HTTPException) as exc: + await verify_browser_request(_request({}), SETTINGS) + assert exc.value.status_code == 401 + + +@pytest.mark.asyncio +async def test_forged_marker_value_is_rejected(): + # Only the exact NPM-set value "1" is trusted. + with pytest.raises(HTTPException): + await verify_browser_request(_request({"X-Library-Desk-Proxy": "yes"}), SETTINGS) + + +@pytest.mark.asyncio +async def test_wrong_api_key_is_rejected(): + with pytest.raises(HTTPException): + await verify_browser_request(_request({"Authorization": "Bearer wrong"}), SETTINGS) diff --git a/tests/test_required_user.py b/tests/test_required_user.py index 4e19f0e..6b69d35 100644 --- a/tests/test_required_user.py +++ b/tests/test_required_user.py @@ -12,19 +12,21 @@ import pytest from fastapi.testclient import TestClient from src.main import app -from src.core.dependencies import verify_api_key +from src.core.dependencies import verify_api_key, verify_browser_request @pytest.fixture(scope="module") def client(): - """TestClient with API-key auth stubbed out (no lifespan startup).""" + """TestClient with auth stubbed out (no lifespan startup).""" app.dependency_overrides[verify_api_key] = lambda: "test-key" + app.dependency_overrides[verify_browser_request] = lambda: "test-user" try: # No context manager: startup/lifespan events are NOT triggered, # so no connections to external services are attempted. yield TestClient(app) finally: app.dependency_overrides.pop(verify_api_key, None) + app.dependency_overrides.pop(verify_browser_request, None) QUERY_PARAM_ENDPOINTS = [