diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 362f1cb..cbd5791 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,7 +80,7 @@ jobs: run: | docker compose config --quiet # the production stack refuses to start without its secrets; give it placeholders to validate - SECRET_KEY=x ARCADEDB_ROOT_PASSWORD=x ARCADEDB_APP_PASSWORD=x REDIS_PASSWORD=x \ + SECRET_KEY=x SETUP_TOKEN=x ARCADEDB_ROOT_PASSWORD=x ARCADEDB_APP_PASSWORD=x REDIS_PASSWORD=x \ docker compose -f deploy/docker-compose.yml config --quiet - name: Build the local stack's images run: docker compose build diff --git a/.gitignore b/.gitignore index 27d37a6..64eb753 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ venv/ # Local data — never commit user data or databases backend/data/*.db backend/data/*.json +deploy/backups/ # Editor .idea/ diff --git a/CHANGELOG.md b/CHANGELOG.md index c667978..07a4034 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,13 @@ Notable changes to Knowledge Hubs. The format follows [Keep a Changelog](https:/ - A "Source code" link in the app's sidebar, as the AGPL requires for network use. - A screenshot of every page in `docs/images/`, a [screenshot gallery](docs/screenshots.md), and an animated tour at the top of the README. +- `SETUP_TOKEN`: registering the first account, which becomes the server admin, needs this token. The production stack requires it. +- Sign-in and sign-up throttling (`AUTH_RATE_LIMIT`): repeated failed sign-ins get 429 with `Retry-After`. Counters are shared through Redis across app instances. +- **Sign out** revokes the session on the server (`POST /auth/logout`): every token of the account stops working. +- A Content Security Policy and a `Permissions-Policy` header from the web server. +- `API_DOCS`: `false` hides `/docs`, `/redoc` and `/openapi.json`; the production stack sets it. +- [`deploy/backup.sh`](deploy/backup.sh): a consistent ArcadeDB backup copied to `deploy/backups/`, with old copies pruned. The deployment guide describes restoring. + ### Changed - **License: GPL-3.0 → AGPL-3.0.** - Angular 17 → 21. Building the frontend needs Node 20.19+ or 22.12+; the Docker images use Node 22. @@ -20,6 +27,8 @@ Notable changes to Knowledge Hubs. The format follows [Keep a Changelog](https:/ - Docker base images: Caddy 2.10, Node 22. ### Fixed +- URL fetching could be redirected to a private address by DNS rebinding. The address is now checked again when connecting, and the connection goes to exactly that address. +- The production frontend build no longer loads its stylesheet through an inline `onload` handler (`inlineCritical` off), which the Content Security Policy would block. - Reloading a page whose path is also an API path (such as `/knowledge`) made the app show "Failed to load knowledge": the browser reused the cached page for the app's data request. The web server now sends `Vary: Accept`. ### Removed diff --git a/backend/.env.example b/backend/.env.example index d7e25cb..745d600 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -19,10 +19,21 @@ CREDENTIALS_KEY= # CONNECTOR_ROOTS=C:\Users\me\Meetings;C:\code CONNECTOR_ROOTS= -# The first account created is the admin; everyone after is a member. +# The first account created is the admin; everyone after is a member. Set SETUP_TOKEN +# on any server others can reach: registering that first account then asks for it. +# python -c "import secrets; print(secrets.token_urlsafe(24))" +SETUP_TOKEN= + # false = only an admin can create accounts (Users page / POST /admin/users). ALLOW_REGISTRATION=true +# Sign-in throttling: 5 failed attempts per account and 30 in all per address every +# 15 minutes, 20 sign-ups per address an hour. false turns it off. +AUTH_RATE_LIMIT=true + +# Interactive API docs at /docs, /redoc and /openapi.json (false hides them) +API_DOCS=true + # ── Inactivity retention ────────────────────────────────────────────────────── # Accounts and groups unused for this many days are DELETED with all their data # (checked at startup and then every RETENTION_CHECK_HOURS). The last server admin is diff --git a/backend/app/auth.py b/backend/app/auth.py index d662716..a1637eb 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -60,9 +60,15 @@ def verify_password(plain: str, hashed: str) -> bool: return False -def create_token(user_id: str) -> str: +def create_token(user_id: str, token_version: int = 0) -> str: + """ + `tv` is the account's session version when the token was issued. Signing + out bumps the version (POST /auth/logout), which invalidates every token + issued before, so a stolen token stops working once the user signs out. + """ payload = { "sub": user_id, + "tv": token_version, "exp": datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES), } return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM) @@ -86,7 +92,8 @@ async def get_current_user( raise credentials_error user = await UserRepository(session).get(user_id) - if not user: + # tokens from before session versions existed carry no `tv`: they match version 0 + if not user or payload.get("tv", 0) != (user.get("token_version") or 0): raise credentials_error await activity.touch_user(session.client, user) # at most once an hour; see services/retention.py # every LLM call made while handling this request uses the user's chosen model diff --git a/backend/app/main.py b/backend/app/main.py index caf39cc..c10ba36 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -6,6 +6,7 @@ import asyncio import contextlib import logging +import os from contextlib import asynccontextmanager from typing import AsyncIterator @@ -46,7 +47,14 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: await close_db() -app = FastAPI(title="Knowledge Hubs", lifespan=lifespan) +def api_docs_enabled() -> bool: + """API_DOCS=false hides /docs, /redoc and /openapi.json (the production stack does).""" + return os.getenv("API_DOCS", "true").strip().lower() not in ("false", "0", "no", "off") + + +_docs = api_docs_enabled() +app = FastAPI(title="Knowledge Hubs", lifespan=lifespan, docs_url="/docs" if _docs else None, + redoc_url="/redoc" if _docs else None, openapi_url="/openapi.json" if _docs else None) @app.exception_handler(QueueUnavailable) diff --git a/backend/app/ratelimit.py b/backend/app/ratelimit.py new file mode 100644 index 0000000..5194a95 --- /dev/null +++ b/backend/app/ratelimit.py @@ -0,0 +1,151 @@ +""" +Throttles password guessing on sign-in and mass sign-ups. + +Fixed-window counters, keyed by client address (and, for sign-in, the +username tried). In worker mode (REDIS_URL set) they live in Redis, so every +app instance behind the load balancer counts the same attempts; otherwise +they are in memory, which is enough for a single process. + +Only *failed* sign-ins count, and a success clears that address's counter for +that account, so someone who mistypes a password once isn't penalised. The +per-account limit is keyed by address too: a stranger can't lock a user out +from elsewhere by failing on purpose. + +If Redis is unreachable the limiter lets requests through (and logs it): +sign-in must keep working while the queue is down, as everything else does. + +AUTH_RATE_LIMIT=false turns it off (the test suite does; its own tests turn +it back on). +""" +from __future__ import annotations + +import logging +import math +import os +import time +from typing import Dict, Tuple + +from fastapi import HTTPException, Request + +from app import coordination + +logger = logging.getLogger(__name__) + +LOGIN_WINDOW = 15 * 60 +LOGIN_FAILURES_PER_ACCOUNT = 5 # per address and username +LOGIN_FAILURES_PER_ADDRESS = 30 # per address, any username +REGISTER_WINDOW = 60 * 60 +REGISTRATIONS_PER_ADDRESS = 20 + +_local: Dict[str, Tuple[int, float]] = {} # key -> (count, window ends at) +_LOCAL_MAX_KEYS = 10_000 + + +def enabled() -> bool: + return os.getenv("AUTH_RATE_LIMIT", "true").strip().lower() not in ("false", "0", "no", "off") + + +def client_address(request: Request) -> str: + # behind the load balancer, uvicorn's --proxy-headers has already replaced this + # with the address Caddy saw (Caddy discards X-Forwarded-For sent by clients) + return request.client.host if request.client else "unknown" + + +def reset() -> None: + """Forget every in-memory counter (tests).""" + _local.clear() + + +def _prune(now: float) -> None: + if len(_local) > _LOCAL_MAX_KEYS: + for key in [k for k, (_, ends) in _local.items() if ends <= now]: + del _local[key] + + +async def _count(key: str) -> Tuple[int, int]: + """(attempts so far, seconds until the window resets).""" + if coordination.worker_mode(): + client = coordination._redis() + count, ttl = await client.get(f"kh:rl:{key}"), await client.ttl(f"kh:rl:{key}") + return int(count or 0), max(int(ttl), 1) + count, ends = _local.get(key, (0, 0.0)) + now = time.monotonic() + if ends <= now: + return 0, 0 + return count, max(math.ceil(ends - now), 1) + + +async def _hit(key: str, window: int) -> None: + if coordination.worker_mode(): + client = coordination._redis() + async with client.pipeline(transaction=True) as pipe: + pipe.incr(f"kh:rl:{key}") + pipe.expire(f"kh:rl:{key}", window, nx=True) + await pipe.execute() + return + now = time.monotonic() + _prune(now) + count, ends = _local.get(key, (0, 0.0)) + if ends <= now: + count, ends = 0, now + window + _local[key] = (count + 1, ends) + + +async def _clear(key: str) -> None: + if coordination.worker_mode(): + await coordination._redis().delete(f"kh:rl:{key}") + return + _local.pop(key, None) + + +def _too_many(retry_after: int) -> HTTPException: + minutes = max(1, math.ceil(retry_after / 60)) + return HTTPException( + status_code=429, + detail=f"Too many attempts. Try again in {minutes} minute{'s' if minutes != 1 else ''}.", + headers={"Retry-After": str(retry_after)}, + ) + + +async def check(*limits: Tuple[str, int]) -> None: + """Raise 429 if any (key, limit) has already been reached.""" + if not enabled(): + return + try: + for key, limit in limits: + count, retry_after = await _count(key) + if count >= limit: + raise _too_many(retry_after) + except HTTPException: + raise + except Exception as exc: # Redis down: fail open + logger.warning("Rate limiter unavailable, allowing the request: %s", exc) + + +async def hit(*keys: str, window: int) -> None: + if not enabled(): + return + try: + for key in keys: + await _hit(key, window) + except Exception as exc: + logger.warning("Rate limiter unavailable, attempt not counted: %s", exc) + + +async def clear(key: str) -> None: + if not enabled(): + return + try: + await _clear(key) + except Exception as exc: + logger.warning("Rate limiter unavailable, counter not cleared: %s", exc) + + +def login_keys(request: Request, username_key: str) -> Tuple[str, str]: + address = client_address(request) + return f"login:{address}:{username_key}", f"login:{address}" + + +def register_key(request: Request) -> str: + return f"register:{client_address(request)}" + diff --git a/backend/app/repositories/users.py b/backend/app/repositories/users.py index e675b52..c077951 100644 --- a/backend/app/repositories/users.py +++ b/backend/app/repositories/users.py @@ -38,6 +38,12 @@ async def list(self) -> List[Dict[str, Any]]: "SELECT id, username, role, created_at, llm_model, last_active_at, last_login_at FROM User ORDER BY created_at" ) + async def revoke_tokens(self, user_id: str) -> None: + """Invalidate every token issued so far (see app.auth.create_token).""" + await self.session.execute( + "UPDATE User SET token_version = ifnull(token_version, 0) + 1 WHERE id = :id", {"id": user_id} + ) + async def set_role(self, user_id: str, role: str) -> None: await self.session.execute("UPDATE User SET role = :r WHERE id = :id", {"r": role, "id": user_id}) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 65e118d..f560a1e 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -1,11 +1,12 @@ from __future__ import annotations +import hmac import os import uuid from datetime import datetime, timezone from typing import Any, Dict -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.security import OAuth2PasswordRequestForm from pydantic import BaseModel, Field, field_validator @@ -15,7 +16,7 @@ from app.rbac import DEFAULT_ROLE, Permission, Role, has_permission from app.repositories import UserRepository from app.usernames import check_new_username, username_key -from app import activity +from app import activity, ratelimit from app.services import llm_catalog router = APIRouter(prefix="/auth", tags=["auth"]) @@ -24,6 +25,8 @@ class RegisterRequest(BaseModel): username: str = Field(min_length=3, max_length=60) password: str = Field(min_length=6) + # required for the first (admin) account when the server sets SETUP_TOKEN + setup_token: str | None = Field(default=None, max_length=512) @field_validator("username") @classmethod @@ -36,6 +39,19 @@ def registration_open() -> bool: return os.getenv("ALLOW_REGISTRATION", "true").strip().lower() not in ("false", "0", "no", "off") +def setup_token() -> str: + """ + SETUP_TOKEN guards the first account, which becomes the server admin: on a + server reachable from the internet, whoever registered first would + otherwise own it. Empty = no token needed (a hub only you can reach). + """ + return os.getenv("SETUP_TOKEN", "").strip() + + +async def admin_claimed(session: ArcadeSession) -> bool: + return bool(await session.query_one("SELECT key FROM Meta WHERE key = 'first_admin'")) + + async def create_user(session: ArcadeSession, username: str, password: str, role: Role | None = None) -> Dict[str, Any]: """ Create an account. With no explicit role, the very first account ever @@ -75,13 +91,33 @@ async def insert(as_role: Role, claim_admin: bool) -> Dict[str, Any]: raise HTTPException(status_code=409, detail="Username already taken") +@router.get("/setup") +async def setup_status(session: ArcadeSession = Depends(get_session)) -> Dict[str, bool]: + """What the sign-in page needs to offer: whether the first account needs the setup token.""" + claimed = await admin_claimed(session) + return { + "setup_token_required": bool(setup_token()) and not claimed, + "registration_open": registration_open() or not claimed, + } + + @router.post("/register", status_code=201) async def register( body: RegisterRequest, + request: Request, session: ArcadeSession = Depends(get_session), ) -> Dict[str, str]: - # the first account can always be created, otherwise nobody could ever administer the hub - if not registration_open() and await session.query_one("SELECT key FROM Meta WHERE key = 'first_admin'"): + key = ratelimit.register_key(request) + await ratelimit.check((key, ratelimit.REGISTRATIONS_PER_ADDRESS)) + await ratelimit.hit(key, window=ratelimit.REGISTER_WINDOW) + if not await admin_claimed(session): + # the first account can always be created, otherwise nobody could ever administer + # the hub; with SETUP_TOKEN set, only by someone who has the server's configuration + expected = setup_token() + if expected and not hmac.compare_digest((body.setup_token or "").strip().encode(), expected.encode()): + raise HTTPException(status_code=403, detail="The first account is the server admin and needs the " + "setup token (SETUP_TOKEN in the server's configuration).") + elif not registration_open(): raise HTTPException(status_code=403, detail="Self-registration is closed. Ask an administrator for an account.") user = await create_user(session, body.username, body.password) return {"user_id": user["id"], "role": user["role"]} @@ -98,15 +134,32 @@ async def me(current_user: User = Depends(get_current_user)) -> Dict[str, Any]: } +@router.post("/logout", status_code=204) +async def logout( + current_user: User = Depends(get_current_user), + session: ArcadeSession = Depends(get_session), +) -> None: + """Sign out: every token this account holds stops working, on every device.""" + await UserRepository(session).revoke_tokens(current_user.id) + await session.commit() + + @router.post("/token", response_model=TokenResponse) async def login( + request: Request, form: OAuth2PasswordRequestForm = Depends(), session: ArcadeSession = Depends(get_session), ) -> TokenResponse: + account_key, address_key = ratelimit.login_keys(request, username_key(form.username)) + # checked before the password, so throttled guesses don't even cost a bcrypt round + await ratelimit.check((account_key, ratelimit.LOGIN_FAILURES_PER_ACCOUNT), + (address_key, ratelimit.LOGIN_FAILURES_PER_ADDRESS)) users = UserRepository(session) user = await users.get_by_username(form.username) if not user or not verify_password(form.password, user["hashed_password"]): + await ratelimit.hit(account_key, address_key, window=ratelimit.LOGIN_WINDOW) raise HTTPException(status_code=401, detail="Invalid credentials") + await ratelimit.clear(account_key) # A local LLM is required: one of the three catalog models must be installed. reachable, installed = await llm_catalog.availability() @@ -123,4 +176,4 @@ async def login( await users.set_llm_model(user["id"], model) await session.commit() await activity.touch_user(session.client, user, login=True) - return TokenResponse(access_token=create_token(user["id"])) + return TokenResponse(access_token=create_token(user["id"], user.get("token_version") or 0)) diff --git a/backend/app/schema.py b/backend/app/schema.py index d61903d..f019751 100644 --- a/backend/app/schema.py +++ b/backend/app/schema.py @@ -48,6 +48,7 @@ CREATE PROPERTY User.hashed_password IF NOT EXISTS STRING CREATE PROPERTY User.role IF NOT EXISTS STRING CREATE PROPERTY User.created_at IF NOT EXISTS STRING +CREATE PROPERTY User.token_version IF NOT EXISTS INTEGER CREATE PROPERTY User.llm_model IF NOT EXISTS STRING CREATE INDEX IF NOT EXISTS ON User (id) UNIQUE CREATE INDEX IF NOT EXISTS ON User (username) UNIQUE diff --git a/backend/app/services/file_ingestion.py b/backend/app/services/file_ingestion.py index 43a9a07..1545ae3 100644 --- a/backend/app/services/file_ingestion.py +++ b/backend/app/services/file_ingestion.py @@ -6,6 +6,7 @@ import socket from typing import BinaryIO, Optional +import httpcore import httpx from fastapi import HTTPException, UploadFile from starlette.concurrency import run_in_threadpool @@ -89,25 +90,67 @@ def _is_public_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool: return ip.is_global and not ip.is_multicast -async def _assert_public_host(url: httpx.URL) -> None: - """Reject URLs whose host is, or resolves to, a non-public address.""" - if url.scheme not in ("http", "https"): - raise HTTPException(status_code=400, detail="URL must start with http:// or https://") - host = url.host - if not host: - raise HTTPException(status_code=400, detail="URL has no host") +async def _lookup(host: str, port: int) -> list[str]: + """Every address `host` resolves to.""" try: - infos = await asyncio.get_running_loop().getaddrinfo( - host, url.port or (443 if url.scheme == "https" else 80), - type=socket.SOCK_STREAM, - ) + infos = await asyncio.get_running_loop().getaddrinfo(host, port, type=socket.SOCK_STREAM) except socket.gaierror: raise HTTPException(status_code=400, detail=f"Could not resolve host: {host}") - # every resolved address must be public, not just the first - for info in infos: - ip = ipaddress.ip_address(info[4][0].split("%", 1)[0]) - if not _is_public_ip(ip): + return [info[4][0].split("%", 1)[0] for info in infos] + + +async def _resolve_public(host: str, port: int) -> str: + """Resolve `host`; every address must be public, not just the first. Returns one to connect to.""" + addresses = await _lookup(host, port) + if not addresses: + raise HTTPException(status_code=400, detail=f"Could not resolve host: {host}") + for address in addresses: + if not _is_public_ip(ipaddress.ip_address(address)): raise HTTPException(status_code=400, detail="URL resolves to a private or reserved address") + return addresses[0] + + +async def _assert_public_host(url: httpx.URL) -> None: + """Reject URLs whose host is, or resolves to, a non-public address (a clear error up front).""" + if url.scheme not in ("http", "https"): + raise HTTPException(status_code=400, detail="URL must start with http:// or https://") + if not url.host: + raise HTTPException(status_code=400, detail="URL has no host") + await _resolve_public(url.host, url.port or (443 if url.scheme == "https" else 80)) + + +class _PublicOnlyBackend(httpcore.AsyncNetworkBackend): + """ + Checks the address at the moment of connecting, and connects to exactly + the address it checked. Checking a URL and then letting the HTTP client + resolve the host again would let a hostile DNS server answer with a public + address the first time and a private one the second (DNS rebinding). + TLS is unaffected: the certificate is still verified against the host + name, which httpcore passes to start_tls separately. + """ + + def __init__(self) -> None: + self._inner = httpcore.AnyIOBackend() + + async def connect_tcp(self, host, port, timeout=None, local_address=None, socket_options=None): + address = await _resolve_public(host, port) + return await self._inner.connect_tcp(address, port, timeout=timeout, local_address=local_address, + socket_options=socket_options) + + async def connect_unix_socket(self, path, timeout=None, socket_options=None): + raise HTTPException(status_code=400, detail="Unix sockets are not allowed") + + async def sleep(self, seconds: float) -> None: + await self._inner.sleep(seconds) + + +def _public_only_transport() -> httpx.AsyncHTTPTransport: + transport = httpx.AsyncHTTPTransport() + # httpx has no public hook for the network backend; its pool is rebuilt with ours + # (httpx is pinned in requirements.txt; test_ingestion covers this) + transport._pool = httpcore.AsyncConnectionPool(ssl_context=httpx.create_ssl_context(), + network_backend=_PublicOnlyBackend()) + return transport async def fetch_url(url: str) -> str: @@ -116,8 +159,11 @@ async def fetch_url(url: str) -> str: except Exception: raise HTTPException(status_code=400, detail="Invalid URL") try: - # redirects are followed manually so every hop is re-validated - async with httpx.AsyncClient(timeout=15, follow_redirects=False) as client: + # redirects are followed manually so every hop is re-validated; the transport + # checks the address again when it connects. Passing a transport also means + # proxy settings from the environment are ignored: a proxy would connect for us. + async with httpx.AsyncClient(timeout=15, follow_redirects=False, + transport=_public_only_transport()) as client: for _ in range(MAX_REDIRECTS + 1): await _assert_public_host(target) async with client.stream("GET", target) as response: diff --git a/backend/requirements.txt b/backend/requirements.txt index de77db2..cda88a3 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -9,6 +9,7 @@ bcrypt==5.0.0 pypdf==6.19.0 python-docx==1.2.0 httpx==0.28.1 +httpcore==1.0.9 # imported directly by file_ingestion (connect-time address check) trafilatura==2.3.0 numpy==2.4.6 psutil==7.2.2 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 0367631..ac2e39e 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -43,6 +43,9 @@ os.environ["GRAPHRAG_RERANK"] = "on" os.environ["SECRET_KEY"] = secrets.token_urlsafe(48) os.environ["ALLOW_REGISTRATION"] = "true" +# every test signs in from the same address; test_security turns the limiter back on +os.environ["AUTH_RATE_LIMIT"] = "false" +os.environ.pop("SETUP_TOKEN", None) # the inactivity job would race the tests on the shared database; tests run it explicitly os.environ["RETENTION_ENABLED"] = "false" # folder connectors may read the temp dir only (pytest's tmp_path lives under it) diff --git a/backend/tests/test_ingestion.py b/backend/tests/test_ingestion.py index 8d08ae6..746f9ae 100644 --- a/backend/tests/test_ingestion.py +++ b/backend/tests/test_ingestion.py @@ -230,9 +230,9 @@ def do_GET(self): def test_url_ingest_fetches_and_follows_redirects(client, alice, page_server, monkeypatch): import app.services.file_ingestion as fi - async def allow(url): # the test server is on loopback, which is normally blocked - return None - monkeypatch.setattr(fi, "_assert_public_host", allow) + async def allow(host, port): # the test server is on loopback, which is normally blocked + return "127.0.0.1" + monkeypatch.setattr(fi, "_resolve_public", allow) r = client.post("/knowledge/artifacts/url", headers=alice, json={"url": f"{page_server}/moved", "title": "Page"}) assert r.status_code == 200, r.text body = r.json() @@ -241,6 +241,22 @@ async def allow(url): # the test server is on loopback, which is normally bloc assert {"decision", "risk"} <= {i["type"] for i in body["items"]} +def test_url_ingest_resists_dns_rebinding(client, alice, page_server, monkeypatch): + # a hostile DNS server answers with a public address when the URL is checked, + # then with loopback when the client connects: the connection must be refused + import app.services.file_ingestion as fi + answers = iter([["93.184.216.34"]]) + + async def rebinding(host, port): + return next(answers, ["127.0.0.1"]) + monkeypatch.setattr(fi, "_lookup", rebinding) + port = page_server.rsplit(":", 1)[1] + r = client.post("/knowledge/artifacts/url", headers=alice, + json={"url": f"http://rebind.example:{port}/page", "title": "x"}) + assert r.status_code == 400, r.text + assert "private" in r.json()["detail"] + + # ── edit / delete ─────────────────────────────────────────────────────────── def test_editing_content_reextracts_items(client, alice): diff --git a/backend/tests/test_security.py b/backend/tests/test_security.py index 0607648..b640487 100644 --- a/backend/tests/test_security.py +++ b/backend/tests/test_security.py @@ -157,6 +157,111 @@ def test_closed_registration_admins_create_accounts(client, make_user, monkeypat assert client.get("/auth/me", headers=headers_for(client, username)).json()["username"] == username +def test_first_admin_needs_the_setup_token(client, db_query, monkeypatch): + monkeypatch.setenv("SETUP_TOKEN", "the-servers-setup-token") + assert client.get("/auth/setup").json()["setup_token_required"] is False # the session's admin exists + claim = db_query("SELECT value FROM Meta WHERE key = 'first_admin'")[0]["value"] + db_query("DELETE FROM Meta WHERE key = 'first_admin'") + try: + # a fresh, internet-facing install: strangers can't take the admin account + assert client.get("/auth/setup").json() == {"setup_token_required": True, "registration_open": True} + r, _ = register(client) + assert r.status_code == 403 and "setup token" in r.json()["detail"] + name = f"user_{uuid.uuid4().hex[:10]}" + r = client.post("/auth/register", json={"username": name, "password": "secret123", "setup_token": "wrong"}) + assert r.status_code == 403 + r = client.post("/auth/register", + json={"username": name, "password": "secret123", "setup_token": "the-servers-setup-token"}) + assert r.status_code == 201 and r.json()["role"] == "admin" + # once the admin exists, the token is neither needed nor makes anyone else admin + assert client.get("/auth/setup").json()["setup_token_required"] is False + r, _ = register(client) + assert r.status_code == 201 and r.json()["role"] == "member" + finally: + db_query("UPDATE Meta SET value = :v WHERE key = 'first_admin'", v=claim) + + +def test_signing_out_revokes_the_token(client): + _, username = register(client) + first, second = headers_for(client, username), headers_for(client, username) + assert client.post("/auth/logout", headers=first).status_code == 204 + # every session of the account ends, including the one on another device + assert client.get("/auth/me", headers=first).status_code == 401 + assert client.get("/auth/me", headers=second).status_code == 401 + assert client.get("/auth/me", headers=headers_for(client, username)).status_code == 200 + + +def test_tokens_from_before_session_versions_still_work(client): + import jwt + from datetime import datetime, timedelta, timezone + from app.auth import ALGORITHM, SECRET_KEY + _, username = register(client) + sub = jwt.decode(headers_for(client, username)["Authorization"].split()[1], SECRET_KEY, + algorithms=[ALGORITHM])["sub"] + legacy = jwt.encode({"sub": sub, "exp": datetime.now(timezone.utc) + timedelta(hours=1)}, + SECRET_KEY, algorithm=ALGORITHM) + assert client.get("/auth/me", headers={"Authorization": f"Bearer {legacy}"}).status_code == 200 + + +@pytest.fixture +def rate_limited(monkeypatch): + from app import ratelimit + monkeypatch.setenv("AUTH_RATE_LIMIT", "true") + ratelimit.reset() + yield ratelimit + ratelimit.reset() + + +def test_password_guessing_is_throttled(client, rate_limited): + _, username = register(client) + for _ in range(rate_limited.LOGIN_FAILURES_PER_ACCOUNT): + assert client.post("/auth/token", data={"username": username, "password": "wrong"}).status_code == 401 + r = client.post("/auth/token", data={"username": username, "password": "secret123"}) + assert r.status_code == 429 and int(r.headers["Retry-After"]) > 0 # even the right password, for now + # other accounts can still sign in from here + _, other = register(client) + assert client.post("/auth/token", data={"username": other, "password": "secret123"}).status_code == 200 + + +def test_a_successful_sign_in_resets_the_count(client, rate_limited): + _, username = register(client) + for _ in range(rate_limited.LOGIN_FAILURES_PER_ACCOUNT - 1): + client.post("/auth/token", data={"username": username, "password": "wrong"}) + assert client.post("/auth/token", data={"username": username, "password": "secret123"}).status_code == 200 + assert client.post("/auth/token", data={"username": username, "password": "wrong"}).status_code == 401 + assert client.post("/auth/token", data={"username": username, "password": "secret123"}).status_code == 200 + + +def test_mass_registration_is_throttled(client, rate_limited): + for _ in range(rate_limited.REGISTRATIONS_PER_ADDRESS): + assert register(client)[0].status_code == 201 + assert register(client)[0].status_code == 429 + + +def test_rate_limits_are_shared_through_redis(client, rate_limited, monkeypatch): + # in worker mode every app instance counts in Redis, so guesses spread over instances add up + import os + from app import coordination + redis = os.getenv("KH_TEST_REDIS_URL") + if not redis: + pytest.skip("set KH_TEST_REDIS_URL to test against Redis") + monkeypatch.setenv("REDIS_URL", redis) + coordination._redis_clients.clear() + try: + _, username = register(client) + for _ in range(rate_limited.LOGIN_FAILURES_PER_ACCOUNT): + client.post("/auth/token", data={"username": username, "password": "wrong"}) + rate_limited.reset() # another instance: nothing in its memory + assert client.post("/auth/token", data={"username": username, "password": "secret123"}).status_code == 429 + finally: + import redis as sync_redis + r = sync_redis.from_url(redis) + for key in r.scan_iter("kh:rl:*"): # the per-address counters would carry over to the next run + r.delete(key) + monkeypatch.delenv("REDIS_URL") + coordination._redis_clients.clear() + + # ── encryption primitives (no database) ───────────────────────────────────── def test_encryption_binds_value_to_its_record(monkeypatch): diff --git a/deploy/.env.example b/deploy/.env.example index 634eb67..380c513 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -12,6 +12,10 @@ LB_HTTPS_PORT=443 SECRET_KEY= # python -m app.crypto (in backend/) -- needed for connectors that store a token CREDENTIALS_KEY= +# The first account you create becomes the server admin; registering it asks for this +# token, so nobody who finds the site before you can take that account. +# python -c "import secrets; print(secrets.token_urlsafe(24))" +SETUP_TOKEN= # ArcadeDB root: used ONLY by the one-off provisioning job, never by the app ARCADEDB_ROOT_PASSWORD= # The app's own ArcadeDB user (created by provisioning; access to its database only) @@ -32,6 +36,8 @@ CONNECTOR_SOURCES_DIR=./sources # ── Accounts and retention ──────────────────────────────────────────────────── ALLOW_REGISTRATION=true +# true publishes the interactive API docs (/docs, /redoc, /openapi.json) +API_DOCS=false RETENTION_ENABLED=true RETENTION_INACTIVE_DAYS=365 RETENTION_CHECK_HOURS=24 diff --git a/deploy/Caddyfile b/deploy/Caddyfile index b17af08..15b7759 100644 --- a/deploy/Caddyfile +++ b/deploy/Caddyfile @@ -17,9 +17,19 @@ X-Content-Type-Options nosniff X-Frame-Options DENY Referrer-Policy strict-origin-when-cross-origin + Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" -Server } + # Content Security Policy: only this site's own scripts run, so injected markup + # can't execute code or read the sign-in token. Inline styles are allowed (Angular + # injects component styles); inline scripts and event handlers are not, which is + # why angular.json turns off inlineCritical (it adds an onload= handler). + # Not on the API docs pages: Swagger UI loads from a CDN, and the production stack + # turns those pages off (API_DOCS=false). + @csp not path /docs* /redoc* + header @csp Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" + # Some API prefixes are also app routes (/knowledge, /connectors, /groups, # /admin/users): a browser page load (GET accepting HTML) gets the app. # Vary: Accept keeps the browser from answering the app's own API request diff --git a/deploy/backup.sh b/deploy/backup.sh new file mode 100755 index 0000000..3671605 --- /dev/null +++ b/deploy/backup.sh @@ -0,0 +1,44 @@ +#!/bin/sh +# Back up the Knowledge Hubs database to deploy/backups/ (see docs/deployment.md). +# +# deploy/backup.sh run from anywhere, while the stack is up +# 0 3 * * * /path/to/deploy/backup.sh nightly, from the host's crontab +# +# ArcadeDB writes a consistent snapshot (BACKUP DATABASE, as the app's own user: +# the root password isn't needed); the zip is copied out of the container and +# removed from it, and local backups older than BACKUP_KEEP_DAYS are deleted. +# Copy deploy/backups/ off this machine too: a backup on the same disk doesn't +# survive losing the disk. +# +# BACKUP_DIR=./backups BACKUP_KEEP_DAYS=14 ENV_FILE=.env +# BACKUP_CONTAINER= the ArcadeDB container (default: this stack's arcadedb-0) +set -eu +export MSYS_NO_PATHCONV=1 # Git Bash on Windows: don't rewrite the container paths below +cd "$(dirname "$0")" + +BACKUP_DIR=${BACKUP_DIR:-./backups} +KEEP_DAYS=${BACKUP_KEEP_DAYS:-14} +ENV_FILE=${ENV_FILE:-.env} + +env_value() { sed -n "s/^$1=//p" "$ENV_FILE" | tail -n 1 | sed 's/^"\(.*\)"$/\1/'; } +DB=$(env_value ARCADEDB_DATABASE); DB=${DB:-knowledge_hubs} +APP_USER=$(env_value ARCADEDB_APP_USER); APP_USER=${APP_USER:-kh_app} +APP_PASSWORD=$(env_value ARCADEDB_APP_PASSWORD) +[ -n "$APP_PASSWORD" ] || { echo "backup: ARCADEDB_APP_PASSWORD is not set in $ENV_FILE" >&2; exit 1; } + +CONTAINER=${BACKUP_CONTAINER:-$(docker compose ps -q arcadedb-0)} +[ -n "$CONTAINER" ] || { echo "backup: arcadedb-0 isn't running (docker compose up -d)" >&2; exit 1; } + +AUTH=$(printf '%s:%s' "$APP_USER" "$APP_PASSWORD" | base64 | tr -d '\n') +RESULT=$(docker exec "$CONTAINER" wget -q -O - \ + --header "Authorization: Basic $AUTH" --header "Content-Type: application/json" \ + --post-data '{"language":"sql","command":"BACKUP DATABASE"}' \ + "http://localhost:2480/api/v1/command/$DB") || { echo "backup: ArcadeDB refused the backup" >&2; exit 1; } +FILE=$(printf '%s' "$RESULT" | sed -n 's/.*"backupFile":"\([^"]*\)".*/\1/p') +[ -n "$FILE" ] || { echo "backup: unexpected answer from ArcadeDB: $RESULT" >&2; exit 1; } + +mkdir -p "$BACKUP_DIR" +docker cp "$CONTAINER:/home/arcadedb/backups/$DB/$FILE" "$BACKUP_DIR/$FILE" +docker exec "$CONTAINER" rm -f "/home/arcadedb/backups/$DB/$FILE" +find "$BACKUP_DIR" -name "$DB-backup-*.zip" -mtime +"$KEEP_DAYS" -delete +echo "$BACKUP_DIR/$FILE" diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index da9c678..ad48da9 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -29,6 +29,9 @@ x-backend: &backend REDIS_URL: redis://:${REDIS_PASSWORD:?set REDIS_PASSWORD in deploy/.env}@redis:6379/0 DB_MIGRATE_ON_STARTUP: "false" # the provisioning job migrates, once SECRET_KEY: ${SECRET_KEY:?set SECRET_KEY in deploy/.env} + # the first account becomes the server admin and must present this token + SETUP_TOKEN: ${SETUP_TOKEN:?set SETUP_TOKEN in deploy/.env} + API_DOCS: ${API_DOCS:-false} CREDENTIALS_KEY: ${CREDENTIALS_KEY:-} CONNECTOR_ROOTS: /data/sources ALLOW_REGISTRATION: ${ALLOW_REGISTRATION:-true} diff --git a/docker-compose.yml b/docker-compose.yml index 9b60bda..2da695e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,8 @@ # Optional settings (environment or a .env file next to this one): # KH_PORT=8080 port on this machine # KH_BIND=127.0.0.1 0.0.0.0 to let other machines on your network in +# SETUP_TOKEN=... then registering the first (admin) account needs it: +# set it before letting other machines in # OLLAMA_BASE_URL=http://host.docker.internal:11434 # use an Ollama already running on this machine # (e.g. for GPU acceleration on macOS); then start @@ -67,6 +69,7 @@ services: KH_SECRETS_DIR: /var/lib/knowledge-hubs CONNECTOR_ROOTS: /data/sources ALLOW_REGISTRATION: ${ALLOW_REGISTRATION:-true} + SETUP_TOKEN: ${SETUP_TOKEN:-} RETENTION_ENABLED: ${RETENTION_ENABLED:-true} RETENTION_INACTIVE_DAYS: ${RETENTION_INACTIVE_DAYS:-365} MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-20} diff --git a/docs/api.md b/docs/api.md index fa4e85a..f77f495 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,10 +1,10 @@ # API -All endpoints except `/auth/register`, `/auth/token`, `GET /health`, `GET /models/catalog` and `POST /models/bootstrap-install` need `Authorization: Bearer `. Data endpoints act in the space named by the `X-Space` header (a group id, or your own user id); without it they use your personal space. Interactive docs are at `http://localhost:8000/docs`. +All endpoints except `/auth/register`, `/auth/setup`, `/auth/token`, `GET /health`, `GET /models/catalog` and `POST /models/bootstrap-install` need `Authorization: Bearer `. Data endpoints act in the space named by the `X-Space` header (a group id, or your own user id); without it they use your personal space. Interactive docs are at `http://localhost:8000/docs` (off in the production stack: `API_DOCS`). | Group | Endpoints | |---|---| -| Auth | `POST /auth/register`, `POST /auth/token` (form fields `username`, `password`; token valid for 8 h), `GET /auth/me` (role and permissions) | +| Auth | `POST /auth/register` (`username`, `password`, and `setup_token` for the first account when the server sets `SETUP_TOKEN`), `GET /auth/setup` (whether that token is needed), `POST /auth/token` (form fields `username`, `password`; token valid for 8 h; 429 after repeated failures), `POST /auth/logout` (ends every session of the account), `GET /auth/me` (role and permissions) | | Admin *(server admins)* | `GET /admin/users` (with last activity and expiry), `POST /admin/users` (create with a role), `PATCH /admin/users/{id}` (change role), `GET /admin/retention` (what's due), `POST /admin/retention/run` | | Spaces & groups | `GET /spaces`; `POST/GET /groups`, `PATCH/DELETE /groups/{id}`, `GET /groups/{id}/members`, `GET /groups/{id}/candidates?q=` (search users), `POST /groups/{id}/invitations`, `DELETE /groups/{id}/members/{user_id}`, `POST /groups/{id}/leave`; `GET /invitations`, `POST /invitations/{group_id}/accept`, `/decline` | | Ingest | `POST /knowledge/artifacts` (text), `/artifacts/upload` (multipart), `/artifacts/url`, `/artifacts/transcript` | diff --git a/docs/configuration.md b/docs/configuration.md index 7c28092..21c6d41 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -18,6 +18,9 @@ Where settings go depends on how you run it: | `CREDENTIALS_KEY` | — | Key(s) that encrypt stored connector credentials; comma-separated for rotation. Without it, connectors that need a token can't be saved | | `CONNECTOR_ROOTS` | — | Folders that folder / git-ADR connectors may read (`;`-separated on Windows, `:` elsewhere). Empty disables those connectors | | `ALLOW_REGISTRATION` | `true` | `false` = only server admins create accounts. The first account can always be created | +| `SETUP_TOKEN` | — (**required** in `deploy/`) | Registering the first account, which becomes the server admin, asks for this token. Set it on any server others can reach. Empty = no token needed | +| `AUTH_RATE_LIMIT` | `true` | Sign-in throttling: 5 failed attempts per account and 30 in total per client address every 15 minutes, and 20 sign-ups per address an hour (429 with `Retry-After`). Shared through Redis in worker mode | +| `API_DOCS` | `true` (`false` in `deploy/`) | Interactive API docs at `/docs`, `/redoc` and `/openapi.json` | | `RETENTION_INACTIVE_DAYS` | `365` | Accounts and groups unused this long are deleted with their data | | `RETENTION_CHECK_HOURS` | `24` | How often the clean-up runs (also once at startup) | | `RETENTION_ENABLED` | `true` | `false` turns automatic deletion off; admins still see what's due | diff --git a/docs/deployment.md b/docs/deployment.md index 7f617bd..4da3c44 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -39,13 +39,14 @@ cd deploy cp .env.example .env # fill in deploy/.env: SITE_ADDRESS, and generate every secret: -# SECRET_KEY, ARCADEDB_ROOT_PASSWORD, ARCADEDB_APP_PASSWORD, REDIS_PASSWORD: +# SECRET_KEY, SETUP_TOKEN, ARCADEDB_ROOT_PASSWORD, ARCADEDB_APP_PASSWORD, REDIS_PASSWORD: # python -c "import secrets; print(secrets.token_urlsafe(32))" # CREDENTIALS_KEY: (in backend/) python -m app.crypto docker compose up -d --build -# → https:/// (the first account you create is the server admin) +# → https:/// register the first account with SETUP_TOKEN: it becomes the server admin ``` +- **The first account:** the sign-in page asks for `SETUP_TOKEN` when no admin exists yet, so someone who finds the site before you can't take the admin account. After that the token isn't used; set `ALLOW_REGISTRATION=false` if only admins should create accounts. - **TLS:** with `SITE_ADDRESS=localhost`, browsers warn until you trust Caddy's local CA. With a real domain, ports 80 and 443 must be reachable for Let's Encrypt. - **Ollama** runs on the Docker host (`OLLAMA_BASE_URL`, default `http://host.docker.internal:11434`). - **Folder and git-ADR connectors** read `CONNECTOR_SOURCES_DIR` (default `deploy/sources`), mounted read-only at `/data/sources` in the app and worker containers. Point folder connectors at paths under `/data/sources`. @@ -75,6 +76,7 @@ The first run of the "leader stopped" scenario found the first one. `/health` pr - **More app instances:** copy `app2` in `docker-compose.yml` and add it to `APP_UPSTREAMS`. More sync throughput: raise the worker's `--concurrency`, or run more `worker` replicas. Keep **one** `beat`. - **Logs:** `docker compose logs -f app1 worker`. Health: `curl -k https:///health` shows each node, the queue and the background mode. -- **Backups:** each ArcadeDB node keeps its data in its own volumes (`arcadedb-N-databases`, `-config`, `-replication`, `-backups`). Back up at least one node's `databases` volume, plus `redis-data` for queued work. +- **Backups:** run [`deploy/backup.sh`](../deploy/backup.sh) (`sh deploy/backup.sh`) while the stack is up, nightly from the host's crontab: `0 3 * * * sh /path/to/deploy/backup.sh`. It has ArcadeDB write a consistent snapshot, as the app's own user, copies the zip to `deploy/backups/` and deletes local copies older than `BACKUP_KEEP_DAYS` (14). Copy that folder off the machine as well. Queued syncs in `redis-data` don't need backing up: a lost sync runs again the next time someone clicks **Sync now**. +- **Restoring:** ArcadeDB restores a backup zip into a database that doesn't exist yet, and reads a local file only when the node's `JAVA_OPTS` include `-Darcadedb.server.restoreImportAllowLocalUrls=true`. Copy the zip into the container (`docker compose cp`), then send the server command `restore database knowledge_hubs file:///path/in/container/backup.zip` as root (`POST /api/v1/server`). This was verified on a single node. For the cluster, restore on one node with the others stopped and their `-databases` volumes emptied, then start them so they resync; rehearse it on a copy first. - **Upgrades without downtime:** `docker compose build provision lb`, then `docker compose run --rm provision` (idempotent: it re-applies the app user and runs any new migrations). Then restart the instances one at a time, so one is always serving: `docker compose up -d --no-deps app1`, wait for it to report healthy, then `docker compose up -d --no-deps app2 worker`. A plain `docker compose up -d --build` also works, but restarts both instances at once. - **Rotating secrets:** change the value in `deploy/.env` and run `docker compose up -d`. For `CREDENTIALS_KEY`, use `,` for one restart (see [Security notes](security.md)). For `ARCADEDB_APP_PASSWORD`, `provision` re-applies it before the app instances restart. diff --git a/docs/getting-started.md b/docs/getting-started.md index 7fab6a7..ec38e51 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -72,7 +72,7 @@ cd ../frontend && npm install && npm start # → http://localhost:4200 ``` -**The first account you create is the server admin.** Everyone who registers after that is a normal user. See [Accounts, roles and spaces](accounts-and-spaces.md). +**The first account you create is the server admin.** Everyone who registers after that is a normal user. If `SETUP_TOKEN` is set (the production stack requires it), registering that first account asks for it. See [Accounts, roles and spaces](accounts-and-spaces.md). The first embedding call loads the sentence-transformers model from the local cache (step 4): the app runs with `TRANSFORMERS_OFFLINE=1`, so it never downloads anything on its own. If you change `LOCAL_EMBED_MODEL`, fetch that model the same way. diff --git a/docs/known-limitations.md b/docs/known-limitations.md index dd8252f..084a546 100644 --- a/docs/known-limitations.md +++ b/docs/known-limitations.md @@ -14,6 +14,11 @@ - **Group connectors share one credential.** Every member of a group can sync a group connector with the token whoever added it saved. Use a token scoped to what the group should see. - **Per-sync caps.** GitHub reads at most 10 pages of closed pull requests per sync; each connector has a `max` setting for items per sync. +**Accounts** +- **Signing out ends every session of the account.** Tokens can't be revoked one at a time: **Sign out** on one device signs you out on all of them. +- **Sign-in throttling counts by client address.** Many people behind one address (an office NAT) share the limit of 30 failed sign-ins per 15 minutes. If Redis is down, the limits aren't enforced, rather than blocking sign-in. +- **Tokens live in the browser's `localStorage`.** The Content Security Policy stops injected scripts, which are what could read it, and signing out revokes the token on the server. + **Data** - **Deletion is permanent.** Inactivity retention, group deletion and removing a connector's data can't be undone; back up the `arcadedb-data` volume. - **Shared copies drop lineage links.** Sharing a source into another space copies its items, statuses and review state, but not links to items outside it. @@ -27,4 +32,3 @@ **Performance and network** - **Writes are one HTTP round trip per statement.** An ingest of N items makes roughly 3N calls to ArcadeDB. That's fine on the same machine, but slower if ArcadeDB is remote. -- **URL fetching is exposed to DNS rebinding.** Addresses are checked, then httpx resolves the host again when connecting, so a hostile DNS server can return a different address the second time. diff --git a/docs/security.md b/docs/security.md index 70f1eb1..2e564c7 100644 --- a/docs/security.md +++ b/docs/security.md @@ -4,6 +4,9 @@ How Knowledge Hubs protects accounts and data. To report a vulnerability, see [S **Accounts** - **Passwords and tokens.** Passwords are hashed with bcrypt (which reads only the first 72 bytes of a password). Tokens are HS256 JWTs (PyJWT) signed with `SECRET_KEY` and expire after 8 hours; a token without an expiry is refused. The frontend sends the token only to its own API. +- **The first account.** It becomes the server admin. With `SETUP_TOKEN` set (required in the production stack), registering it needs that token, compared in constant time, so whoever finds a new server first can't claim it. +- **Throttling.** Failed sign-ins are limited to 5 per account and 30 in total per client address every 15 minutes, and sign-ups to 20 per address an hour. Past that the API answers 429 with `Retry-After`, before checking the password. A successful sign-in clears that account's count. In the production stack the counters are in Redis, so the limit holds across app instances. The per-account limit is keyed by address too, so nobody can lock a user out by failing on purpose from elsewhere. +- **Signing out.** Each token carries the account's session version. **Sign out** (`POST /auth/logout`) increments it, so every token issued before stops working at once, a copied one included. - **Signing key.** The server refuses to start if `SECRET_KEY` is missing, shorter than 32 characters, or one of the placeholders this repo has ever shipped. Anyone who knows the key could sign in as any account. - **Roles.** [`backend/app/rbac.py`](../backend/app/rbac.py) maps roles to permissions, and each route declares the permission it needs. Roles are read from the database on every request, so a change applies to existing sessions at once. An unknown role grants nothing, and the last server admin can't be demoted. - *Upgrading:* databases from before roles existed had every account as admin. On first start, the earliest account stays admin and every other admin becomes a member (logged once). @@ -17,7 +20,9 @@ How Knowledge Hubs protects accounts and data. To report a vulnerability, see [S **Infrastructure (production stack)** - **A dedicated database user.** The app connects as a user that can read, write and change the schema in its own database, and can't create databases or see any other. Only the one-off `provision` job and the ArcadeDB nodes ever receive the root password; this was checked against the stack's actual configuration. -- **TLS everywhere outside.** Caddy terminates HTTPS, redirects HTTP, and adds HSTS, `X-Content-Type-Options`, `X-Frame-Options: DENY` and `Referrer-Policy`. Internal traffic stays on the Compose network; only the load balancer publishes ports. +- **TLS everywhere outside.** Caddy terminates HTTPS, redirects HTTP, and adds HSTS, `X-Content-Type-Options`, `X-Frame-Options: DENY`, `Referrer-Policy` and `Permissions-Policy`. +- **Content Security Policy.** Only the site's own scripts run: no inline scripts, event handlers or other origins, and the page can't be framed. Injected markup therefore can't run code that would read the sign-in token from `localStorage`. +- **No public API docs.** The production stack sets `API_DOCS=false`, which removes `/docs`, `/redoc` and `/openapi.json`. Internal traffic stays on the Compose network; only the load balancer publishes ports. - **Secrets stay out of images.** `.dockerignore` files exclude every `.env`, and each container gets only the variables `docker-compose.yml` lists for it. Redis requires a password. **Connectors and credentials** @@ -27,7 +32,7 @@ How Knowledge Hubs protects accounts and data. To report a vulnerability, see [S - **Folder connectors.** Disabled unless `CONNECTOR_ROOTS` is set. Paths are checked against it when a connector is saved and again at every sync. Directory symlinks aren't followed, and file symlinks that resolve outside the folder are skipped. `git` runs with the repository's `fsmonitor`, hooks and pager overridden and system config ignored, so a scanned repository can't make the server run programs. **Input** -- **URL fetching.** Only `http(s)` is allowed. Hosts that resolve to private, loopback, link-local (including cloud metadata) or reserved addresses are rejected, and every redirect hop is checked again. +- **URL fetching.** Only `http(s)` is allowed. Hosts that resolve to private, loopback, link-local (including cloud metadata) or reserved addresses are rejected, and every redirect hop is checked again. The check is repeated when connecting, and the connection goes to exactly the address that was checked, so a DNS server that answers differently the second time (DNS rebinding) can't redirect it. Proxy settings from the environment are ignored for these fetches. - **Uploads.** Oversized bodies get a 413 before they're buffered. PDF and DOCX are parsed from Starlette's spooled temp file rather than read into memory. Legacy `.doc` is rejected; save it as `.docx`. **Local Docker stack.** [`docker-compose.yml`](../docker-compose.yml) publishes only the web port, on `127.0.0.1` unless you set `KH_BIND`. ArcadeDB and Ollama are reachable only inside the Compose network, which is why ArcadeDB can use a default password there. The generated `SECRET_KEY` and `CREDENTIALS_KEY` are files readable only by the app user, in the `backend-secrets` volume. Use [`deploy/`](deployment.md) for anything exposed beyond your machine or network. diff --git a/frontend/angular.json b/frontend/angular.json index fc90beb..ab94714 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -43,7 +43,15 @@ "maximumError": "8kb" } ], - "outputHashing": "all" + "outputHashing": "all", + "optimization": { + "scripts": true, + "fonts": true, + "styles": { + "minify": true, + "inlineCritical": false + } + } }, "development": { "optimization": false, diff --git a/frontend/src/app/app.component.ts b/frontend/src/app/app.component.ts index 599f89c..74beb47 100644 --- a/frontend/src/app/app.component.ts +++ b/frontend/src/app/app.component.ts @@ -119,8 +119,8 @@ export class AppComponent implements OnDestroy { onSpaceChange(id: string) { this.spaces.switchTo(id) } - logout() { - this.auth.logout() + async logout() { + await this.auth.signOut() this.router.navigate(['/login']) } } diff --git a/frontend/src/app/models/api.ts b/frontend/src/app/models/api.ts index 7feb238..7b07161 100644 --- a/frontend/src/app/models/api.ts +++ b/frontend/src/app/models/api.ts @@ -268,6 +268,13 @@ export interface TokenResponse { token_type: string } +/** GET /auth/setup */ +export interface SetupStatus { + /** no admin yet, and the server sets SETUP_TOKEN: the first account must supply it */ + setup_token_required: boolean + registration_open: boolean +} + /* ── Decision lineage ─────────────────────────────────────────────────────── */ export type LinkKind = 'supersedes' | 'reverses' | 'amends' | 'realizes' | 'mitigates' | 'learned_from' diff --git a/frontend/src/app/pages/login/login.component.html b/frontend/src/app/pages/login/login.component.html index 49259f7..3cf955f 100644 --- a/frontend/src/app/pages/login/login.component.html +++ b/frontend/src/app/pages/login/login.component.html @@ -15,10 +15,16 @@

Knowledge Hubs

+ @if (mode === 'register' && needsSetupToken) { + +

This is a new hub: the first account becomes its administrator. + Enter the SETUP_TOKEN from the server's configuration.

+ }
@if (sessionExpired && !error) {

Your session expired. Please sign in again.

} @if (error) {

{{ error }}

} -