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
33 changes: 33 additions & 0 deletions bin/chat-chainlit.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
# named with a hyphen, so it cannot be listed in [[tool.mypy.overrides]].
import asyncio
import os
import time
from collections.abc import Awaitable, Callable
from pathlib import Path

Expand Down Expand Up @@ -39,6 +40,7 @@
from handoff import seed
from handoff.store import AnalysisHandoff, handoffs
from handoff.window import acknowledgement, claimed_id
from util import captcha_cookie
from util.chainlit_helpers import (
PrefixedS3StorageClient,
is_feature_enabled,
Expand All @@ -51,9 +53,11 @@
from util.config_yml.messages import TriggerEvent
from util.logging import logging
from util.orcid_provider import ORCIDOAuthProvider
from util.rate_limit import SlidingWindowLimiter, positive_int
from util.secrets import (
SECRET_NAMES,
get_db_uri,
get_secret,
load_secrets_to_environ,
mounted_secrets,
)
Expand Down Expand Up @@ -522,6 +526,32 @@ async def answer_with_model(content: str, message_id: str) -> None:
save_openai_metrics(message_id, openai_cb)


#: Guests' messages, limited per human check rather than per session. The
#: per-session quota is keyed on the session id, which the client chooses, so
#: a reconnect -- or a script -- started a fresh quota every time (review, 1b).
_per_solve = SlidingWindowLimiter(
limit=positive_int("CHAT_MESSAGES_PER_SOLVE", 100),
window=float(positive_int("CHAT_SOLVE_WINDOW_SECONDS", 3 * 60 * 60)),
)
PER_SOLVE_LIMITED = (
"You've reached the limit on messages for now. Please try again later."
)


def solve_key() -> str:
"""Which human check this connection passed, or failing that, where from."""
environ = context.session.environ or {}
verdict = captcha_cookie.check(
captcha_cookie.from_cookie_header(environ.get("HTTP_COOKIE")),
get_secret("CLOUDFLARE_SECRET_KEY") or "",
time.time(),
)
if verdict.ok:
return f"solve:{verdict.nonce}"
forwarded = str(environ.get("HTTP_X_FORWARDED_FOR", "")).split(",")[0].strip()
return f"ip:{forwarded or environ.get('REMOTE_ADDR', '')}"


@cl.on_message
async def main(message: cl.Message) -> None:
# First, before any early return: a "yes" means the offer just made, so
Expand All @@ -531,6 +561,9 @@ async def main(message: cl.Message) -> None:

if await message_rate_limited(config):
return
if cl.user_session.get("user") is None and not _per_solve.allow(solve_key()):
await cl.Message(content=PER_SOLVE_LIMITED).send()
return

await static_messages(config, TriggerEvent.on_message)

Expand Down
155 changes: 95 additions & 60 deletions bin/chat-fastapi.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import hashlib
import hmac
import os
import time
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import asynccontextmanager
from string import Template
from typing import Any
from urllib.parse import urlsplit

import requests
import httpx
from chainlit.utils import mount_chainlit
from dotenv import load_dotenv
from fastapi import FastAPI, Request, Response
Expand All @@ -17,6 +16,7 @@
from api.analysis_summary import router as analysis_summary_router
from api.answer import router as answer_router
from api.handoff import router as handoff_router
from util import captcha_cookie
from util.caller_token import load_verifying_key
from util.captcha_scope import is_captcha_exempt
from util.embedding_environment import EmbeddingEnvironment
Expand Down Expand Up @@ -117,26 +117,67 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
HEADER_DONT_CACHE = {"Cache-Control": "no-store"}


def make_signature(value: str) -> str:
if CLOUDFLARE_SECRET_KEY is None:
raise ValueError("CLOUDFLARE_SECRET_KEY is not set")
return hmac.new(
CLOUDFLARE_SECRET_KEY.encode(), value.encode(), hashlib.sha256
).hexdigest()
def _gated(path: str) -> bool:
"""Whether the human check applies to this path."""
return not is_captcha_exempt(
path,
chainlit_uri=CHAINLIT_URI,
# The value resolved through get_secret, not os.environ. get_secret
# prefers a mounted Docker secret file, so a deployment that mounts the
# key rather than exporting it used to land here with the env var unset
# and skip the captcha entirely -- switching off a protection the
# operator had configured, silently.
captcha_configured=bool(CLOUDFLARE_SECRET_KEY),
# The website API verifies its own caller, with a signed token rather
# than a captcha, so redirecting it to a captcha page would break it. This
# is a deliberate hole in an authentication boundary, which is why the
# rule was pinned by tests before it was widened. With the slash: the
# bare prefix exempted `/chat/guest/api-anything` too (review, 1b).
extra_prefixes=[f"{API_PREFIX}/"],
)


def create_secure_cookie(value: str) -> str:
signature = make_signature(value)
return f"{value}|{signature}"
def _set_pass(response: Response) -> None:
response.set_cookie(
key=captcha_cookie.COOKIE_NAME,
value=captcha_cookie.mint(CLOUDFLARE_SECRET_KEY or "", time.time()),
max_age=captcha_cookie.MAX_AGE_SECONDS,
secure=True, # HTTPS only
httponly=True, # inaccessible to client side JS
)


def verify_secure_cookie(cookie_value: str) -> bool:
try:
value, signature = cookie_value.split("|", 1)
expected_signature = make_signature(value)
return hmac.compare_digest(signature, expected_signature)
except Exception:
return False
class WebsocketGate:
"""The human check, for websocket connections.

`@app.middleware("http")` never sees a websocket. Browsers met the gate only
because socket.io starts on HTTP polling; a client opening the websocket
directly got the whole chat with no captcha, and -- choosing its own session
id -- no per-person limit either (review, area 1b, reproduced).

Refused before the handshake completes, which the server sends as a 403.
"""

def __init__(self, app: Any) -> None:
self.app = app

async def __call__(self, scope: Any, receive: Any, send: Any) -> None:
if scope["type"] == "websocket" and _gated(scope.get("path", "")):
headers = dict(scope.get("headers") or [])
cookie = captcha_cookie.from_cookie_header(
headers.get(b"cookie", b"").decode("latin-1")
)
verdict = captcha_cookie.check(
cookie, CLOUDFLARE_SECRET_KEY or "", time.time()
)
if not verdict.ok:
await receive() # websocket.connect
await send({"type": "websocket.close", "code": 1008})
return
await self.app(scope, receive, send)


app.add_middleware(WebsocketGate)


@app.middleware("http")
Expand All @@ -151,39 +192,27 @@ async def verify_captcha_middleware(
if ".." not in clean_path:
return RedirectResponse(url=f"{clean_path}/")

# Allow access to CAPTCHA pages and static files
if is_captcha_exempt(
path,
chainlit_uri=CHAINLIT_URI,
# The value resolved through get_secret, not os.environ. get_secret
# prefers a mounted Docker secret file, so a deployment that mounts the
# key rather than exporting it used to land here with the env var unset
# and skip the captcha entirely -- switching off a protection the
# operator had configured, silently.
captcha_configured=bool(CLOUDFLARE_SECRET_KEY),
# The answer endpoint verifies its own caller, with a signed token rather
# than a captcha, so redirecting it to a captcha page would break it. This
# is a deliberate hole in an authentication boundary, which is why the
# rule was pinned by tests before it was widened.
extra_prefixes=[API_PREFIX],
):
if not _gated(path):
return await call_next(request)

host = request.headers.get("referer")
if host and host.startswith("http:"):
error_html = ERROR_PAGE_TEMPLATE.substitute(
error_title="HTTPS is required for accessing this site",
)
return Response(content=error_html, status_code=400, media_type="text/html")

# Check if the user has completed the CAPTCHA verification
captcha_verified = request.cookies.get("captcha_verified")
# There was a check here that refused any request whose Referer was an
# http: page. The Referer is the page the reader came from, so it turned
# away readers following a link from any plain-http site, and stopped no
# one: a script omits the header (review, area 1b). TLS is Apache's job,
# and the cookie is Secure.

# If CAPTCHA is not verified, block access
if not captcha_verified or not verify_secure_cookie(captcha_verified):
verdict = captcha_cookie.check(
request.cookies.get(captcha_cookie.COOKIE_NAME),
CLOUDFLARE_SECRET_KEY or "",
time.time(),
)
if not verdict.ok:
return RedirectResponse(url=f"{CHAINLIT_URI}/verify_captcha_page")

return await call_next(request)
response = await call_next(request)
if verdict.renew:
_set_pass(response)
return response


# Serve the CAPTCHA verification page (basic HTML form)
Expand Down Expand Up @@ -227,7 +256,10 @@ async def captcha_page() -> Response:

@app.post(f"{CHAINLIT_URI}/verify_captcha")
async def verify_captcha(request: Request) -> Response:
form_data = await request.form()
# Bounded: anyone can reach this route, and the defaults put no cap on
# file parts, which were spooled to the disk the whole host shares
# (review, area 1b). The form has one field.
form_data = await request.form(max_files=0, max_fields=4, max_part_size=8192)
cf_turnstile_response = form_data.get("cf-turnstile-response")
if not isinstance(cf_turnstile_response, str):
error_html = ERROR_PAGE_TEMPLATE.substitute(
Expand Down Expand Up @@ -267,9 +299,18 @@ async def verify_captcha(request: Request) -> Response:
"remoteip": client_ip,
}

# Perform request to Cloudflare Turnstile verification endpoint
response = requests.post(url, data=data, timeout=10)
result = response.json()
# Asynchronous: the blocking `requests.post` held the event loop every
# session shares for a Cloudflare round trip on each anonymous POST, and
# an unreachable or non-JSON reply was an unhandled 500 (review, 1b).
try:
async with httpx.AsyncClient(timeout=10.0) as client:
reply = await client.post(url, data=data)
result = reply.json()
except (httpx.HTTPError, ValueError):
logging.warning("Turnstile siteverify failed; treating as not verified")
result = {}
if not isinstance(result, dict):
result = {}

# If CAPTCHA validation fails, return an error
if not result.get("success"):
Expand All @@ -283,18 +324,12 @@ async def verify_captcha(request: Request) -> Response:
media_type="text/html",
)

# Set a signed cookie to mark CAPTCHA as verified
cookie_value = create_secure_cookie(cf_turnstile_response)
redirect_response = RedirectResponse(
url=f"{CHAINLIT_URI}/", status_code=302, headers=HEADER_DONT_CACHE
)
redirect_response.set_cookie(
key="captcha_verified",
value=cookie_value,
max_age=3600, # Cookie expires in 1 hour
secure=True, # HTTPS only
httponly=True, # inaccessible to client side JS
)
# A fresh nonce and issue time -- not the Turnstile token, which the old
# cookie carried and which named nothing a limit could count.
_set_pass(redirect_response)

return redirect_response

Expand Down
84 changes: 84 additions & 0 deletions src/util/captcha_cookie.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"""The cookie that says this browser passed the human check.

Review of the public surface (area 1b, 2026-10-03) found the first version was
`value|HMAC(secret, value)` with no issue time. `max_age` is only a hint to the
browser, so one solved challenge was a pass for ever, for any client, and --
since beta signs with production's Turnstile secret -- on production too.

Now the signed value carries its issue time and a random nonce:

- **It expires on the server.** A cookie older than `MAX_AGE_SECONDS` fails,
whatever the browser was told.
- **It renews while in use.** One older than `RENEW_AFTER_SECONDS` is re-issued
on the next HTTP response, so an open tab keeps working past the first hour
instead of its uploads and buttons starting to fail.
- **The nonce names one solve.** Rate limits key on it: unlike Chainlit's
session id, the client cannot mint a new one without solving another
challenge.

Pure functions over the secret and the clock, so the rules are tested here; the
gate in `bin/chat-fastapi.py` is wiring.
"""

import hashlib
import hmac
import secrets
from dataclasses import dataclass
from http.cookies import CookieError, SimpleCookie

COOKIE_NAME = "captcha_verified"
VERSION = "v2"
MAX_AGE_SECONDS = 12 * 60 * 60
RENEW_AFTER_SECONDS = 30 * 60
#: A clock a little behind ours must not make a fresh cookie look from the
#: future and fail.
FUTURE_SKEW_SECONDS = 60


@dataclass(frozen=True)
class Check:
ok: bool
#: Which solve this is, for rate limiting. Empty unless `ok`.
nonce: str = ""
#: Old enough that the response should carry a fresh cookie.
renew: bool = False


def _sign(secret: str, value: str) -> str:
return hmac.new(secret.encode(), value.encode(), hashlib.sha256).hexdigest()


def mint(secret: str, now: float) -> str:
"""A fresh cookie value: version, issue time, nonce, signature."""
value = f"{VERSION}.{int(now)}.{secrets.token_urlsafe(16)}"
return f"{value}|{_sign(secret, value)}"


def check(cookie_value: str | None, secret: str, now: float) -> Check:
"""Whether a cookie value is a current pass. Never raises."""
if not cookie_value or not secret:
return Check(ok=False)
value, _, signature = cookie_value.partition("|")
if not hmac.compare_digest(signature, _sign(secret, value)):
return Check(ok=False)
parts = value.split(".")
if len(parts) != 3 or parts[0] != VERSION or not parts[1].isdigit():
# Signed but in the old, timeless format: a pass that never expired.
return Check(ok=False)
age = now - int(parts[1])
if age > MAX_AGE_SECONDS or age < -FUTURE_SKEW_SECONDS:
return Check(ok=False)
return Check(ok=True, nonce=parts[2], renew=age > RENEW_AFTER_SECONDS)


def from_cookie_header(header: str | None) -> str | None:
"""This cookie's value from a raw `Cookie:` header, or None."""
if not header:
return None
jar: SimpleCookie = SimpleCookie()
try:
jar.load(header)
except CookieError:
return None
morsel = jar.get(COOKIE_NAME)
return morsel.value if morsel else None
6 changes: 3 additions & 3 deletions src/util/rate_limit.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from collections import deque


def _positive_int(name: str, default: int) -> int:
def positive_int(name: str, default: int) -> int:
"""Configuration that is absent, empty or nonsense falls back to the default."""
raw = os.getenv(name, "")
if not raw.strip():
Expand Down Expand Up @@ -98,6 +98,6 @@ def limiter_from_env() -> SlidingWindowLimiter:
than anyone reads -- and it still caps a leaked token at 180 an hour.
"""
return SlidingWindowLimiter(
limit=_positive_int("ANSWER_RATE_LIMIT", 30),
window=float(_positive_int("ANSWER_RATE_WINDOW_SECONDS", 600)),
limit=positive_int("ANSWER_RATE_LIMIT", 30),
window=float(positive_int("ANSWER_RATE_WINDOW_SECONDS", 600)),
)
Loading
Loading