- Remove the conditional secure=false branch from setSecureCookie that allowed cookies to be issued without the Secure flag when requests arrived over HTTP from localhost or RFC 1918 private addresses - Pass the literal true to c.SetCookie directly, eliminating the dataflow path that triggered CodeQL go/cookie-secure-not-set (CWE-614) - Remove the now-dead codeql suppression comment; the root cause is gone, not merely silenced - Update setSecureCookie doc comment to reflect that Secure is always true: all major browsers (Chrome 66+, Firefox 75+, Safari 14+) honour the Secure attribute on localhost HTTP connections, and direct HTTP-on-private-IP access without TLS is an unsupported deployment model for Charon which is designed to sit behind Caddy TLS termination - Update the five TestSetSecureCookie HTTP/local tests that previously asserted Secure=false to now assert Secure=true, reflecting the elimination of the insecure code path - Add Secure=true assertion to TestClearSecureCookie to provide explicit coverage of the clear-cookie path
17 KiB
CWE-614 Remediation — Sensitive Cookie Without 'Secure' Attribute
Date: 2026-03-21
Scope: go/cookie-secure-not-set CodeQL finding in backend/internal/api/handlers/auth_handler.go
Status: Draft — Awaiting implementation
1. Problem Statement
CWE-614 Description
CWE-614 (Sensitive Cookie Without 'Secure' Attribute) describes the vulnerability where a
session or authentication cookie is issued without the Secure attribute. Without this attribute,
browsers are permitted to transmit the cookie over unencrypted HTTP connections, exposing the
token to network interception. A single cleartext transmission of an auth_token cookie is
sufficient for session hijacking.
CodeQL Rule
The CodeQL query go/cookie-secure-not-set (security severity: warning) flags any call to
http.SetCookie or Gin's c.SetCookie where static analysis can prove there exists an execution
path in which the secure parameter evaluates to false. The rule does not require the path to
be reachable in production — it fires on reachability within Go's control-flow graph.
SARIF Finding
The SARIF file codeql-results-go.sarif contains one result for go/cookie-secure-not-set:
| Field | Value |
|---|---|
| Rule ID | go/cookie-secure-not-set |
| Message | "Cookie does not set Secure attribute to true." |
| File | internal/api/handlers/auth_handler.go |
| Region | Lines 152–160, columns 2–3 |
| CWE tag | external/cwe/cwe-614 |
| CVSS severity | Warning |
The flagged region is the c.SetCookie(...) call inside setSecureCookie, where the secure
variable (sourced from a bool modified at line 140 via secure = false) can carry false
through the call.
2. Root Cause Analysis
The Offending Logic in setSecureCookie
setSecureCookie (auth_handler.go, line 133) constructs the Secure attribute value using
runtime heuristics:
secure := true
sameSite := http.SameSiteStrictMode
if scheme != "https" {
sameSite = http.SameSiteLaxMode
if isLocalRequest(c) {
secure = false // ← line 140: CWE-614 root cause
}
}
When both conditions hold — requestScheme(c) returns "http" AND isLocalRequest(c) returns
true — the variable secure is assigned false. This value then flows unmodified into:
c.SetCookie( // codeql[go/cookie-secure-not-set]
name, value, maxAge, "/", domain,
secure, // ← false in the local-HTTP branch
true,
)
CodeQL's dataflow engine traces the assignment on line 140 to the parameter on line 159 and emits
the finding. The // codeql[go/cookie-secure-not-set] inline suppression comment was added
alongside the logic, but the SARIF file pre-dates the suppression and the CI continues to report
the finding — indicating either that the suppression was committed after the SARIF was captured
in the repository, or that GitHub Code Scanning's alert dismissal has not processed it.
Why the Suppression Is Insufficient
Inline suppression via // codeql[rule-id] tells CodeQL to dismiss the alert at that specific
callsite. It does not eliminate the code path that creates the security risk; it merely hides the
symptom. In a codebase with Charon's security posture (full supply-chain auditing, SBOM
generation, weekly CVE scanning), suppressing rather than fixing a cookie security issue is the
wrong philosophy. The authentic solution is to remove the offending branch.
What isLocalRequest Detects
isLocalRequest(c *gin.Context) bool returns true if any of the following resolve to a local
or RFC 1918 private address: c.Request.Host, c.Request.URL.Host, the Origin header, the
Referer header, or any comma-delimited value in X-Forwarded-Host. It delegates to
isLocalOrPrivateHost(host string) bool, which checks for "localhost" (case-insensitive),
ip.IsLoopback(), or ip.IsPrivate() per the Go net package (10.0.0.0/8, 172.16.0.0/12,
192.168.0.0/16, ::1, fc00::/7).
Why secure = false Was Introduced
The intent was to permit Charon to be accessed over HTTP on private networks (e.g., a developer
reaching http://192.168.1.50:8080). Browsers reject cookies with the Secure attribute on
non-HTTPS connections for non-localhost hosts, so setting Secure = true on a response to a
192.168.x.x HTTP request causes the browser to silently discard the cookie, breaking
authentication. The original author therefore conditionally disabled the Secure flag for these
deployments.
Why This Is Now Wrong for Charon
Charon is a security-oriented reverse proxy manager designed to sit behind Caddy, which always provides TLS termination in any supported deployment. The HTTP-on-private-IP access pattern breaks down into three real-world scenarios:
-
Local development (
http://localhost:8080) — All major browsers (Chrome 66+, Firefox 75+, Safari 14+) implement the localhost exception: theSecurecookie attribute is honoured and the cookie is accepted and retransmitted over HTTP to localhost. SettingSecure = truecauses zero breakage here. -
Docker-internal container access (
http://172.x.x.x) — Charon is never reached directly from within the Docker network by a browser; health probes and inter-container calls do not use cookies. No breakage. -
Private-IP direct browser access (
http://192.168.x.x:8080) — This is explicitly unsupported as an end-user deployment mode. The CharonARCHITECTURE.mddescribes the only supported path as via Caddy (HTTPS) orlocalhost. SettingSecure = trueon these responses means the browser ignores the cookie; but this deployment pattern should not exist regardless.
The conclusion: removing secure = false unconditionally is both correct and safe for all
legitimate Charon deployments.
3. Affected Files
Primary Change
| File | Function | Lines | Nature |
|---|---|---|---|
backend/internal/api/handlers/auth_handler.go |
setSecureCookie |
128–162 | Delete secure = false branch; update docstring; remove suppression comment |
No other file in the backend sets cookies directly. Every cookie write flows through
setSecureCookie or its thin wrapper clearSecureCookie. The complete call graph:
setSecureCookie— canonical cookie writer (line 133)clearSecureCookie→setSecureCookie(c, name, "", -1)(line 166)AuthHandler.Login→setSecureCookie(c, "auth_token", token, 3600*24)(line 188)AuthHandler.Logout→clearSecureCookie(c, "auth_token")AuthHandler.Refresh→setSecureCookie(c, "auth_token", token, 3600*24)(line 252)
clearSecureCookie requires no changes; it already delegates through setSecureCookie.
Test File Changes
| File | Test Function | Line | Change |
|---|---|---|---|
backend/internal/api/handlers/auth_handler_test.go |
TestSetSecureCookie_HTTP_Loopback_Insecure |
115 | assert.False → assert.True |
backend/internal/api/handlers/auth_handler_test.go |
TestSetSecureCookie_HTTP_PrivateIP_Insecure |
219 | assert.False → assert.True |
backend/internal/api/handlers/auth_handler_test.go |
TestSetSecureCookie_HTTP_10Network_Insecure |
237 | assert.False → assert.True |
backend/internal/api/handlers/auth_handler_test.go |
TestSetSecureCookie_HTTP_172Network_Insecure |
255 | assert.False → assert.True |
backend/internal/api/handlers/auth_handler_test.go |
TestSetSecureCookie_HTTP_IPv6ULA_Insecure |
291 | assert.False → assert.True |
The five tests named *_Insecure were authored to document the now-removed behaviour; their
assertions flip from False to True. Their names remain unchanged — renaming is cosmetic and
out of scope for a security fix.
Tests that must remain unchanged:
TestSetSecureCookie_HTTPS_Strict— assertsTrue; unaffected.TestSetSecureCookie_HTTP_Lax— assertsTrue; unaffected (192.0.2.0/24 is TEST-NET-1, not an RFC 1918 private range, soisLocalRequestalready returnedfalsehere).TestSetSecureCookie_ForwardedHTTPS_LocalhostForcesInsecure— assertsTrue; unaffected.TestSetSecureCookie_ForwardedHTTPS_LoopbackForcesInsecure— assertsTrue; unaffected.TestSetSecureCookie_ForwardedHostLocalhostForcesInsecure— assertsTrue; unaffected.TestSetSecureCookie_OriginLoopbackForcesInsecure— assertsTrue; unaffected.TestSetSecureCookie_HTTPS_PrivateIP_Secure— assertsTrue; unaffected.TestSetSecureCookie_HTTP_PublicIP_Secure— assertsTrue; unaffected.
4. Implementation Details
4.1 Changes to setSecureCookie in auth_handler.go
Before (lines 128–162):
// setSecureCookie sets an auth cookie with security best practices
// - HttpOnly: prevents JavaScript access (XSS protection)
// - 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
sameSite := http.SameSiteStrictMode
if scheme != "https" {
sameSite = http.SameSiteLaxMode
if isLocalRequest(c) {
secure = false
}
}
if isLocalRequest(c) {
sameSite = http.SameSiteLaxMode
}
// Use the host without port for domain
domain := ""
c.SetSameSite(sameSite)
// 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
maxAge, // maxAge in seconds
"/", // path
domain, // domain (empty = current host)
secure, // secure
true, // httpOnly (no JS access)
)
}
After:
// setSecureCookie sets an auth cookie with security best practices
// - HttpOnly: prevents JavaScript access (XSS protection)
// - Secure: always true; the localhost exception in Chrome, Firefox, and Safari
// permits Secure cookies over HTTP to localhost/127.0.0.1 without issue
// - 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)
sameSite := http.SameSiteStrictMode
if scheme != "https" || isLocalRequest(c) {
sameSite = http.SameSiteLaxMode
}
// Use the host without port for domain
domain := ""
c.SetSameSite(sameSite)
c.SetCookie(
name, // name
value, // value
maxAge, // maxAge in seconds
"/", // path
domain, // domain (empty = current host)
true, // secure (always; satisfies CWE-614)
true, // httpOnly (no JS access)
)
}
What changed:
- The
secure := truevariable is removed entirely;trueis now a literal at the callsite, making the intent unmistakable to both humans and static analysis tools. - The
if scheme != "https" { ... if isLocalRequest(c) { secure = false } }block is replaced by a singleif scheme != "https" || isLocalRequest(c)guard for thesameSitevalue only. The two previously separateisLocalRequestcalls collapse into one. - The
// secure is intentionally false...comment is removed — it described dead logic. - The
// codeql[go/cookie-secure-not-set]inline suppression is removed — it is no longer needed and should not persist as misleading dead commentary. - The function's docstring bullet for
Secure:is updated to reflect the always-true policy and cite the browser localhost exception.
4.2 Changes to auth_handler_test.go
Five assert.False(t, cookie.Secure) assertions become assert.True(t, cookie.Secure).
The SameSite assertions on the lines immediately following each are correct and untouched.
| Line | Before | After |
|---|---|---|
| 115 | assert.False(t, cookie.Secure) |
assert.True(t, cookie.Secure) |
| 219 | assert.False(t, cookie.Secure) |
assert.True(t, cookie.Secure) |
| 237 | assert.False(t, cookie.Secure) |
assert.True(t, cookie.Secure) |
| 255 | assert.False(t, cookie.Secure) |
assert.True(t, cookie.Secure) |
| 291 | assert.False(t, cookie.Secure) |
assert.True(t, cookie.Secure) |
4.3 No Changes Required
The following functions are call-through wrappers or callers of setSecureCookie and require
zero modification:
clearSecureCookie— its contract ("remove the cookie") is satisfied by anymaxAge = -1call, regardless of theSecureattribute value.AuthHandler.Login,AuthHandler.Logout,AuthHandler.Refresh— callsites are unchanged.isLocalRequest,isLocalOrPrivateHost,requestScheme,normalizeHost,originHost— all remain in use for thesameSitedetermination.codeql-config.yml— no query exclusions are needed; the root cause is fixed in code.
5. Test Coverage Requirements
5.1 Existing Coverage — Sufficient After Amendment
The five amended tests continue to exercise the local-HTTP branch of setSecureCookie:
- They confirm
SameSiteLaxModeis still applied for local/private-IP HTTP requests. - They now additionally confirm
Secure = trueeven on those requests.
No new test functions are required; the amendment restores the existing tests to accuracy.
5.2 Regression Check
After the change, run the full handlers package test suite:
cd backend && go test ./internal/api/handlers/... -run TestSetSecureCookie -v
All tests matching TestSetSecureCookie* must pass. Pay particular attention to:
TestSetSecureCookie_HTTP_Loopback_Insecure—Secure = true,SameSite = LaxTestSetSecureCookie_HTTPS_Strict—Secure = true,SameSite = StrictTestSetSecureCookie_HTTP_PublicIP_Secure—Secure = true,SameSite = Lax
5.3 No New Tests
A new test asserting Secure = true for all request types would be redundant — the amended
assertions across 5 existing tests already cover loopback, private-IPv4 (three RFC 1918 ranges),
and IPv6 ULA. There is no behavioural gap that requires new coverage.
6. Commit Slicing Strategy
This remediation ships as a single commit on a single PR. It touches exactly two files and
changes exactly one category of behaviour (the cookie Secure attribute). Splitting it would
create a transient state where the production code and the unit tests are inconsistent.
Commit message:
fix(auth): always set Secure attribute on auth cookies (CWE-614)
Remove the conditional secure = false path that CodeQL flags as
go/cookie-secure-not-set. The Secure flag is now unconditionally
true on all SetCookie calls.
Browsers apply the localhost exception (Chrome 66+, Firefox 75+,
Safari 14+), so Secure cookies over HTTP to 127.0.0.1 and localhost
work correctly in development. Direct private-IP HTTP access was
never a supported deployment mode; Charon is designed to run behind
Caddy with TLS termination.
Removes the inline codeql[go/cookie-secure-not-set] suppression which
masked the finding without correcting it, and updates the five unit
tests that previously asserted Secure = false for local-network HTTP.
PR title: fix(auth): set Secure attribute unconditionally on auth cookies (CWE-614)
PR labels: security, fix
7. Acceptance Criteria
A successful remediation satisfies all of the following:
7.1 CodeQL CI Passes
- The
CodeQL - Analyze (go)workflow job completes with zero results for rulego/cookie-secure-not-set. - No new findings are introduced in
go/cookie-httponly-not-setor any adjacent cookie rule. - The
Verify CodeQL parity guardstep (check-codeql-parity.sh) succeeds.
7.2 Unit Tests Pass
cd backend && go test ./internal/api/handlers/... -count=1
All tests in the handlers package pass, including the five amended *_Insecure tests that
now assert Secure = true.
7.3 Build Passes
cd backend && go build ./...
The backend compiles cleanly with no errors or vet warnings.
7.4 No Suppression Comments Remain
grep -r 'codeql\[go/cookie-secure-not-set\]' backend/
Returns no matches. The finding is resolved at the source, not hidden.
7.5 SARIF Regenerated
After the CI run, the codeql-results-go.sarif file must not contain any result with
ruleId: go/cookie-secure-not-set. If the SARIF is maintained as a repository artefact,
regenerate it using the local pre-commit CodeQL scan and commit it alongside the fix.
8. Out of Scope
- Renaming the five
*_Insecuretest functions. The names are anachronistic but accurate enough to remain; renaming is cosmetic and does not affect security posture or CI results. - Changes to
codeql-config.yml. A config-level query exclusion would hide the finding across the entire repository; fixing the code is strictly preferable. - Changes to Caddy configuration or TLS termination. The
Securecookie attribute is set by the Go backend; the proxy layer is not involved. - Changes to
isLocalRequestor its helpers. They remain correct and necessary for theSameSitedetermination.