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_ID= # [SECRET] Chat OAuth2 client ID (auto-generated by init)
|
||||||
CHAT_OAUTH_CLIENT_SECRET= # [SECRET] Chat OAuth2 client secret (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)
|
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) ────────────────────────
|
# ── Vault-only secrets (DO NOT put these in .env) ────────────────────────
|
||||||
# These tokens grant access to external systems (GitHub, ClawHub, deploy targets).
|
# 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.
|
disinto-chat server — minimal HTTP backend for Claude chat UI.
|
||||||
|
|
||||||
Routes:
|
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/login → 302 to Forgejo OAuth authorize
|
||||||
GET /chat/oauth/callback → exchange code for token, validate user, set session
|
GET /chat/oauth/callback → exchange code for token, validate user, set session
|
||||||
GET /chat/ → serves index.html (session required)
|
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", "")
|
CHAT_OAUTH_CLIENT_SECRET = os.environ.get("CHAT_OAUTH_CLIENT_SECRET", "")
|
||||||
EDGE_TUNNEL_FQDN = os.environ.get("EDGE_TUNNEL_FQDN", "")
|
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 users — disinto-admin always allowed; CSV allowlist extends it
|
||||||
_allowed_csv = os.environ.get("DISINTO_CHAT_ALLOWED_USERS", "")
|
_allowed_csv = os.environ.get("DISINTO_CHAT_ALLOWED_USERS", "")
|
||||||
ALLOWED_USERS = {"disinto-admin"}
|
ALLOWED_USERS = {"disinto-admin"}
|
||||||
|
|
@ -186,11 +193,44 @@ class ChatHandler(BaseHTTPRequestHandler):
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
return None
|
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):
|
def do_GET(self):
|
||||||
"""Handle GET requests."""
|
"""Handle GET requests."""
|
||||||
parsed = urlparse(self.path)
|
parsed = urlparse(self.path)
|
||||||
path = parsed.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)
|
# OAuth routes (no session required)
|
||||||
if path == "/chat/login":
|
if path == "/chat/login":
|
||||||
self.handle_login()
|
self.handle_login()
|
||||||
|
|
@ -202,14 +242,20 @@ class ChatHandler(BaseHTTPRequestHandler):
|
||||||
|
|
||||||
# Serve index.html at root
|
# Serve index.html at root
|
||||||
if path in ("/", "/chat", "/chat/"):
|
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
|
return
|
||||||
self.serve_index()
|
self.serve_index()
|
||||||
return
|
return
|
||||||
|
|
||||||
# Serve static files
|
# Serve static files
|
||||||
if path.startswith("/chat/static/") or path.startswith("/static/"):
|
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
|
return
|
||||||
self.serve_static(path)
|
self.serve_static(path)
|
||||||
return
|
return
|
||||||
|
|
@ -229,7 +275,10 @@ class ChatHandler(BaseHTTPRequestHandler):
|
||||||
|
|
||||||
# Chat endpoint (session required)
|
# Chat endpoint (session required)
|
||||||
if path in ("/chat", "/chat/"):
|
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
|
return
|
||||||
self.handle_chat()
|
self.handle_chat()
|
||||||
return
|
return
|
||||||
|
|
@ -237,6 +286,36 @@ class ChatHandler(BaseHTTPRequestHandler):
|
||||||
# 404 for unknown paths
|
# 404 for unknown paths
|
||||||
self.send_error_page(404, "Not found")
|
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):
|
def handle_login(self):
|
||||||
"""Redirect to Forgejo OAuth authorize endpoint."""
|
"""Redirect to Forgejo OAuth authorize endpoint."""
|
||||||
_gc_sessions()
|
_gc_sessions()
|
||||||
|
|
@ -440,6 +519,10 @@ def main():
|
||||||
print(f"Allowed users: {', '.join(sorted(ALLOWED_USERS))}", file=sys.stderr)
|
print(f"Allowed users: {', '.join(sorted(ALLOWED_USERS))}", file=sys.stderr)
|
||||||
else:
|
else:
|
||||||
print("WARNING: CHAT_OAUTH_CLIENT_ID not set — OAuth disabled", file=sys.stderr)
|
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()
|
httpd.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -430,6 +430,8 @@ services:
|
||||||
- EDGE_TUNNEL_USER=${EDGE_TUNNEL_USER:-tunnel}
|
- EDGE_TUNNEL_USER=${EDGE_TUNNEL_USER:-tunnel}
|
||||||
- EDGE_TUNNEL_PORT=${EDGE_TUNNEL_PORT:-}
|
- EDGE_TUNNEL_PORT=${EDGE_TUNNEL_PORT:-}
|
||||||
- EDGE_TUNNEL_FQDN=${EDGE_TUNNEL_FQDN:-}
|
- EDGE_TUNNEL_FQDN=${EDGE_TUNNEL_FQDN:-}
|
||||||
|
# Shared secret for Caddy ↔ chat forward_auth (#709)
|
||||||
|
- FORWARD_AUTH_SECRET=${FORWARD_AUTH_SECRET:-}
|
||||||
volumes:
|
volumes:
|
||||||
- ./docker/Caddyfile:/etc/caddy/Caddyfile
|
- ./docker/Caddyfile:/etc/caddy/Caddyfile
|
||||||
- caddy_data:/data
|
- caddy_data:/data
|
||||||
|
|
@ -505,6 +507,8 @@ services:
|
||||||
CHAT_OAUTH_CLIENT_SECRET: ${CHAT_OAUTH_CLIENT_SECRET:-}
|
CHAT_OAUTH_CLIENT_SECRET: ${CHAT_OAUTH_CLIENT_SECRET:-}
|
||||||
EDGE_TUNNEL_FQDN: ${EDGE_TUNNEL_FQDN:-}
|
EDGE_TUNNEL_FQDN: ${EDGE_TUNNEL_FQDN:-}
|
||||||
DISINTO_CHAT_ALLOWED_USERS: ${DISINTO_CHAT_ALLOWED_USERS:-}
|
DISINTO_CHAT_ALLOWED_USERS: ${DISINTO_CHAT_ALLOWED_USERS:-}
|
||||||
|
# Shared secret for Caddy forward_auth verify endpoint (#709)
|
||||||
|
FORWARD_AUTH_SECRET: ${FORWARD_AUTH_SECRET:-}
|
||||||
networks:
|
networks:
|
||||||
- disinto-net
|
- disinto-net
|
||||||
|
|
||||||
|
|
@ -611,7 +615,20 @@ _generate_caddyfile_impl() {
|
||||||
}
|
}
|
||||||
|
|
||||||
# Chat service — reverse proxy to disinto-chat backend (#705)
|
# 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/* {
|
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
|
reverse_proxy chat:8080
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue