Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ venv/
# Local data — never commit user data or databases
backend/data/*.db
backend/data/*.json
deploy/backups/

# Editor
.idea/
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
13 changes: 12 additions & 1 deletion backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 9 additions & 2 deletions backend/app/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
10 changes: 9 additions & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import asyncio
import contextlib
import logging
import os
from contextlib import asynccontextmanager
from typing import AsyncIterator

Expand Down Expand Up @@ -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)
Expand Down
151 changes: 151 additions & 0 deletions backend/app/ratelimit.py
Original file line number Diff line number Diff line change
@@ -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)}"

6 changes: 6 additions & 0 deletions backend/app/repositories/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -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})

Expand Down
Loading
Loading