Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions backend/internal/api/handlers/auth_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -117,7 +117,7 @@ func isLocalRequest(c *gin.Context) bool {
continue
}

if isLocalHost(host) {
if isLocalOrPrivateHost(host) {
return true
}
}
Expand All @@ -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
Expand All @@ -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
Expand Down
131 changes: 122 additions & 9 deletions backend/internal/api/handlers/auth_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -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"))
})
}

Expand Down Expand Up @@ -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)
Expand Down
93 changes: 93 additions & 0 deletions docs/issues/issue-825-manual-test-plan.md
Original file line number Diff line number Diff line change
@@ -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://<LAN-IP>: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://<your-domain-or-ip>` 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://<LAN-IP>:8080`.
- [ ] Log in with valid credentials.
- [ ] **Expected**: Login succeeds on the remapped port.
- [ ] If a reverse proxy is available, repeat via `https://<LAN-IP>: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.
Loading
Loading