diff --git a/backend/internal/api/handlers/auth_handler.go b/backend/internal/api/handlers/auth_handler.go index e38527c67..ce4c55727 100644 --- a/backend/internal/api/handlers/auth_handler.go +++ b/backend/internal/api/handlers/auth_handler.go @@ -77,12 +77,12 @@ func originHost(rawURL string) string { return normalizeHost(parsedURL.Host) } -func isLocalHost(host string) bool { +func isLocalOrPrivateHost(host string) bool { if strings.EqualFold(host, "localhost") { return true } - if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() { + if ip := net.ParseIP(host); ip != nil && (ip.IsLoopback() || ip.IsPrivate()) { return true } @@ -117,7 +117,7 @@ func isLocalRequest(c *gin.Context) bool { continue } - if isLocalHost(host) { + if isLocalOrPrivateHost(host) { return true } } @@ -127,8 +127,9 @@ func isLocalRequest(c *gin.Context) bool { // setSecureCookie sets an auth cookie with security best practices // - HttpOnly: prevents JavaScript access (XSS protection) -// - Secure: true for HTTPS; false only for local non-HTTPS loopback flows -// - SameSite: Strict for HTTPS, Lax for HTTP/IP to allow forward-auth redirects +// - Secure: true for HTTPS; false for local/private network HTTP requests +// - SameSite: Lax for any local/private-network request (regardless of scheme), +// Strict otherwise (public HTTPS only) func setSecureCookie(c *gin.Context, name, value string, maxAge int) { scheme := requestScheme(c) secure := true @@ -148,7 +149,7 @@ func setSecureCookie(c *gin.Context, name, value string, maxAge int) { domain := "" c.SetSameSite(sameSite) - // secure is intentionally false for local non-HTTPS loopback (development only); always true for external HTTPS requests. + // secure is intentionally false for local/private network HTTP requests; always true for external or HTTPS requests. c.SetCookie( // codeql[go/cookie-secure-not-set] name, // name value, // value diff --git a/backend/internal/api/handlers/auth_handler_test.go b/backend/internal/api/handlers/auth_handler_test.go index 101df3214..cf308bd90 100644 --- a/backend/internal/api/handlers/auth_handler_test.go +++ b/backend/internal/api/handlers/auth_handler_test.go @@ -202,6 +202,114 @@ func TestSetSecureCookie_OriginLoopbackForcesInsecure(t *testing.T) { assert.Equal(t, http.SameSiteLaxMode, cookie.SameSite) } +func TestSetSecureCookie_HTTP_PrivateIP_Insecure(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + req := httptest.NewRequest("POST", "http://192.168.1.50:8080/login", http.NoBody) + req.Host = "192.168.1.50:8080" + req.Header.Set("X-Forwarded-Proto", "http") + ctx.Request = req + + setSecureCookie(ctx, "auth_token", "abc", 60) + cookies := recorder.Result().Cookies() + require.Len(t, cookies, 1) + cookie := cookies[0] + assert.False(t, cookie.Secure) + assert.Equal(t, http.SameSiteLaxMode, cookie.SameSite) +} + +func TestSetSecureCookie_HTTP_10Network_Insecure(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + req := httptest.NewRequest("POST", "http://10.0.0.5:8080/login", http.NoBody) + req.Host = "10.0.0.5:8080" + req.Header.Set("X-Forwarded-Proto", "http") + ctx.Request = req + + setSecureCookie(ctx, "auth_token", "abc", 60) + cookies := recorder.Result().Cookies() + require.Len(t, cookies, 1) + cookie := cookies[0] + assert.False(t, cookie.Secure) + assert.Equal(t, http.SameSiteLaxMode, cookie.SameSite) +} + +func TestSetSecureCookie_HTTP_172Network_Insecure(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + req := httptest.NewRequest("POST", "http://172.16.0.1:8080/login", http.NoBody) + req.Host = "172.16.0.1:8080" + req.Header.Set("X-Forwarded-Proto", "http") + ctx.Request = req + + setSecureCookie(ctx, "auth_token", "abc", 60) + cookies := recorder.Result().Cookies() + require.Len(t, cookies, 1) + cookie := cookies[0] + assert.False(t, cookie.Secure) + assert.Equal(t, http.SameSiteLaxMode, cookie.SameSite) +} + +func TestSetSecureCookie_HTTPS_PrivateIP_Secure(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + req := httptest.NewRequest("POST", "https://192.168.1.50:8080/login", http.NoBody) + req.Host = "192.168.1.50:8080" + req.Header.Set("X-Forwarded-Proto", "https") + ctx.Request = req + + setSecureCookie(ctx, "auth_token", "abc", 60) + cookies := recorder.Result().Cookies() + require.Len(t, cookies, 1) + cookie := cookies[0] + assert.True(t, cookie.Secure) + assert.Equal(t, http.SameSiteLaxMode, cookie.SameSite) +} + +func TestSetSecureCookie_HTTP_IPv6ULA_Insecure(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + req := httptest.NewRequest("POST", "http://[fd12::1]:8080/login", http.NoBody) + req.Host = "[fd12::1]:8080" + req.Header.Set("X-Forwarded-Proto", "http") + ctx.Request = req + + setSecureCookie(ctx, "auth_token", "abc", 60) + cookies := recorder.Result().Cookies() + require.Len(t, cookies, 1) + cookie := cookies[0] + assert.False(t, cookie.Secure) + assert.Equal(t, http.SameSiteLaxMode, cookie.SameSite) +} + +func TestSetSecureCookie_HTTP_PublicIP_Secure(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + req := httptest.NewRequest("POST", "http://203.0.113.5:8080/login", http.NoBody) + req.Host = "203.0.113.5:8080" + req.Header.Set("X-Forwarded-Proto", "http") + ctx.Request = req + + setSecureCookie(ctx, "auth_token", "abc", 60) + cookies := recorder.Result().Cookies() + require.Len(t, cookies, 1) + cookie := cookies[0] + assert.True(t, cookie.Secure) + assert.Equal(t, http.SameSiteLaxMode, cookie.SameSite) +} + func TestIsProduction(t *testing.T) { t.Setenv("CHARON_ENV", "production") assert.True(t, isProduction()) @@ -271,11 +379,16 @@ func TestHostHelpers(t *testing.T) { assert.Equal(t, "localhost", originHost("http://localhost:8080/path")) }) - t.Run("isLocalHost", func(t *testing.T) { - assert.True(t, isLocalHost("localhost")) - assert.True(t, isLocalHost("127.0.0.1")) - assert.True(t, isLocalHost("::1")) - assert.False(t, isLocalHost("example.com")) + t.Run("isLocalOrPrivateHost", func(t *testing.T) { + assert.True(t, isLocalOrPrivateHost("localhost")) + assert.True(t, isLocalOrPrivateHost("127.0.0.1")) + assert.True(t, isLocalOrPrivateHost("::1")) + assert.True(t, isLocalOrPrivateHost("192.168.1.50")) + assert.True(t, isLocalOrPrivateHost("10.0.0.1")) + assert.True(t, isLocalOrPrivateHost("172.16.0.1")) + assert.True(t, isLocalOrPrivateHost("fd12::1")) + assert.False(t, isLocalOrPrivateHost("203.0.113.5")) + assert.False(t, isLocalOrPrivateHost("example.com")) }) } @@ -1222,10 +1335,10 @@ func TestAuthHandler_HelperFunctions(t *testing.T) { assert.Equal(t, "example.com", originHost("https://example.com/path")) }) - t.Run("isLocalHost and isLocalRequest", func(t *testing.T) { - assert.True(t, isLocalHost("localhost")) - assert.True(t, isLocalHost("127.0.0.1")) - assert.False(t, isLocalHost("example.com")) + t.Run("isLocalOrPrivateHost and isLocalRequest", func(t *testing.T) { + assert.True(t, isLocalOrPrivateHost("localhost")) + assert.True(t, isLocalOrPrivateHost("127.0.0.1")) + assert.False(t, isLocalOrPrivateHost("example.com")) recorder := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(recorder) diff --git a/docs/issues/issue-825-manual-test-plan.md b/docs/issues/issue-825-manual-test-plan.md new file mode 100644 index 000000000..163a30ef6 --- /dev/null +++ b/docs/issues/issue-825-manual-test-plan.md @@ -0,0 +1,93 @@ +--- +title: "Manual Test Plan - Issue #825: HTTP Login from Private Network IPs" +status: Open +priority: High +assignee: QA +labels: testing, backend, frontend, security +--- + +# Test Objective + +Confirm that users can log in to Charon over HTTP when accessing from a private network IP (e.g., `192.168.x.x:8080`), and that HTTPS and localhost login paths remain unaffected. + +# Scope + +- In scope: Auth cookie behavior across HTTP/HTTPS, private IPs, localhost, and port remapping. Session persistence and the Bearer token fallback. +- Out of scope: Setup wizard UI styling, non-auth API endpoints, Caddy proxy configuration changes. + +# Prerequisites + +- Fresh Charon instance (no existing users in the database). +- Access to a machine on the same LAN (or the host machine itself using its LAN IP). +- A second device or browser for cross-tab and LAN IP tests. +- For HTTPS scenarios: a reverse proxy (Caddy or nginx) with TLS termination in front of Charon. +- Browser DevTools available to inspect cookies and network requests. + +# Manual Scenarios + +## 1) Fresh install login over HTTP from LAN IP (original bug) + +- [ ] Deploy a fresh Charon instance with an empty database. +- [ ] From another machine on the LAN, open `http://:8080` (e.g., `http://192.168.1.50:8080`). +- [ ] Complete the setup wizard and create an admin user. +- [ ] Log in with the credentials you just created. +- [ ] **Expected**: Login succeeds. You are redirected to the dashboard. No "unauthorized" flash or redirect back to login. +- [ ] Open DevTools > Network tab. Confirm `/api/auth/me` returns `200`. +- [ ] Open DevTools > Application > Cookies. Confirm the auth cookie has `Secure: false`. + +## 2) Login over HTTPS via reverse proxy + +- [ ] Configure a reverse proxy (Caddy or nginx) with TLS termination pointing to Charon. +- [ ] Open `https://` in the browser. +- [ ] Log in with valid credentials. +- [ ] **Expected**: Login succeeds. Redirected to the dashboard. +- [ ] Open DevTools > Application > Cookies. Confirm the auth cookie has `Secure: true`. + +## 3) Login from localhost over HTTP (regression check) + +- [ ] On the machine running Charon, open `http://localhost:8080`. +- [ ] Log in with valid credentials. +- [ ] **Expected**: Login succeeds. This is existing behavior and must not regress. + +## 4) Session persistence after page refresh + +- [ ] Log in successfully via any access method. +- [ ] Press F5 (hard refresh). +- [ ] **Expected**: You remain logged in. The dashboard loads without a redirect to the login page. + +## 5) Multiple browser tabs + +- [ ] Log in on Tab 1. +- [ ] Open a new Tab 2 and navigate to the same Charon URL. +- [ ] **Expected**: Tab 2 loads the dashboard immediately without prompting for login. + +## 6) Logout and re-login + +- [ ] Log in, then click Logout. +- [ ] **Expected**: Redirected to the login page. +- [ ] Log in again with the same credentials. +- [ ] **Expected**: Login succeeds with a clean session. No stale session errors. + +## 7) Port remapping scenario + +- [ ] Run Charon with Docker port mappings: `-p 82:80 -p 445:443 -p 8080:8080`. +- [ ] Access Charon on `http://:8080`. +- [ ] Log in with valid credentials. +- [ ] **Expected**: Login succeeds on the remapped port. +- [ ] If a reverse proxy is available, repeat via `https://:445`. +- [ ] **Expected**: Login succeeds with `Secure: true` on the cookie. + +# Expected Results + +- Login over HTTP from a private network IP works on fresh install. +- Cookie `Secure` flag is `false` for HTTP and `true` for HTTPS. +- Localhost login remains functional. +- Sessions survive page refreshes and work across tabs. +- Logout cleanly destroys the session, and re-login creates a new one. +- Port remapping does not break authentication. + +# Regression Checks + +- [ ] Confirm no changes to login behavior when accessing via `localhost` or `127.0.0.1`. +- [ ] Confirm HTTPS connections still set `Secure: true` on the auth cookie. +- [ ] Confirm the Bearer token header fallback does not interfere when cookies are working normally. diff --git a/docs/plans/current_spec.md b/docs/plans/current_spec.md index 12f1e701d..ae6bb142c 100644 --- a/docs/plans/current_spec.md +++ b/docs/plans/current_spec.md @@ -1,497 +1,372 @@ -# Telegram Notification Provider — Test Failure Remediation Plan +# Issue #825: User Cannot Login After Fresh Install -**Date:** 2026-03-11 -**Author:** Planning Agent -**Status:** Remediation Required — All security scans pass, test failures block merge -**Previous Plan:** Archived as `docs/plans/telegram_implementation_spec.md` +**Date:** 2026-03-14 +**Status:** Root Cause Identified — Code Bug + Frontend Fragility +**Issue:** Login API returns 200 but GET `/api/v1/auth/me` immediately returns 401 +**Previous Plan:** Archived as `docs/plans/telegram_remediation_spec.md` --- ## 1. Introduction -The Telegram notification provider feature is functionally complete with passing security scans and coverage gates. However, **56 E2E test failures** and **2 frontend unit test failures** block the PR merge. This plan identifies root causes, categorises each failure set, and provides specific remediation steps. +A user reports that after a fresh install with remapped ports (`82:80`, `445:443`, `8080:8080`), accessing Charon via a separate external Caddy reverse proxy, the login succeeds (200) but the session validation (`/auth/me`) immediately fails (401). -### Failure Summary +### Objectives -| Spec File | Failures | Browsers | Unique Est. | Category | -|---|---|---|---|---| -| `notifications.spec.ts` | 48 | 3 | ~16 | **Our change** | -| `notifications-payload.spec.ts` | 18 | 3 | ~6 | **Our change** | -| `telegram-notification-provider.spec.ts` | 4 | 1–3 | ~2 | **Our change** | -| `encryption-management.spec.ts` | 20 | 3 | ~7 | Pre-existing | -| `auth-middleware-cascade.spec.ts` | 18 | 3 | 6 | Pre-existing | -| `Notifications.test.tsx` (unit) | 2 | — | 2 | **Our change** | - -CI retries: 2 per test (`playwright.config.js` L144). Failure counts above represent unique test failures × browser projects. +1. Identify the root cause of the login→401 failure chain +2. Determine whether this is a code bug or a user configuration issue +3. Propose a targeted fix with minimal blast radius --- -## 2. Root Cause Analysis - -### Root Cause A: `isNew` Guard on Test Button (CRITICAL — Causes ~80% of failures) - -**What changed:** The Telegram feature added a guard in `Notifications.tsx` (L117-124) that blocks the "Test" button for new (unsaved) providers: - -```typescript -// Line 117-124: handleTest() early return guard -const handleTest = () => { - const formData = watch(); - const currentType = normalizeProviderType(formData.type); - if (!formData.id && currentType !== 'email') { - toast.error(t('notificationProviders.saveBeforeTesting')); - return; - } - testMutation.mutate({ ...formData, type: currentType } as Partial); -}; -``` - -And a `disabled` attribute on the test button at `Notifications.tsx` (L382): - -```typescript -// Line 382: Button disabled state -disabled={testMutation.isPending || (isNew && !isEmail)} -``` - -**Why it was added:** The backend `Test` handler at `notification_provider_handler.go` (L333-336) requires a saved provider ID for all non-email types. For Gotify/Telegram, the server needs the stored token. For Discord/Webhook, the server still fetches the provider from DB. Without a saved provider, the backend returns `MISSING_PROVIDER_ID`. - -**Why it breaks tests:** Many existing E2E and unit tests click the test button from a **new (unsaved) provider form** using mocked endpoints. With the new guard: -1. The `