Skip to content

Commit f42a5a8

Browse files
fix: resolve four Semgrep security findings (#48)
Default admin cookies to Secure except for explicit local HTTP. Direct TLS always enables Secure. OAuth state permits Strict cookies. Scope suppressions to safe parser IDs and JSON-encoded NDJSON. Test cookie modes, export headers, and receipt text encoding. Signed-off-by: Shreyansh Sancheti <43677304+shreyanshjain7174@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 2f81c85 commit f42a5a8

8 files changed

Lines changed: 170 additions & 17 deletions

File tree

‎README.md‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -369,7 +369,7 @@ Set `AGENTGATE_VAULT_CACHE_TTL=0` to disable plaintext caching. Refresh sharing
369369
| `AGENTGATE_VAULT_KEY` | 32-byte vault key. Also derives receipt encryption keys and the API-key HMAC pepper. | Yes |
370370
| `AGENTGATE_VAULT_CACHE_TTL` | Decrypted-token cache duration. Default `60s`; `0` disables caching. Negative or invalid durations stop startup. | No |
371371
| `AGENTGATE_ADMIN_SECRET` | Secret for admin API access | Yes |
372-
| `AGENTGATE_PUBLIC_URL` | Base URL used to build the OAuth callback (default `http://localhost:8080`) | No |
372+
| `AGENTGATE_PUBLIC_URL` | Public base URL for OAuth callbacks and admin cookie security. Default `http://localhost:8080`. | No |
373373
| `AGENTGATE_API_KEY_CACHE_TTL` | Verified-key cache duration. Default `30s`; `0` disables caching. | No |
374374
| `AGENTGATE_RATE_KEY_RPS` | Requests per second per API key ID. Default `20`. Zero requires tenant admission disabled. | No |
375375
| `AGENTGATE_RATE_KEY_BURST` | Per-key token bucket burst. Default `40`. Zero requires tenant admission disabled. | No |
@@ -453,6 +453,16 @@ The early rate limiter runs before these checks. Structural caps then bound fact
453453
Biscuit-go v2.2.0 deadlines do not cover check evaluation and cannot reliably stop timed-out goroutines.
454454
Runtime limits alone do not bound per-request CPU.
455455

456+
### Admin Session Cookies
457+
458+
Set `AGENTGATE_PUBLIC_URL` to your HTTPS address behind a TLS-terminating proxy.
459+
Admin cookies default to `Secure`, `HttpOnly`, and `SameSite=Strict`, with a 1-hour lifetime and `Path=/`.
460+
Only explicit HTTP URLs on `localhost` or `127.0.0.1` disable `Secure` for local development.
461+
Direct TLS requests always set `Secure`, including when the public URL uses local HTTP.
462+
Forwarded headers cannot downgrade cookie security.
463+
Logout clears the cookie with the same security attributes.
464+
OAuth callbacks validate encrypted state without an admin session, so they don't need `SameSite=Lax`.
465+
456466
### Service Configs
457467

458468
Service configurations are YAML files in `configs/services/`. See `configs/services/google_workspace.yaml` for an example. The gateway itself loads the merged `configs/services.yaml`.

‎cmd/agentgate-mcp/main.go‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ func serve(ctx context.Context, input io.Reader, output io.Writer, client *sdk.C
5959
scanner.Buffer(make([]byte, 4096), maxLineBytes)
6060
encoder := json.NewEncoder(output)
6161
for scanner.Scan() {
62+
// parseRequest returns a value with an echoable ID on errors, never a nil pointer.
63+
// nosemgrep: trailofbits.go.invalid-usage-of-modified-variable.invalid-usage-of-modified-variable
6264
req, envelopeError := parseRequest(scanner.Bytes())
6365
if envelopeError != nil {
6466
if err := encoder.Encode(map[string]any{"jsonrpc": "2.0", "id": req.ID, "error": envelopeError}); err != nil {

‎internal/admin/handler.go‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,12 +118,15 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
118118
http.Error(w, "login failed", http.StatusInternalServerError)
119119
return
120120
}
121+
cookie.Secure = cookie.Secure || r.TLS != nil
121122
http.SetCookie(w, cookie)
122123
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
123124
}
124125

125126
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
126-
http.SetCookie(w, h.sessions.ClearCookie())
127+
cookie := h.sessions.ClearCookie()
128+
cookie.Secure = cookie.Secure || r.TLS != nil
129+
http.SetCookie(w, cookie)
127130
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
128131
}
129132

‎internal/admin/handler_test.go‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,68 @@ func TestHandlerLoginSetsSessionCookie(t *testing.T) {
6565
}
6666
}
6767

68+
func TestHandlerSessionCookieSecurity(t *testing.T) {
69+
t.Parallel()
70+
tests := []struct {
71+
name string
72+
publicURL string
73+
requestURL string
74+
forwardedProto string
75+
wantSecure bool
76+
}{
77+
{name: "https proxy", publicURL: "https://agentgate.example", requestURL: "http://agentgate.example", wantSecure: true},
78+
{name: "localhost development", publicURL: "http://localhost:8080", requestURL: "http://localhost:8080"},
79+
{name: "loopback development", publicURL: "http://127.0.0.1:8080", requestURL: "http://127.0.0.1:8080"},
80+
{name: "direct tls", publicURL: "http://localhost:8080", requestURL: "https://localhost:8080", wantSecure: true},
81+
{name: "remote http", publicURL: "http://agentgate.example", requestURL: "http://agentgate.example", wantSecure: true},
82+
{name: "missing public url", requestURL: "http://agentgate.example", wantSecure: true},
83+
{name: "forwarded http cannot downgrade", publicURL: "https://agentgate.example", requestURL: "http://agentgate.example", forwardedProto: "http", wantSecure: true},
84+
{name: "forwarded https is not trusted", publicURL: "http://localhost:8080", requestURL: "http://localhost:8080", forwardedProto: "https"},
85+
}
86+
for _, test := range tests {
87+
t.Run(test.name, func(t *testing.T) {
88+
handler, _ := testHandler(t)
89+
handler.sessions = NewSessionManager([]byte("dev-key-change-in-production-32b"), test.publicURL)
90+
form := url.Values{"email": {"admin@example.com"}, "password": {"correct horse battery staple"}}
91+
request := httptest.NewRequest(http.MethodPost, test.requestURL+"/admin/login", bytes.NewBufferString(form.Encode()))
92+
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
93+
request.Header.Set("X-Forwarded-Proto", test.forwardedProto)
94+
response := httptest.NewRecorder()
95+
handler.Login(response, request)
96+
if response.Code != http.StatusSeeOther {
97+
t.Fatalf("Login status = %d, want %d", response.Code, http.StatusSeeOther)
98+
}
99+
loginCookies := response.Result().Cookies()
100+
if len(loginCookies) != 1 {
101+
t.Fatalf("Login returned %d cookies, want 1", len(loginCookies))
102+
}
103+
104+
logoutRequest := httptest.NewRequest(http.MethodPost, test.requestURL+"/admin/logout", nil)
105+
logoutRequest.Header.Set("X-Forwarded-Proto", test.forwardedProto)
106+
logoutRequest.Header.Set(csrfHeaderName, csrfHeaderValue)
107+
logoutRequest.AddCookie(loginCookies[0])
108+
logoutResponse := httptest.NewRecorder()
109+
handler.RequireAdmin(http.HandlerFunc(handler.Logout)).ServeHTTP(logoutResponse, logoutRequest)
110+
if logoutResponse.Code != http.StatusSeeOther {
111+
t.Fatalf("Logout status = %d, want %d", logoutResponse.Code, http.StatusSeeOther)
112+
}
113+
logoutCookies := logoutResponse.Result().Cookies()
114+
if len(logoutCookies) != 1 || logoutCookies[0].MaxAge != -1 {
115+
t.Fatalf("Logout cookies = %#v", logoutCookies)
116+
}
117+
for _, cookie := range []*http.Cookie{loginCookies[0], logoutCookies[0]} {
118+
if cookie.Secure != test.wantSecure || !cookie.HttpOnly ||
119+
cookie.SameSite != http.SameSiteStrictMode || cookie.Path != "/" {
120+
t.Fatalf("cookie has incorrect attributes: %#v", cookie)
121+
}
122+
}
123+
if loginCookies[0].MaxAge != int(sessionTTL.Seconds()) || loginCookies[0].Expires.IsZero() {
124+
t.Fatalf("Login cookie has incorrect lifetime: %#v", loginCookies[0])
125+
}
126+
})
127+
}
128+
}
129+
68130
func TestHandlerRequireAdmin(t *testing.T) {
69131
t.Parallel()
70132
handler, identity := testHandler(t)

‎internal/admin/session.go‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"fmt"
1111
"io"
1212
"net/http"
13+
"net/url"
1314
"strings"
1415
"time"
1516

@@ -42,9 +43,15 @@ type SessionManager struct {
4243
}
4344

4445
func NewSessionManager(masterKey []byte, publicURL string) *SessionManager {
46+
secure := true
47+
parsed, err := url.Parse(publicURL)
48+
if err == nil && parsed.Scheme == "http" && parsed.User == nil &&
49+
(strings.EqualFold(parsed.Hostname(), "localhost") || parsed.Hostname() == "127.0.0.1") {
50+
secure = false
51+
}
4552
return &SessionManager{
4653
key: signer.DerivePurposeKey(masterKey, sessionKeyPurpose),
47-
secure: strings.HasPrefix(publicURL, "https://"),
54+
secure: secure,
4855
now: time.Now,
4956
}
5057
}
@@ -76,6 +83,8 @@ func (m *SessionManager) CreateCookie(identity SessionIdentity) (*http.Cookie, e
7683
}
7784
ciphertext := gcm.Seal(nonce, nonce, payload, nil)
7885
expiresAt := m.now().Add(sessionTTL)
86+
// Secure is disabled only for explicit loopback HTTP development URLs.
87+
// nosemgrep: go.lang.security.audit.net.cookie-missing-secure.cookie-missing-secure
7988
return &http.Cookie{
8089
Name: sessionCookieName,
8190
Value: base64.URLEncoding.EncodeToString(ciphertext),
@@ -84,7 +93,7 @@ func (m *SessionManager) CreateCookie(identity SessionIdentity) (*http.Cookie, e
8493
MaxAge: int(sessionTTL.Seconds()),
8594
HttpOnly: true,
8695
Secure: m.secure,
87-
SameSite: http.SameSiteLaxMode,
96+
SameSite: http.SameSiteStrictMode,
8897
}, nil
8998
}
9099

@@ -125,13 +134,15 @@ func (m *SessionManager) Authenticate(r *http.Request) (SessionIdentity, error)
125134
}
126135

127136
func (m *SessionManager) ClearCookie() *http.Cookie {
137+
// Secure is disabled only for explicit loopback HTTP development URLs.
138+
// nosemgrep: go.lang.security.audit.net.cookie-missing-secure.cookie-missing-secure
128139
return &http.Cookie{
129140
Name: sessionCookieName,
130141
Value: "",
131142
Path: "/",
132143
MaxAge: -1,
133144
HttpOnly: true,
134145
Secure: m.secure,
135-
SameSite: http.SameSiteLaxMode,
146+
SameSite: http.SameSiteStrictMode,
136147
}
137148
}

‎internal/admin/session_test.go‎

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ func TestSessionManagerAuthenticate(t *testing.T) {
2626
if identity != (SessionIdentity{AdminID: "admin-1", OrgID: "org-1"}) {
2727
t.Fatalf("identity = %#v", identity)
2828
}
29-
if !cookie.HttpOnly || !cookie.Secure || cookie.SameSite != http.SameSiteLaxMode || cookie.Path != "/" {
29+
if !cookie.HttpOnly || !cookie.Secure || cookie.SameSite != http.SameSiteStrictMode || cookie.Path != "/" {
3030
t.Fatalf("cookie has unsafe attributes: %#v", cookie)
3131
}
3232
}
@@ -71,16 +71,47 @@ func TestSessionManagerRejectsInvalidSessions(t *testing.T) {
7171

7272
func TestSessionManagerCookieSecurityFollowsPublicURL(t *testing.T) {
7373
t.Parallel()
74-
manager := NewSessionManager([]byte("dev-key-change-in-production-32b"), "http://localhost:8080")
75-
cookie, err := manager.CreateCookie(SessionIdentity{AdminID: "admin-1", OrgID: "org-1"})
76-
if err != nil {
77-
t.Fatalf("CreateCookie: %v", err)
78-
}
79-
if cookie.Secure {
80-
t.Fatal("http public URL set Secure cookie")
74+
tests := []struct {
75+
name string
76+
publicURL string
77+
wantSecure bool
78+
}{
79+
{name: "https", publicURL: "https://agentgate.example", wantSecure: true},
80+
{name: "uppercase https", publicURL: "HTTPS://agentgate.example", wantSecure: true},
81+
{name: "https localhost", publicURL: "https://localhost:8080", wantSecure: true},
82+
{name: "http localhost", publicURL: "http://localhost:8080"},
83+
{name: "http uppercase localhost", publicURL: "http://LOCALHOST:8080"},
84+
{name: "http loopback", publicURL: "http://127.0.0.1:8080"},
85+
{name: "http remote", publicURL: "http://agentgate.example", wantSecure: true},
86+
{name: "http lookalike", publicURL: "http://localhost.example", wantSecure: true},
87+
{name: "http userinfo", publicURL: "http://remote@localhost", wantSecure: true},
88+
{name: "http ipv6", publicURL: "http://[::1]:8080", wantSecure: true},
89+
{name: "missing url", wantSecure: true},
90+
{name: "relative url", publicURL: "/localhost", wantSecure: true},
91+
{name: "malformed url", publicURL: "http://localhost:%", wantSecure: true},
8192
}
82-
cleared := manager.ClearCookie()
83-
if cleared.MaxAge != -1 || !cleared.HttpOnly || cleared.SameSite != http.SameSiteLaxMode {
84-
t.Fatalf("ClearCookie = %#v", cleared)
93+
for _, test := range tests {
94+
t.Run(test.name, func(t *testing.T) {
95+
now := time.Date(2026, time.September, 1, 12, 0, 0, 0, time.UTC)
96+
manager := NewSessionManager([]byte("dev-key-change-in-production-32b"), test.publicURL)
97+
manager.now = func() time.Time { return now }
98+
cookie, err := manager.CreateCookie(SessionIdentity{AdminID: "admin-1", OrgID: "org-1"})
99+
if err != nil {
100+
t.Fatalf("CreateCookie: %v", err)
101+
}
102+
if cookie.Name != sessionCookieName || cookie.Value == "" ||
103+
cookie.MaxAge != int(sessionTTL.Seconds()) || !cookie.Expires.Equal(now.Add(sessionTTL)) {
104+
t.Fatalf("CreateCookie = %#v", cookie)
105+
}
106+
cleared := manager.ClearCookie()
107+
if cleared.Name != sessionCookieName || cleared.Value != "" || cleared.MaxAge != -1 {
108+
t.Fatalf("ClearCookie = %#v", cleared)
109+
}
110+
for _, c := range []*http.Cookie{cookie, cleared} {
111+
if c.Secure != test.wantSecure || !c.HttpOnly || c.SameSite != http.SameSiteStrictMode || c.Path != "/" {
112+
t.Fatalf("cookie has incorrect attributes: %#v", c)
113+
}
114+
}
115+
})
85116
}
86117
}

‎internal/receipt/export_handler.go‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,8 @@ func writeJSONLLine(w http.ResponseWriter, line []byte, err error) {
227227
if err != nil {
228228
return // the response is already committed by the time marshaling could fail; nothing safe left to do
229229
}
230+
// Callers supply JSON-encoded records under application/x-ndjson with nosniff, not HTML.
231+
// nosemgrep: go.lang.security.audit.xss.no-direct-write-to-responsewriter.no-direct-write-to-responsewriter
230232
w.Write(line)
231233
w.Write([]byte("\n"))
232234
}

‎internal/receipt/export_handler_test.go‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,12 @@ func TestExportHandler_FullRangeVerifiesOffline(t *testing.T) {
2424
if w.Code != http.StatusOK {
2525
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
2626
}
27-
if ct := w.Header().Get("Content-Type"); ct != "application/x-ndjson" {
27+
if ct := w.Result().Header.Get("Content-Type"); ct != "application/x-ndjson" {
2828
t.Fatalf("Content-Type = %s, want application/x-ndjson", ct)
2929
}
30+
if got := w.Result().Header.Get("X-Content-Type-Options"); got != "nosniff" {
31+
t.Fatalf("X-Content-Type-Options = %q, want nosniff", got)
32+
}
3033

3134
receipts, keys, manifest := parseExportBody(t, w.Body.String())
3235
if manifest == nil {
@@ -122,6 +125,12 @@ func TestExportHandler_InvalidRangesReject400(t *testing.T) {
122125
if w.Code != http.StatusBadRequest {
123126
t.Errorf("%s: status = %d, want 400", url, w.Code)
124127
}
128+
if ct := w.Result().Header.Get("Content-Type"); ct != "application/json" {
129+
t.Errorf("%s: Content-Type = %q, want application/json", url, ct)
130+
}
131+
if got := w.Result().Header.Get("X-Content-Type-Options"); got != "nosniff" {
132+
t.Errorf("%s: X-Content-Type-Options = %q, want nosniff", url, got)
133+
}
125134
}
126135
}
127136

@@ -219,6 +228,29 @@ func TestExportHandler_NoRawParamsOrSensitiveData(t *testing.T) {
219228
}
220229
}
221230

231+
func TestExportHandler_JSONEncodesReceiptText(t *testing.T) {
232+
t.Parallel()
233+
ledger, store := newTestLedgerAndStore(t)
234+
draft := testDraft("github", "list_repos")
235+
draft.HumanPrincipal = "</script><script>alert(\"receipt\")</script>\n{\"injected\":true}"
236+
if _, err := ledger.Append(t.Context(), draft); err != nil {
237+
t.Fatalf("Append: %v", err)
238+
}
239+
request := httptest.NewRequest(http.MethodGet, "/v1/receipts/export?from=1", nil)
240+
response := httptest.NewRecorder()
241+
ExportHandler(ledger.db, store)(response, request)
242+
if response.Code != http.StatusOK {
243+
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
244+
}
245+
if strings.Contains(response.Body.String(), "<script>") {
246+
t.Fatal("export contains unescaped HTML")
247+
}
248+
receipts, _, _ := parseExportBody(t, response.Body.String())
249+
if len(receipts) != 1 || receipts[0].HumanPrincipal != draft.HumanPrincipal {
250+
t.Fatalf("export changed or injected receipt data: %#v", receipts)
251+
}
252+
}
253+
222254
func parseExportBody(t *testing.T, body string) (receipts []Receipt, keys []TrustedKey, manifest *ExportManifest) {
223255
t.Helper()
224256
for _, line := range strings.Split(strings.TrimSpace(body), "\n") {

0 commit comments

Comments
 (0)