Skip to content

Commit 22fecba

Browse files
cnjackclaude
andauthored
feat(web): require token auth when bound to a non-loopback host (#105)
* feat(web): require token auth when bound to a non-loopback host The web server had no authentication — the only gate was an Origin check, which does not stop direct (non-browser) requests. Binding to a non-loopback host (`--host 0.0.0.0`) therefore exposed full agent control (shell/file tools, MCP) to anyone who could reach the port. When bound to a non-loopback host (or when a token is explicitly provided), the server now requires a bearer token: - internal/web/auth.go: authMiddleware enforces the token on all non-exempt requests (positive allowlist: SPA assets, /api/health, /api/auth/verify). Token is read from Authorization: Bearer, the `jcode-auth` WebSocket subprotocol, or a ?token= fallback; compared in constant time. - command/web.go: new --auth-token flag (also JCODE_WEB_TOKEN); otherwise a token is auto-generated, persisted to ~/.jcode/web_token (0600), and printed in the startup banner. Loopback binds keep the existing no-auth behaviour. - Frontend: a TokenGate login page (shown when /api/health reports auth_required), Authorization-header injection, and WS token via subprotocol. - pty.go: fix the PTY WebSocket CheckOrigin (was unconditionally true) to isAllowedWebOrigin as defence in depth. Loopback default deployments keep no-auth (unchanged); the separate simple-request CSRF hardening for that case is tracked independently. Tests: internal/web/auth_test.go covers loopback detection, token extraction, constant-time compare, the exemption allowlist, and middleware allow/deny. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(web): address CodeRabbit review on token auth - auth.go: limit ?token= fallback to the WebSocket endpoints only (keep bearer tokens out of HTTP access/proxy logs and history); add IsValidWSSubprotocolToken. - command/web.go: reject explicit tokens that aren't valid WS subprotocol values; MkdirAll ~/.jcode before persisting the auto-generated token so a first remote start doesn't silently fall back to a session-only token. - api.ts: normalize HeadersInit via Headers; attach response status to thrown errors so callers can distinguish 401 from transport/5xx. - App.vue / TokenGate.vue: only treat 401 as an invalid token; transport/5xx now surface as connection/server errors instead of forcing the login gate. - authToken.ts: return a readonly token ref from useAuthToken. - TokenGate.vue: use the --color-error-fg design token (drop hardcoded hex). - i18n: add auth.serverError (en/zh-Hans/zh-Hant/ja/ko). - auth_test.go: cover ws-only query fallback and IsValidWSSubprotocolToken. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent cbc2ed4 commit 22fecba

16 files changed

Lines changed: 854 additions & 26 deletions

File tree

internal/command/web.go

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@ package command
22

33
import (
44
"context"
5+
"crypto/rand"
6+
"encoding/base64"
57
"encoding/json"
68
"fmt"
79
"os"
810
"os/signal"
911
"path/filepath"
12+
"strings"
1013
"sync"
1114
"sync/atomic"
1215
"syscall"
@@ -61,17 +64,19 @@ func NewWebCmd() *cobra.Command {
6164
var port int
6265
var host string
6366
var openBrowser bool
67+
var authToken string
6468
cmd := &cobra.Command{
6569
Use: "web",
6670
Short: "Start the web server",
6771
SilenceUsage: true,
6872
RunE: func(cmd *cobra.Command, args []string) error {
69-
return runWebServer(port, host, openBrowser)
73+
return runWebServer(port, host, openBrowser, authToken)
7074
},
7175
}
7276
cmd.Flags().IntVar(&port, "port", 8080, "HTTP server port")
7377
cmd.Flags().StringVar(&host, "host", "127.0.0.1", "HTTP server host")
7478
cmd.Flags().BoolVar(&openBrowser, "open", true, "Open browser after server starts")
79+
cmd.Flags().StringVar(&authToken, "auth-token", "", "Bearer token required when bound to a non-loopback host (auto-generated if empty). Can also be set via JCODE_WEB_TOKEN.")
7580
return cmd
7681
}
7782

@@ -97,7 +102,64 @@ func dropInteractiveTools(tools []tool.BaseTool) []tool.BaseTool {
97102
return out
98103
}
99104

100-
func runWebServer(port int, host string, openBrowser bool) error {
105+
// resolveWebToken decides the web auth token and whether auth must be enforced.
106+
//
107+
// Auth is required when the bind host is non-loopback (exposed to the network),
108+
// or when a token was explicitly supplied. Token source priority:
109+
// 1. --auth-token flag / JCODE_WEB_TOKEN env — session-scoped, never written to disk
110+
// 2. ~/.jcode/web_token — persisted (0600), reused across restarts
111+
// 3. auto-generated (32 random bytes, base64url) when exposed and none of the
112+
// above; persisted to ~/.jcode/web_token so the token is stable across restarts
113+
func resolveWebToken(host, flagToken string) (token string, requireAuth bool, err error) {
114+
explicit := flagToken
115+
if explicit == "" {
116+
explicit = os.Getenv("JCODE_WEB_TOKEN")
117+
}
118+
// Explicit token (flag/env): enforce auth, never touch disk (session-scoped).
119+
if explicit != "" {
120+
if !web.IsValidWSSubprotocolToken(explicit) {
121+
return "", true, fmt.Errorf("auth token must be printable ASCII with no spaces or separators (it is sent as a WebSocket subprotocol)")
122+
}
123+
return explicit, true, nil
124+
}
125+
// Loopback bind with no explicit token: keep the existing no-auth behaviour.
126+
if web.IsLoopbackBind(host) {
127+
return "", false, nil
128+
}
129+
// Exposed bind, no explicit token: reuse a persisted token or generate one.
130+
dir := config.ConfigDir()
131+
path := filepath.Join(dir, "web_token")
132+
if b, rerr := os.ReadFile(path); rerr == nil {
133+
if t := strings.TrimSpace(string(b)); t != "" {
134+
return t, true, nil
135+
}
136+
}
137+
gen, gerr := generateWebToken()
138+
if gerr != nil {
139+
return "", true, fmt.Errorf("generate web token: %w", gerr)
140+
}
141+
// Ensure ~/.jcode exists before writing, otherwise a first remote start would
142+
// fail with ENOENT and silently fall back to a session-scoped token.
143+
if merr := os.MkdirAll(dir, 0o700); merr != nil {
144+
config.Logger().Printf("[web] could not create config dir %s: %v", dir, merr)
145+
} else if werr := os.WriteFile(path, []byte(gen), 0o600); werr != nil {
146+
// Non-fatal: fall back to a session-scoped token (auth still enforced).
147+
config.Logger().Printf("[web] could not persist web token to %s: %v", path, werr)
148+
}
149+
return gen, true, nil
150+
}
151+
152+
// generateWebToken returns 32 cryptographically-random bytes as a URL-safe
153+
// base64 string (no padding).
154+
func generateWebToken() (string, error) {
155+
var b [32]byte
156+
if _, err := rand.Read(b[:]); err != nil {
157+
return "", err
158+
}
159+
return base64.RawURLEncoding.EncodeToString(b[:]), nil
160+
}
161+
162+
func runWebServer(port int, host string, openBrowser bool, authToken string) error {
101163
// Check if we need setup (no providers configured).
102164
needsSetup := config.NeedsSetup()
103165

@@ -540,6 +602,18 @@ func runWebServer(port int, host string, openBrowser bool) error {
540602
}, nil
541603
}
542604

605+
// Resolve the web auth token. Auth is enforced when bound to a non-loopback
606+
// host (exposed to the network) or when a token was explicitly provided.
607+
webToken, requireAuth, err := resolveWebToken(host, authToken)
608+
if err != nil {
609+
return err
610+
}
611+
if requireAuth {
612+
fmt.Printf("\n🔐 Web access token (required when reaching %s):\n %s\n", host, webToken)
613+
fmt.Printf(" Open http://%s:%d/ and paste this token to sign in.\n\n", host, port)
614+
config.Logger().Printf("[web] token auth enabled for non-loopback bind %q", host)
615+
}
616+
543617
// Bootstrap engine for the initial task.
544618
bootEC, err := buildWebTask("", pwd, startupMode.String(), nil, false)
545619
if err != nil {
@@ -588,6 +662,8 @@ func runWebServer(port int, host string, openBrowser bool) error {
588662
TokenUsage: bootEC.TokenUsage,
589663
ContextBreakdownFn: bootEC.BreakdownFn,
590664
Automations: autoStore,
665+
AuthToken: webToken,
666+
RequireAuth: requireAuth,
591667
})
592668

593669
// Start the periodic automation scheduler. A single process owns periodic

internal/web/auth.go

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
package web
2+
3+
import (
4+
"crypto/subtle"
5+
"net"
6+
"net/http"
7+
"strings"
8+
)
9+
10+
// wsAuthSubprotocol is the WebSocket subprotocol name under which the bearer
11+
// token rides on handshakes. Browsers cannot set custom headers on WebSocket
12+
// connections, so the frontend sends ["jcode-auth", "<token>"] and the token is
13+
// read from the second value. The server also advertises this subprotocol on
14+
// the upgrader so gorilla echoes the protocol name back and the handshake
15+
// completes cleanly.
16+
const wsAuthSubprotocol = "jcode-auth"
17+
18+
// IsLoopbackBind reports whether the given bind host is loopback-only.
19+
//
20+
// An empty host, "0.0.0.0", or "::" binds all interfaces and is treated as
21+
// exposed (non-loopback). "localhost" and any IP whose IsLoopback() is true are
22+
// loopback. A hostname we cannot resolve statically is conservatively treated as
23+
// exposed, so we fail safe (require auth) rather than fail open.
24+
func IsLoopbackBind(host string) bool {
25+
switch host {
26+
case "", "0.0.0.0", "::":
27+
return false
28+
case "localhost":
29+
return true
30+
}
31+
if ip := net.ParseIP(host); ip != nil {
32+
return ip.IsLoopback()
33+
}
34+
return false // unknown hostname → assume exposed, require auth
35+
}
36+
37+
// extractToken pulls the bearer token from a request, in priority order:
38+
// 1. Authorization: Bearer <token>
39+
// 2. Sec-WebSocket-Protocol: jcode-auth, <token> (browser WebSocket handshakes
40+
// can't carry custom headers, so the token rides as the second subprotocol)
41+
// 3. ?token=<token> (fallback for non-browser ws clients; discouraged because
42+
// it lands in access logs, proxy logs and browser history)
43+
func extractToken(r *http.Request) string {
44+
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
45+
return strings.TrimSpace(strings.TrimPrefix(h, "Bearer "))
46+
}
47+
if protos := r.Header.Get("Sec-WebSocket-Protocol"); protos != "" {
48+
parts := strings.Split(protos, ",")
49+
for i, p := range parts {
50+
if strings.TrimSpace(p) == wsAuthSubprotocol && i+1 < len(parts) {
51+
return strings.TrimSpace(parts[i+1])
52+
}
53+
}
54+
}
55+
// The ?token= fallback exists only for non-browser WebSocket clients that can
56+
// set neither a header nor a subprotocol. Restrict it to the WS endpoints so
57+
// bearer tokens for normal HTTP APIs never land in access/proxy logs, the
58+
// Referer header, or browser history.
59+
if acceptsQueryToken(r.URL.Path) {
60+
return r.URL.Query().Get("token")
61+
}
62+
return ""
63+
}
64+
65+
// acceptsQueryToken reports whether the ?token= fallback is allowed for a path —
66+
// only the WebSocket endpoints (the main event stream and the PTY sockets).
67+
func acceptsQueryToken(path string) bool {
68+
return path == "/api/ws" || (strings.HasPrefix(path, "/api/pty/") && strings.HasSuffix(path, "/ws"))
69+
}
70+
71+
// IsValidWSSubprotocolToken reports whether s is usable as a WebSocket
72+
// subprotocol value (RFC 6455 / RFC 7230 token): non-empty, printable ASCII with
73+
// no spaces or separators. The frontend sends the token as the second WS
74+
// subprotocol, so an explicit token containing such characters would make the
75+
// browser's WebSocket constructor throw and break the connection at startup.
76+
func IsValidWSSubprotocolToken(s string) bool {
77+
if s == "" {
78+
return false
79+
}
80+
for _, r := range s {
81+
if r < 0x21 || r > 0x7e {
82+
return false // control chars, space, and non-ASCII
83+
}
84+
switch r {
85+
case '(', ')', '<', '>', '@', ',', ';', ':', '\\', '"', '/', '[', ']', '?', '=', '{', '}':
86+
return false // RFC 7230 separators
87+
}
88+
}
89+
return true
90+
}
91+
92+
// validToken compares the provided token against the expected one in constant
93+
// time. An empty expected or provided token never validates.
94+
func validToken(provided, expected string) bool {
95+
if expected == "" || provided == "" {
96+
return false
97+
}
98+
return subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) == 1
99+
}
100+
101+
// isAuthExempt reports whether a request may proceed without a token even when
102+
// auth is required. It is a POSITIVE allowlist rather than a "/api/* needs auth"
103+
// rule, so an unregistered /api path that falls through to the SPA handler can't
104+
// sneak past: anything under /api/ requires a token unless explicitly listed.
105+
func isAuthExempt(r *http.Request) bool {
106+
if r.Method == http.MethodOptions {
107+
return true // defensive: preflight is normally short-circuited by corsMiddleware
108+
}
109+
p := r.URL.Path
110+
if r.Method == http.MethodGet && p == "/api/health" {
111+
return true // the frontend probes this before it has a token
112+
}
113+
if r.Method == http.MethodPost && p == "/api/auth/verify" {
114+
return true // the endpoint the login page calls to validate a typed token
115+
}
116+
// Everything outside /api/ is the SPA shell + embedded static assets: the
117+
// login page itself must load before the user has a token.
118+
return !strings.HasPrefix(p, "/api/")
119+
}
120+
121+
// authMiddleware enforces token auth when requireAuth is set. corsMiddleware
122+
// MUST wrap this — corsMiddleware(s.authMiddleware(mux)) — so OPTIONS preflights
123+
// are answered by cors and never reach here without an Authorization header.
124+
func (s *Server) authMiddleware(next http.Handler) http.Handler {
125+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
126+
if !s.requireAuth || isAuthExempt(r) {
127+
next.ServeHTTP(w, r)
128+
return
129+
}
130+
if !validToken(extractToken(r), s.authToken) {
131+
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
132+
return
133+
}
134+
next.ServeHTTP(w, r)
135+
})
136+
}
137+
138+
// handleAuthVerify lets the login page check a token the user typed in. It reads
139+
// the token the same way the middleware does and returns 200 on a match, 401
140+
// otherwise. When auth is not required it always succeeds.
141+
func (s *Server) handleAuthVerify(w http.ResponseWriter, r *http.Request) {
142+
if !s.requireAuth || validToken(extractToken(r), s.authToken) {
143+
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
144+
return
145+
}
146+
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid token"})
147+
}

0 commit comments

Comments
 (0)