Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5f90cdb4f |
@@ -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 `<script src="/library-desk/static/wikijs-integration.js">`.
|
||||
- Machine callers (the Scheduler) continue to use the Bearer API key against the container-network endpoints; only the two browser endpoints switched to proxy auth.
|
||||
|
||||
## [1.8.1] - 2026-07-19
|
||||
|
||||
### Changed
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "library-desk"
|
||||
version = "1.8.1"
|
||||
version = "1.9.0"
|
||||
description = "Coordination service for The Library system - HybridRAG queries, document ingestion, entity extraction, and knowledge consolidation"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
@@ -765,7 +765,8 @@ def get_volatile_cache_service() -> "VolatileCacheService":
|
||||
|
||||
|
||||
# Authentication
|
||||
from fastapi import Security, HTTPException
|
||||
import secrets
|
||||
from fastapi import Security, HTTPException, Request
|
||||
from fastapi.security import HTTPBearer
|
||||
|
||||
security = HTTPBearer()
|
||||
@@ -788,7 +789,7 @@ async def verify_api_key(
|
||||
Raises:
|
||||
HTTPException: If API key is invalid
|
||||
"""
|
||||
if credentials.credentials != settings.library_api_key:
|
||||
if not secrets.compare_digest(credentials.credentials, settings.library_api_key):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Invalid API key"
|
||||
@@ -796,6 +797,42 @@ async def verify_api_key(
|
||||
return credentials.credentials
|
||||
|
||||
|
||||
# Header set by NPM only on the authenticated /library-desk/ proxy location.
|
||||
# library-desk is bound to loopback (127.0.0.1:8089), so NPM is the only path
|
||||
# that can reach it and set this header — a client cannot forge it. NPM also
|
||||
# overwrites any client-supplied value via proxy_set_header.
|
||||
_PROXY_MARKER_HEADER = "x-library-desk-proxy"
|
||||
|
||||
|
||||
async def verify_browser_request(
|
||||
request: Request,
|
||||
settings: SettingsDep,
|
||||
) -> str:
|
||||
"""
|
||||
Auth for browser-facing endpoints (the Wiki.js integration buttons).
|
||||
|
||||
Accepts the request when it arrives through the authenticated NPM proxy
|
||||
location (Authentik session for external users, or the LAN bypass for
|
||||
internal ones) — identified by the trusted proxy marker header. No secret
|
||||
is carried in the browser. Machine callers may still authenticate with the
|
||||
Bearer API key. Returns the acting user's identity.
|
||||
"""
|
||||
if request.headers.get(_PROXY_MARKER_HEADER) == "1":
|
||||
# Authentik injects the identity for externally-authenticated users;
|
||||
# on the LAN bypass these are empty and the endpoint falls back to the
|
||||
# user supplied in the request body.
|
||||
return request.headers.get("x-authentik-email") or "lan"
|
||||
|
||||
# Fallback: server-to-server Bearer API key.
|
||||
auth = request.headers.get("authorization", "")
|
||||
if auth.startswith("Bearer ") and secrets.compare_digest(
|
||||
auth[len("Bearer "):], settings.library_api_key
|
||||
):
|
||||
return auth[len("Bearer "):]
|
||||
|
||||
raise HTTPException(status_code=401, detail="Unauthenticated")
|
||||
|
||||
|
||||
# Service type aliases for FastAPI endpoint dependencies
|
||||
# These are defined after the factory functions
|
||||
from src.services.vector_service import VectorService
|
||||
|
||||
@@ -18,7 +18,7 @@ from src.core.dependencies import (
|
||||
get_wiki_service,
|
||||
get_graph_service,
|
||||
get_ingestion_service,
|
||||
verify_api_key
|
||||
verify_browser_request,
|
||||
)
|
||||
from src.services.wiki_service import WikiService
|
||||
from src.services.graph_service import GraphService
|
||||
@@ -57,7 +57,7 @@ async def link_entities_in_page(
|
||||
wiki_service: WikiService = Depends(get_wiki_service),
|
||||
graph_service: GraphService = Depends(get_graph_service),
|
||||
ingestion_service: IngestionService = Depends(get_ingestion_service),
|
||||
api_key: str = Depends(verify_api_key)
|
||||
actor: str = Depends(verify_browser_request)
|
||||
) -> EntityLinkingResult:
|
||||
"""
|
||||
Find and link entities mentioned in a wiki page.
|
||||
|
||||
@@ -15,7 +15,8 @@ from src.models.ingestion import (
|
||||
BatchIngestionResult
|
||||
)
|
||||
from src.core.dependencies import (
|
||||
get_ingestion_service, verify_api_key, RequiredUserQuery, JobManagerDep
|
||||
get_ingestion_service, verify_api_key, verify_browser_request,
|
||||
RequiredUserQuery, JobManagerDep
|
||||
)
|
||||
from src.jobs.job_manager import JobManager, JobStatus, JobType
|
||||
|
||||
@@ -65,7 +66,7 @@ async def ingest_page(
|
||||
request: IngestionRequest,
|
||||
ingestion: IngestionService = Depends(get_ingestion_service),
|
||||
job_manager: JobManagerDep = None,
|
||||
api_key: str = Depends(verify_api_key)
|
||||
actor: str = Depends(verify_browser_request)
|
||||
):
|
||||
"""
|
||||
Ingest a single wiki page into the knowledge base.
|
||||
|
||||
@@ -2,21 +2,28 @@
|
||||
* Library Desk Integration for Wiki.js
|
||||
* Combined re-index and entity linking buttons
|
||||
*
|
||||
* Usage: Add to Wiki.js Code Injection:
|
||||
* <script src="http://192.168.86.149:8089/static/wikijs-integration.js"></script>
|
||||
* Usage: Add to Wiki.js Code Injection (served same-origin behind Authentik):
|
||||
* <script src="/library-desk/static/wikijs-integration.js"></script>
|
||||
*
|
||||
* 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 <origin>/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({
|
||||
|
||||
@@ -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)
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user