返回 DeepSeek-Reasonix
auth_test.go
根目录 / internal / serve / auth_test.go
1 package serve
2
3 import (
4 "net/http"
5 "net/http/httptest"
6 "net/url"
7 "strings"
8 "testing"
9 "time"
10
11 "reasonix/internal/config"
12 )
13
14 func TestGenerateToken(t *testing.T) {
15 t1 := generateToken()
16 t2 := generateToken()
17 if t1 == t2 {
18 t.Error("generateToken should produce unique values")
19 }
20 if len(t1) < 32 {
21 t.Errorf("token too short: %d bytes", len(t1))
22 }
23 // Should be base64url-encoded (no +, no /).
24 if strings.ContainsAny(t1, "+/") {
25 t.Errorf("token contains non-base64url chars: %q", t1)
26 }
27 }
28
29 func TestHashPassword(t *testing.T) {
30 h, err := HashPassword("test-password")
31 if err != nil {
32 t.Fatal(err)
33 }
34 if !strings.HasPrefix(h, "$2a$12$") {
35 t.Errorf("unexpected bcrypt prefix: %s", h[:20])
36 }
37 // Same password should produce a different hash (random salt).
38 h2, _ := HashPassword("test-password")
39 if h == h2 {
40 t.Error("same password should produce different hash")
41 }
42 }
43
44 func TestAuthGateModeNone(t *testing.T) {
45 ag := newAuthGate(config.ServeConfig{}) // default: authNone
46 if ag.Mode() != "none" {
47 t.Errorf("mode = %q, want none", ag.Mode())
48 }
49 if ag.Token() != "" {
50 t.Errorf("token = %q, want empty", ag.Token())
51 }
52
53 // In none mode, requests pass through.
54 ts := httptest.NewServer(ag.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
55 w.WriteHeader(http.StatusOK)
56 })))
57 defer ts.Close()
58
59 resp, err := http.Get(ts.URL + "/status")
60 if err != nil {
61 t.Fatal(err)
62 }
63 resp.Body.Close()
64 if resp.StatusCode != http.StatusOK {
65 t.Errorf("status = %d, want 200", resp.StatusCode)
66 }
67 }
68
69 func TestNormalizeAuthModeRejectsUnknown(t *testing.T) {
70 if _, err := NormalizeAuthMode("tokne"); err == nil {
71 t.Fatal("unknown auth mode should be rejected")
72 }
73 }
74
75 func TestInvalidAuthModeFailsClosed(t *testing.T) {
76 ag := newAuthGate(config.ServeConfig{AuthMode: "tokne"})
77 if ag.Mode() != "invalid" {
78 t.Errorf("mode = %q, want invalid", ag.Mode())
79 }
80 ts := httptest.NewServer(ag.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
81 t.Fatal("handler should not run for invalid auth mode")
82 })))
83 defer ts.Close()
84
85 resp, err := http.Get(ts.URL + "/status")
86 if err != nil {
87 t.Fatal(err)
88 }
89 resp.Body.Close()
90 if resp.StatusCode != http.StatusUnauthorized {
91 t.Errorf("status = %d, want 401", resp.StatusCode)
92 }
93 }
94
95 // ── Token mode tests ──
96
97 func TestTokenModeNoAuthReturns401(t *testing.T) {
98 ag := newAuthGate(config.ServeConfig{AuthMode: "token", Token: "secret"})
99 ts := httptest.NewServer(ag.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
100 w.WriteHeader(http.StatusOK)
101 })))
102 defer ts.Close()
103
104 resp, err := http.Get(ts.URL + "/status")
105 if err != nil {
106 t.Fatal(err)
107 }
108 resp.Body.Close()
109 if resp.StatusCode != http.StatusUnauthorized {
110 t.Errorf("status = %d, want 401", resp.StatusCode)
111 }
112 }
113
114 func TestTokenModeValidCookie(t *testing.T) {
115 ag := newAuthGate(config.ServeConfig{AuthMode: "token", Token: "secret"})
116 ts := httptest.NewServer(ag.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
117 w.WriteHeader(http.StatusOK)
118 })))
119 defer ts.Close()
120
121 req, _ := http.NewRequest("GET", ts.URL+"/status", nil)
122 req.AddCookie(&http.Cookie{Name: cookieToken, Value: "secret"})
123 resp, err := http.DefaultClient.Do(req)
124 if err != nil {
125 t.Fatal(err)
126 }
127 resp.Body.Close()
128 if resp.StatusCode != http.StatusOK {
129 t.Errorf("status = %d, want 200", resp.StatusCode)
130 }
131 }
132
133 func TestTokenModeInvalidCookie(t *testing.T) {
134 ag := newAuthGate(config.ServeConfig{AuthMode: "token", Token: "secret"})
135 ts := httptest.NewServer(ag.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
136 w.WriteHeader(http.StatusOK)
137 })))
138 defer ts.Close()
139
140 req, _ := http.NewRequest("GET", ts.URL+"/status", nil)
141 req.AddCookie(&http.Cookie{Name: cookieToken, Value: "wrong"})
142 resp, err := http.DefaultClient.Do(req)
143 if err != nil {
144 t.Fatal(err)
145 }
146 resp.Body.Close()
147 if resp.StatusCode != http.StatusUnauthorized {
148 t.Errorf("status = %d, want 401", resp.StatusCode)
149 }
150 }
151
152 func TestTokenModeValidQueryParamRedirects(t *testing.T) {
153 ag := newAuthGate(config.ServeConfig{AuthMode: "token", Token: "secret"})
154
155 // Use a handler that records whether auth passed.
156 var passed bool
157 ts := httptest.NewServer(ag.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
158 passed = true
159 w.WriteHeader(http.StatusOK)
160 })))
161 defer ts.Close()
162
163 // Don't follow redirects so we can inspect the 302.
164 client := &http.Client{
165 CheckRedirect: func(req *http.Request, via []*http.Request) error {
166 return http.ErrUseLastResponse
167 },
168 }
169 resp, err := client.Get(ts.URL + "/?token=secret")
170 if err != nil {
171 t.Fatal(err)
172 }
173 resp.Body.Close()
174 if resp.StatusCode != http.StatusFound {
175 t.Errorf("status = %d, want 302", resp.StatusCode)
176 }
177
178 // Check that the Set-Cookie header is present.
179 setCookie := resp.Header.Get("Set-Cookie")
180 if !strings.Contains(setCookie, cookieToken+"=secret") {
181 t.Errorf("Set-Cookie missing token: %s", setCookie)
182 }
183 if !strings.Contains(setCookie, "HttpOnly") {
184 t.Errorf("Set-Cookie missing HttpOnly: %s", setCookie)
185 }
186 if passed {
187 t.Error("handler should not have run (redirected first)")
188 }
189 }
190
191 func TestTokenModeLoopbackCookieAllowsLocalHTTP(t *testing.T) {
192 ag := newAuthGate(config.ServeConfig{AuthMode: "token", Token: "secret"})
193 ts := httptest.NewServer(ag.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
194 w.WriteHeader(http.StatusOK)
195 })))
196 defer ts.Close()
197
198 client := &http.Client{
199 CheckRedirect: func(req *http.Request, via []*http.Request) error {
200 return http.ErrUseLastResponse
201 },
202 }
203 resp, err := client.Get(ts.URL + "/?token=secret")
204 if err != nil {
205 t.Fatal(err)
206 }
207 resp.Body.Close()
208
209 c := findCookie(resp.Cookies(), cookieToken)
210 if c == nil {
211 t.Fatal("token cookie missing")
212 }
213 if c.Secure {
214 t.Fatal("loopback HTTP token cookie should stay usable without Secure")
215 }
216 }
217
218 func TestTokenModeNonLoopbackCookieAllowsPlainHTTP(t *testing.T) {
219 ag := newAuthGate(config.ServeConfig{AuthMode: "token", Token: "secret"})
220 ts := httptest.NewServer(ag.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
221 w.WriteHeader(http.StatusOK)
222 })))
223 defer ts.Close()
224
225 client := &http.Client{
226 CheckRedirect: func(req *http.Request, via []*http.Request) error {
227 return http.ErrUseLastResponse
228 },
229 }
230 req, _ := http.NewRequest("GET", ts.URL+"/?token=secret", nil)
231 req.Host = "192.0.2.10:8787"
232 resp, err := client.Do(req)
233 if err != nil {
234 t.Fatal(err)
235 }
236 resp.Body.Close()
237
238 c := findCookie(resp.Cookies(), cookieToken)
239 if c == nil {
240 t.Fatal("token cookie missing")
241 }
242 if c.Secure {
243 t.Fatal("plain HTTP token cookie should stay usable without Secure")
244 }
245 }
246
247 func TestTokenModeInvalidQueryParam(t *testing.T) {
248 ag := newAuthGate(config.ServeConfig{AuthMode: "token", Token: "secret"})
249 ts := httptest.NewServer(ag.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
250 w.WriteHeader(http.StatusOK)
251 })))
252 defer ts.Close()
253
254 resp, err := http.Get(ts.URL + "/?token=wrong")
255 if err != nil {
256 t.Fatal(err)
257 }
258 resp.Body.Close()
259 if resp.StatusCode != http.StatusUnauthorized {
260 t.Errorf("status = %d, want 401", resp.StatusCode)
261 }
262 }
263
264 func TestTokenModeAutoGeneratesToken(t *testing.T) {
265 ag := newAuthGate(config.ServeConfig{AuthMode: "token"})
266 if ag.Mode() != "token" {
267 t.Errorf("mode = %q, want token", ag.Mode())
268 }
269 if ag.Token() == "" {
270 t.Error("token should be auto-generated")
271 }
272 if len(ag.Token()) < 32 {
273 t.Errorf("auto-generated token too short: %d", len(ag.Token()))
274 }
275 }
276
277 // ── Password mode tests ──
278
279 func TestPasswordModeLoginPage(t *testing.T) {
280 ag := newAuthGate(config.ServeConfig{AuthMode: "password", PasswordHash: mustHash("test")})
281 ts := httptest.NewServer(ag.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
282 w.WriteHeader(http.StatusOK)
283 })))
284 defer ts.Close()
285
286 resp, err := http.Get(ts.URL + "/login")
287 if err != nil {
288 t.Fatal(err)
289 }
290 defer resp.Body.Close()
291 if resp.StatusCode != http.StatusOK {
292 t.Errorf("login page status = %d, want 200", resp.StatusCode)
293 }
294 }
295
296 func TestPasswordModeNoSessionRedirects(t *testing.T) {
297 ag := newAuthGate(config.ServeConfig{AuthMode: "password", PasswordHash: mustHash("test")})
298 ts := httptest.NewServer(ag.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
299 w.WriteHeader(http.StatusOK)
300 })))
301 defer ts.Close()
302
303 client := &http.Client{
304 CheckRedirect: func(req *http.Request, via []*http.Request) error {
305 return http.ErrUseLastResponse
306 },
307 }
308 req, _ := http.NewRequest("GET", ts.URL+"/", nil)
309 req.Header.Set("Accept", "text/html,application/xhtml+xml")
310 resp, err := client.Do(req)
311 if err != nil {
312 t.Fatal(err)
313 }
314 resp.Body.Close()
315 if resp.StatusCode != http.StatusFound {
316 t.Errorf("redirect status = %d, want 302", resp.StatusCode)
317 }
318 loc := resp.Header.Get("Location")
319 if loc != "/login" {
320 t.Errorf("redirect location = %q, want /login", loc)
321 }
322 }
323
324 func TestPasswordModeAPIWithoutSessionReturns401(t *testing.T) {
325 ag := newAuthGate(config.ServeConfig{AuthMode: "password", PasswordHash: mustHash("test")})
326 ts := httptest.NewServer(ag.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
327 w.WriteHeader(http.StatusOK)
328 })))
329 defer ts.Close()
330
331 // Simulate a non-browser (fetch) request by not setting Accept: text/html.
332 req, _ := http.NewRequest("GET", ts.URL+"/status", nil)
333 resp, err := http.DefaultClient.Do(req)
334 if err != nil {
335 t.Fatal(err)
336 }
337 resp.Body.Close()
338 if resp.StatusCode != http.StatusUnauthorized {
339 t.Errorf("API status = %d, want 401", resp.StatusCode)
340 }
341 }
342
343 func TestPasswordModeValidLogin(t *testing.T) {
344 ag := newAuthGate(config.ServeConfig{AuthMode: "password", PasswordHash: mustHash("correct")})
345 ts := httptest.NewServer(ag.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
346 w.WriteHeader(http.StatusOK)
347 })))
348 defer ts.Close()
349
350 // Don't follow redirects so we can capture the session cookie.
351 client := &http.Client{
352 CheckRedirect: func(req *http.Request, via []*http.Request) error {
353 return http.ErrUseLastResponse
354 },
355 }
356 resp, err := client.PostForm(ts.URL+"/login", url.Values{"password": {"correct"}})
357 if err != nil {
358 t.Fatal(err)
359 }
360 resp.Body.Close()
361 if resp.StatusCode != http.StatusFound {
362 t.Errorf("login status = %d, want 302", resp.StatusCode)
363 }
364
365 // Extract session cookie and verify it works.
366 cookies := resp.Cookies()
367 var sessionCookie *http.Cookie
368 for _, c := range cookies {
369 if c.Name == cookieSession {
370 sessionCookie = c
371 break
372 }
373 }
374 if sessionCookie == nil {
375 t.Fatal("no session cookie set after login")
376 }
377
378 // Use the session cookie to access a protected page.
379 req, _ := http.NewRequest("GET", ts.URL+"/status", nil)
380 req.AddCookie(sessionCookie)
381 resp2, err := http.DefaultClient.Do(req)
382 if err != nil {
383 t.Fatal(err)
384 }
385 resp2.Body.Close()
386 if resp2.StatusCode != http.StatusOK {
387 t.Errorf("authenticated status = %d, want 200", resp2.StatusCode)
388 }
389 }
390
391 func TestPasswordModeNonLoopbackHTTPLoginCookieIsUsable(t *testing.T) {
392 ag := newAuthGate(config.ServeConfig{AuthMode: "password", PasswordHash: mustHash("correct")})
393 ts := httptest.NewServer(ag.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
394 w.WriteHeader(http.StatusOK)
395 })))
396 defer ts.Close()
397
398 client := &http.Client{
399 CheckRedirect: func(req *http.Request, via []*http.Request) error {
400 return http.ErrUseLastResponse
401 },
402 }
403 form := url.Values{"password": {"correct"}}
404 req, _ := http.NewRequest("POST", ts.URL+"/login", strings.NewReader(form.Encode()))
405 req.Host = "192.0.2.10:8787"
406 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
407 resp, err := client.Do(req)
408 if err != nil {
409 t.Fatal(err)
410 }
411 resp.Body.Close()
412
413 sessionCookie := findCookie(resp.Cookies(), cookieSession)
414 if sessionCookie == nil {
415 t.Fatal("session cookie missing")
416 }
417 if sessionCookie.Secure {
418 t.Fatal("plain HTTP password session cookie should stay usable without Secure")
419 }
420
421 req, _ = http.NewRequest("GET", ts.URL+"/status", nil)
422 req.Host = "192.0.2.10:8787"
423 req.AddCookie(sessionCookie)
424 resp, err = http.DefaultClient.Do(req)
425 if err != nil {
426 t.Fatal(err)
427 }
428 resp.Body.Close()
429 if resp.StatusCode != http.StatusOK {
430 t.Errorf("authenticated non-loopback status = %d, want 200", resp.StatusCode)
431 }
432 }
433
434 func TestPasswordModeSanitizesRedirectCookie(t *testing.T) {
435 ag := newAuthGate(config.ServeConfig{AuthMode: "password", PasswordHash: mustHash("correct")})
436 ts := httptest.NewServer(ag.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
437 w.WriteHeader(http.StatusOK)
438 })))
439 defer ts.Close()
440
441 client := &http.Client{
442 CheckRedirect: func(req *http.Request, via []*http.Request) error {
443 return http.ErrUseLastResponse
444 },
445 }
446 form := url.Values{"password": {"correct"}}
447 req, _ := http.NewRequest("POST", ts.URL+"/login", strings.NewReader(form.Encode()))
448 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
449 req.AddCookie(&http.Cookie{Name: cookieRedirect, Value: "//evil.example/path"})
450
451 resp, err := client.Do(req)
452 if err != nil {
453 t.Fatal(err)
454 }
455 resp.Body.Close()
456 if resp.StatusCode != http.StatusFound {
457 t.Errorf("login status = %d, want 302", resp.StatusCode)
458 }
459 if loc := resp.Header.Get("Location"); loc != "/" {
460 t.Fatalf("redirect location = %q, want /", loc)
461 }
462 }
463
464 func TestPasswordModeWrongPassword(t *testing.T) {
465 ag := newAuthGate(config.ServeConfig{AuthMode: "password", PasswordHash: mustHash("correct")})
466 ts := httptest.NewServer(ag.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
467 w.WriteHeader(http.StatusOK)
468 })))
469 defer ts.Close()
470
471 resp, err := http.PostForm(ts.URL+"/login", url.Values{"password": {"wrong"}})
472 if err != nil {
473 t.Fatal(err)
474 }
475 defer resp.Body.Close()
476
477 if resp.StatusCode != http.StatusUnauthorized {
478 t.Errorf("wrong password status = %d, want 401", resp.StatusCode)
479 }
480
481 // Check that no session cookie was set.
482 for _, c := range resp.Cookies() {
483 if c.Name == cookieSession {
484 t.Error("session cookie should not be set on wrong password")
485 }
486 }
487 }
488
489 func TestPasswordModeEmptyPassword(t *testing.T) {
490 ag := newAuthGate(config.ServeConfig{AuthMode: "password", PasswordHash: mustHash("correct")})
491 ts := httptest.NewServer(ag.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
492 w.WriteHeader(http.StatusOK)
493 })))
494 defer ts.Close()
495
496 resp, err := http.PostForm(ts.URL+"/login", url.Values{})
497 if err != nil {
498 t.Fatal(err)
499 }
500 defer resp.Body.Close()
501 if resp.StatusCode != http.StatusUnauthorized {
502 t.Errorf("empty password status = %d, want 401", resp.StatusCode)
503 }
504 }
505
506 // ── Session HMAC tests ──
507
508 func TestSignAndVerifySession(t *testing.T) {
509 ag := newAuthGate(config.ServeConfig{AuthMode: "password", PasswordHash: mustHash("test")})
510
511 tok := ag.signSession()
512 if !ag.verifySession(tok) {
513 t.Error("valid session should verify")
514 }
515 }
516
517 func TestSessionVerifiesAcrossRestartWithSamePasswordHash(t *testing.T) {
518 hash := mustHash("test")
519 ag1 := newAuthGate(config.ServeConfig{AuthMode: "password", PasswordHash: hash})
520 tok := ag1.signSession()
521
522 ag2 := newAuthGate(config.ServeConfig{AuthMode: "password", PasswordHash: hash})
523 if !ag2.verifySession(tok) {
524 t.Fatal("session signed with a persisted password hash should verify after restart")
525 }
526 }
527
528 func TestSessionRejectsDifferentPasswordHash(t *testing.T) {
529 ag1 := newAuthGate(config.ServeConfig{AuthMode: "password", PasswordHash: mustHash("first")})
530 tok := ag1.signSession()
531
532 ag2 := newAuthGate(config.ServeConfig{AuthMode: "password", PasswordHash: mustHash("second")})
533 if ag2.verifySession(tok) {
534 t.Fatal("session should not verify with a different password hash")
535 }
536 }
537
538 func TestSessionTampered(t *testing.T) {
539 ag := newAuthGate(config.ServeConfig{AuthMode: "password", PasswordHash: mustHash("test")})
540
541 tok := ag.signSession()
542 // Tamper with the payload (change a character before the dot).
543 dot := strings.IndexByte(tok, '.')
544 tampered := tok[:dot-1] + string(tok[dot-1]^1) + tok[dot:]
545 if ag.verifySession(tampered) {
546 t.Error("tampered session should not verify")
547 }
548 }
549
550 func TestSessionWrongSignature(t *testing.T) {
551 ag := newAuthGate(config.ServeConfig{AuthMode: "password", PasswordHash: mustHash("test")})
552
553 tok := ag.signSession()
554 dot := strings.LastIndexByte(tok, '.')
555 // Flip a hex character in the signature, ensuring it actually changes
556 // (the first nibble might already be 0 → "0" + sig[1:] would be a no-op).
557 sig := tok[dot+1:]
558 target := byte('0')
559 if sig[0] == target {
560 target = 'f' // guaranteed different
561 }
562 flipped := tok[:dot+1] + string(target) + sig[1:]
563 if ag.verifySession(flipped) {
564 t.Error("wrong-signature session should not verify")
565 }
566 }
567
568 func TestSessionExpired(t *testing.T) {
569 ag := newAuthGate(config.ServeConfig{AuthMode: "password", PasswordHash: mustHash("test")})
570
571 // Craft a session with an expiry in the past.
572 tok := ag.signSession()
573 // The format is: expiry|nonce.sig
574 pipe := strings.IndexByte(tok, '|')
575 expired := "1000000000" + tok[pipe:] // Unix time in 2001
576 if ag.verifySession(expired) {
577 t.Error("expired session should not verify")
578 }
579 }
580
581 func TestSessionMalformed(t *testing.T) {
582 ag := newAuthGate(config.ServeConfig{AuthMode: "password", PasswordHash: mustHash("test")})
583
584 for _, bad := range []string{
585 "",
586 "no-dot",
587 "no-pipe.signature",
588 ".just-signature",
589 "payload.",
590 "payload.badhex",
591 } {
592 if ag.verifySession(bad) {
593 t.Errorf("malformed session %q should not verify", bad)
594 }
595 }
596 }
597
598 // ── Rate limiter tests ──
599
600 func TestRateLimiterAllowsFiveThenBlocks(t *testing.T) {
601 rl := newRateLimit()
602 ip := "192.0.2.1"
603
604 for i := 0; i < rateLimitMax; i++ {
605 if !rl.allow(ip) {
606 t.Fatalf("attempt %d should be allowed", i+1)
607 }
608 }
609 if rl.allow(ip) {
610 t.Error("6th attempt should be blocked")
611 }
612 }
613
614 func TestRateLimiterResetsAfterWindow(t *testing.T) {
615 rl := newRateLimit()
616 ip := "192.0.2.2"
617
618 // Exhaust the limit.
619 for i := 0; i < rateLimitMax; i++ {
620 rl.allow(ip)
621 }
622 if rl.allow(ip) {
623 t.Error("should be blocked after exhausting limit")
624 }
625
626 // Manually expire the window.
627 rl.mu.Lock()
628 w := rl.attempts[ip]
629 if w != nil {
630 w.start = time.Now().Add(-2 * rateLimitWin)
631 }
632 rl.mu.Unlock()
633
634 // Now it should be allowed again.
635 if !rl.allow(ip) {
636 t.Error("should be allowed after window expires")
637 }
638 }
639
640 func TestRateLimiterDifferentIPs(t *testing.T) {
641 rl := newRateLimit()
642 // Exhaust one IP.
643 for i := 0; i < rateLimitMax; i++ {
644 rl.allow("192.0.2.1")
645 }
646 // Another IP should still be allowed.
647 if !rl.allow("192.0.2.2") {
648 t.Error("different IP should not be rate-limited")
649 }
650 }
651
652 // ── clientIP tests ──
653
654 func TestClientIPRemoteAddr(t *testing.T) {
655 ag := newAuthGate(config.ServeConfig{AuthMode: "token", Token: "x"})
656 req, _ := http.NewRequest("GET", "/", nil)
657 req.RemoteAddr = "192.0.2.42:12345"
658 if got := ag.clientIP(req); got != "192.0.2.42" {
659 t.Errorf("clientIP = %q, want 192.0.2.42", got)
660 }
661 }
662
663 func TestClientIPIgnoresXForwardedForWithoutProxy(t *testing.T) {
664 ag := newAuthGate(config.ServeConfig{AuthMode: "token", Token: "x"})
665 req, _ := http.NewRequest("GET", "/", nil)
666 req.RemoteAddr = "192.0.2.1:12345"
667 req.Header.Set("X-Forwarded-For", "10.0.0.1, 10.0.0.2")
668 if got := ag.clientIP(req); got != "192.0.2.1" {
669 t.Errorf("should use RemoteAddr without behind_proxy, got %q", got)
670 }
671 }
672
673 func TestClientIPTrustsXForwardedForWithProxy(t *testing.T) {
674 ag := newAuthGate(config.ServeConfig{AuthMode: "token", Token: "x", BehindProxy: true})
675 req, _ := http.NewRequest("GET", "/", nil)
676 req.RemoteAddr = "10.0.0.99:12345"
677 req.Header.Set("X-Forwarded-For", "192.0.2.42, 10.0.0.1")
678 if got := ag.clientIP(req); got != "192.0.2.42" {
679 t.Errorf("clientIP = %q, want 192.0.2.42", got)
680 }
681 }
682
683 // ── isTLS tests ──
684
685 func TestIsTLS(t *testing.T) {
686 ag := newAuthGate(config.ServeConfig{AuthMode: "token", Token: "x"})
687 req, _ := http.NewRequest("GET", "/", nil)
688 if ag.isTLS(req) {
689 t.Error("plain request should not be TLS")
690 }
691 }
692
693 func TestIsTLSIgnoresForwardedProtoWithoutProxy(t *testing.T) {
694 ag := newAuthGate(config.ServeConfig{AuthMode: "token", Token: "x"})
695 req, _ := http.NewRequest("GET", "/", nil)
696 req.Header.Set("X-Forwarded-Proto", "https")
697 if ag.isTLS(req) {
698 t.Error("should ignore X-Forwarded-Proto without behind_proxy")
699 }
700 }
701
702 func TestIsTLSTrustsForwardedProtoWithProxy(t *testing.T) {
703 ag := newAuthGate(config.ServeConfig{AuthMode: "token", Token: "x", BehindProxy: true})
704 req, _ := http.NewRequest("GET", "/", nil)
705 req.Header.Set("X-Forwarded-Proto", "https")
706 if !ag.isTLS(req) {
707 t.Error("should trust X-Forwarded-Proto with behind_proxy")
708 }
709 }
710
711 func TestAuthCookieSecurePolicy(t *testing.T) {
712 ag := newAuthGate(config.ServeConfig{AuthMode: "token", Token: "x"})
713 for _, host := range []string{"localhost:8787", "127.0.0.1:8787", "[::1]:8787"} {
714 req, _ := http.NewRequest("GET", "http://"+host+"/", nil)
715 if ag.authCookieSecure(req) {
716 t.Errorf("loopback host %s should allow local HTTP cookies", host)
717 }
718 }
719
720 req, _ := http.NewRequest("GET", "http://192.0.2.10:8787/", nil)
721 if ag.authCookieSecure(req) {
722 t.Fatal("plain HTTP cookies should stay usable without Secure")
723 }
724
725 proxy := newAuthGate(config.ServeConfig{AuthMode: "token", Token: "x", BehindProxy: true})
726 req, _ = http.NewRequest("GET", "http://example.test/", nil)
727 req.Header.Set("X-Forwarded-Proto", "https")
728 if !proxy.authCookieSecure(req) {
729 t.Fatal("trusted forwarded HTTPS should mark cookies Secure")
730 }
731 }
732
733 func TestPlainHTTPAuthWarning(t *testing.T) {
734 if got := PlainHTTPAuthWarning(config.ServeConfig{AuthMode: "none"}, "0.0.0.0:8787"); got != "" {
735 t.Fatalf("none auth warning = %q, want empty", got)
736 }
737 if got := PlainHTTPAuthWarning(config.ServeConfig{AuthMode: "password"}, "127.0.0.1:8787"); got != "" {
738 t.Fatalf("loopback warning = %q, want empty", got)
739 }
740 if got := PlainHTTPAuthWarning(config.ServeConfig{AuthMode: "token"}, "0.0.0.0:8787"); !strings.Contains(got, "non-loopback HTTP") {
741 t.Fatalf("non-loopback warning = %q, want HTTP exposure warning", got)
742 }
743 if got := PlainHTTPAuthWarning(config.ServeConfig{AuthMode: "password"}, ":8787"); !strings.Contains(got, "non-loopback HTTP") {
744 t.Fatalf("wildcard warning = %q, want HTTP exposure warning", got)
745 }
746 }
747
748 func TestSafeRedirectTarget(t *testing.T) {
749 for _, tc := range []struct {
750 in string
751 want string
752 }{
753 {"/", "/"},
754 {"/sessions?id=abc#frag", "/sessions?id=abc"},
755 {"", "/"},
756 {"https://evil.example/path", "/"},
757 {"//evil.example/path", "/"},
758 {`/\evil.example/path`, "/"},
759 {"/%2f%2fevil.example/path", "/"},
760 {"/%5cevil.example/path", "/"},
761 {"relative/path", "/"},
762 } {
763 if got := safeRedirectTarget(tc.in); got != tc.want {
764 t.Errorf("safeRedirectTarget(%q) = %q, want %q", tc.in, got, tc.want)
765 }
766 }
767 }
768
769 // ── helpers ──
770
771 func mustHash(password string) string {
772 h, err := HashPassword(password)
773 if err != nil {
774 panic(err)
775 }
776 return h
777 }
778
779 func findCookie(cookies []*http.Cookie, name string) *http.Cookie {
780 for _, c := range cookies {
781 if c.Name == name {
782 return c
783 }
784 }
785 return nil
786 }
787
787 lines GO