Merge pull request 'fix: vision(#623): Caddy Remote-User forwarding + chat-side validation (defense-in-depth) (#709)' (#728) from fix/issue-709 into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
This commit is contained in:
commit
99becf027e
3 changed files with 104 additions and 3 deletions
|
|
@ -73,6 +73,7 @@ WOODPECKER_DB_NAME=woodpecker # [CONFIG] Postgres database name
|
|||
CHAT_OAUTH_CLIENT_ID= # [SECRET] Chat OAuth2 client ID (auto-generated by init)
|
||||
CHAT_OAUTH_CLIENT_SECRET= # [SECRET] Chat OAuth2 client secret (auto-generated by init)
|
||||
DISINTO_CHAT_ALLOWED_USERS= # [CONFIG] CSV of allowed usernames (disinto-admin always allowed)
|
||||
FORWARD_AUTH_SECRET= # [SECRET] Shared secret for Caddy ↔ chat forward_auth (#709)
|
||||
|
||||
# ── Vault-only secrets (DO NOT put these in .env) ────────────────────────
|
||||
# These tokens grant access to external systems (GitHub, ClawHub, deploy targets).
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
disinto-chat server — minimal HTTP backend for Claude chat UI.
|
||||
|
||||
Routes:
|
||||
GET /chat/auth/verify → Caddy forward_auth callback (returns 200+X-Forwarded-User or 401)
|
||||
GET /chat/login → 302 to Forgejo OAuth authorize
|
||||
GET /chat/oauth/callback → exchange code for token, validate user, set session
|
||||
GET /chat/ → serves index.html (session required)
|
||||
|
|
@ -43,6 +44,12 @@ CHAT_OAUTH_CLIENT_ID = os.environ.get("CHAT_OAUTH_CLIENT_ID", "")
|
|||
CHAT_OAUTH_CLIENT_SECRET = os.environ.get("CHAT_OAUTH_CLIENT_SECRET", "")
|
||||
EDGE_TUNNEL_FQDN = os.environ.get("EDGE_TUNNEL_FQDN", "")
|
||||
|
||||
# Shared secret for Caddy forward_auth verify endpoint (#709).
|
||||
# When set, only requests carrying this value in X-Forward-Auth-Secret are
|
||||
# allowed to call /chat/auth/verify. When empty the endpoint is unrestricted
|
||||
# (acceptable during local dev; production MUST set this).
|
||||
FORWARD_AUTH_SECRET = os.environ.get("FORWARD_AUTH_SECRET", "")
|
||||
|
||||
# Allowed users — disinto-admin always allowed; CSV allowlist extends it
|
||||
_allowed_csv = os.environ.get("DISINTO_CHAT_ALLOWED_USERS", "")
|
||||
ALLOWED_USERS = {"disinto-admin"}
|
||||
|
|
@ -186,11 +193,44 @@ class ChatHandler(BaseHTTPRequestHandler):
|
|||
self.end_headers()
|
||||
return None
|
||||
|
||||
def _check_forwarded_user(self, session_user):
|
||||
"""Defense-in-depth: verify X-Forwarded-User matches session user (#709).
|
||||
|
||||
Returns True if the request may proceed, False if a 403 was sent.
|
||||
When X-Forwarded-User is absent (forward_auth removed from Caddy),
|
||||
the request is rejected — fail-closed by design.
|
||||
"""
|
||||
forwarded = self.headers.get("X-Forwarded-User")
|
||||
if not forwarded:
|
||||
rid = self.headers.get("X-Request-Id", "-")
|
||||
print(
|
||||
f"WARN: missing X-Forwarded-User for session_user={session_user} "
|
||||
f"req_id={rid} — fail-closed (#709)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
self.send_error_page(403, "Forbidden: missing forwarded-user header")
|
||||
return False
|
||||
if forwarded != session_user:
|
||||
rid = self.headers.get("X-Request-Id", "-")
|
||||
print(
|
||||
f"WARN: X-Forwarded-User mismatch: header={forwarded} "
|
||||
f"session={session_user} req_id={rid} (#709)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
self.send_error_page(403, "Forbidden: user identity mismatch")
|
||||
return False
|
||||
return True
|
||||
|
||||
def do_GET(self):
|
||||
"""Handle GET requests."""
|
||||
parsed = urlparse(self.path)
|
||||
path = parsed.path
|
||||
|
||||
# Verify endpoint for Caddy forward_auth (#709)
|
||||
if path == "/chat/auth/verify":
|
||||
self.handle_auth_verify()
|
||||
return
|
||||
|
||||
# OAuth routes (no session required)
|
||||
if path == "/chat/login":
|
||||
self.handle_login()
|
||||
|
|
@ -202,14 +242,20 @@ class ChatHandler(BaseHTTPRequestHandler):
|
|||
|
||||
# Serve index.html at root
|
||||
if path in ("/", "/chat", "/chat/"):
|
||||
if not self._require_session():
|
||||
user = self._require_session()
|
||||
if not user:
|
||||
return
|
||||
if not self._check_forwarded_user(user):
|
||||
return
|
||||
self.serve_index()
|
||||
return
|
||||
|
||||
# Serve static files
|
||||
if path.startswith("/chat/static/") or path.startswith("/static/"):
|
||||
if not self._require_session():
|
||||
user = self._require_session()
|
||||
if not user:
|
||||
return
|
||||
if not self._check_forwarded_user(user):
|
||||
return
|
||||
self.serve_static(path)
|
||||
return
|
||||
|
|
@ -229,7 +275,10 @@ class ChatHandler(BaseHTTPRequestHandler):
|
|||
|
||||
# Chat endpoint (session required)
|
||||
if path in ("/chat", "/chat/"):
|
||||
if not self._require_session():
|
||||
user = self._require_session()
|
||||
if not user:
|
||||
return
|
||||
if not self._check_forwarded_user(user):
|
||||
return
|
||||
self.handle_chat()
|
||||
return
|
||||
|
|
@ -237,6 +286,36 @@ class ChatHandler(BaseHTTPRequestHandler):
|
|||
# 404 for unknown paths
|
||||
self.send_error_page(404, "Not found")
|
||||
|
||||
def handle_auth_verify(self):
|
||||
"""Caddy forward_auth callback — validate session and return X-Forwarded-User (#709).
|
||||
|
||||
Caddy calls this endpoint for every /chat/* request. If the session
|
||||
cookie is valid the endpoint returns 200 with the X-Forwarded-User
|
||||
header set to the session username. Otherwise it returns 401 so Caddy
|
||||
knows the request is unauthenticated.
|
||||
|
||||
Access control: when FORWARD_AUTH_SECRET is configured, the request must
|
||||
carry a matching X-Forward-Auth-Secret header (shared secret between
|
||||
Caddy and the chat backend).
|
||||
"""
|
||||
# Shared-secret gate
|
||||
if FORWARD_AUTH_SECRET:
|
||||
provided = self.headers.get("X-Forward-Auth-Secret", "")
|
||||
if not secrets.compare_digest(provided, FORWARD_AUTH_SECRET):
|
||||
self.send_error_page(403, "Forbidden: invalid forward-auth secret")
|
||||
return
|
||||
|
||||
user = _validate_session(self.headers.get("Cookie"))
|
||||
if not user:
|
||||
self.send_error_page(401, "Unauthorized: no valid session")
|
||||
return
|
||||
|
||||
self.send_response(200)
|
||||
self.send_header("X-Forwarded-User", user)
|
||||
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
||||
self.end_headers()
|
||||
self.wfile.write(b"ok")
|
||||
|
||||
def handle_login(self):
|
||||
"""Redirect to Forgejo OAuth authorize endpoint."""
|
||||
_gc_sessions()
|
||||
|
|
@ -440,6 +519,10 @@ def main():
|
|||
print(f"Allowed users: {', '.join(sorted(ALLOWED_USERS))}", file=sys.stderr)
|
||||
else:
|
||||
print("WARNING: CHAT_OAUTH_CLIENT_ID not set — OAuth disabled", file=sys.stderr)
|
||||
if FORWARD_AUTH_SECRET:
|
||||
print("forward_auth secret configured (#709)", file=sys.stderr)
|
||||
else:
|
||||
print("WARNING: FORWARD_AUTH_SECRET not set — verify endpoint unrestricted", file=sys.stderr)
|
||||
httpd.serve_forever()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -430,6 +430,8 @@ services:
|
|||
- EDGE_TUNNEL_USER=${EDGE_TUNNEL_USER:-tunnel}
|
||||
- EDGE_TUNNEL_PORT=${EDGE_TUNNEL_PORT:-}
|
||||
- EDGE_TUNNEL_FQDN=${EDGE_TUNNEL_FQDN:-}
|
||||
# Shared secret for Caddy ↔ chat forward_auth (#709)
|
||||
- FORWARD_AUTH_SECRET=${FORWARD_AUTH_SECRET:-}
|
||||
volumes:
|
||||
- ./docker/Caddyfile:/etc/caddy/Caddyfile
|
||||
- caddy_data:/data
|
||||
|
|
@ -505,6 +507,8 @@ services:
|
|||
CHAT_OAUTH_CLIENT_SECRET: ${CHAT_OAUTH_CLIENT_SECRET:-}
|
||||
EDGE_TUNNEL_FQDN: ${EDGE_TUNNEL_FQDN:-}
|
||||
DISINTO_CHAT_ALLOWED_USERS: ${DISINTO_CHAT_ALLOWED_USERS:-}
|
||||
# Shared secret for Caddy forward_auth verify endpoint (#709)
|
||||
FORWARD_AUTH_SECRET: ${FORWARD_AUTH_SECRET:-}
|
||||
networks:
|
||||
- disinto-net
|
||||
|
||||
|
|
@ -611,7 +615,20 @@ _generate_caddyfile_impl() {
|
|||
}
|
||||
|
||||
# Chat service — reverse proxy to disinto-chat backend (#705)
|
||||
# OAuth routes bypass forward_auth — unauthenticated users need these (#709)
|
||||
handle /chat/login {
|
||||
reverse_proxy chat:8080
|
||||
}
|
||||
handle /chat/oauth/callback {
|
||||
reverse_proxy chat:8080
|
||||
}
|
||||
# Defense-in-depth: forward_auth stamps X-Forwarded-User from session (#709)
|
||||
handle /chat/* {
|
||||
forward_auth chat:8080 {
|
||||
uri /chat/auth/verify
|
||||
copy_headers X-Forwarded-User
|
||||
header_up X-Forward-Auth-Secret {$FORWARD_AUTH_SECRET}
|
||||
}
|
||||
reverse_proxy chat:8080
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue