Skip to content

Commit 68c04bd

Browse files
Sylvain Bauzaclaude
andcommitted
fix: address CodeRabbit review feedback on Gerrit integration
- Fix SSRF DNS rebinding (TOCTOU): add ssrfSafeTransport with custom dialer that validates resolved IPs at connection time, preventing DNS rebinding attacks between validation and HTTP request - Fail closed on non-200 Gerrit responses in ValidateGerritToken instead of assuming valid (prevents storing invalid connections) - Add isValidUserID() check to GetGerritStatus, DisconnectGerrit, and ListGerritInstances for consistency with ConnectGerrit - Tighten test assertions: assert exact HTTP 200 instead of NotTo(Equal(400)) or BeElementOf(200, 404) - Add timeout (15s) and error handling to frontend test/route.ts - Use discriminated union types for GerritConnectRequest and GerritTestRequest in frontend API types - Fix stale Gerrit config: call generate_gerrit_config() even when backend returns empty instances list, to clean up old config - Add docstring clarifying MCP config regeneration timing - Fix documentation: per-user Secret naming, pinned MCP server revision, multi-instance UX consistency, dedicated test request schema in OpenAPI contract Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ca2a03e commit 68c04bd

14 files changed

Lines changed: 173 additions & 49 deletions

File tree

components/backend/handlers/gerrit_auth.go

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,14 @@ func gerritSecretName(userID string) string {
4545
return gerritSecretPrefix + userID
4646
}
4747

48+
// isPrivateOrBlocked returns true if the IP is loopback, private,
49+
// link-local, or a cloud metadata address.
50+
func isPrivateOrBlocked(ip net.IP) bool {
51+
return ip.IsLoopback() || ip.IsPrivate() ||
52+
ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
53+
ip.Equal(net.ParseIP("169.254.169.254"))
54+
}
55+
4856
// validateGerritURL validates a Gerrit URL for SSRF protection.
4957
// It ensures the scheme is HTTPS and the host does not resolve to
5058
// loopback, private, link-local, or metadata-service addresses.
@@ -70,17 +78,48 @@ func validateGerritURL(rawURL string) error {
7078
if ip == nil {
7179
continue
7280
}
73-
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
81+
if isPrivateOrBlocked(ip) {
7482
return fmt.Errorf("URL must not resolve to a private or loopback address")
7583
}
76-
// Block cloud metadata endpoints (169.254.169.254)
77-
if ip.Equal(net.ParseIP("169.254.169.254")) {
78-
return fmt.Errorf("URL must not resolve to a metadata service address")
79-
}
8084
}
8185
return nil
8286
}
8387

88+
// ssrfSafeTransport returns an *http.Transport with a custom dialer that
89+
// rejects connections to private/loopback/metadata IPs. This prevents DNS
90+
// rebinding attacks where a hostname resolves to a public IP during
91+
// validateGerritURL but to a private IP when the HTTP request is made.
92+
func ssrfSafeTransport() *http.Transport {
93+
return &http.Transport{
94+
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
95+
host, port, err := net.SplitHostPort(addr)
96+
if err != nil {
97+
return nil, fmt.Errorf("invalid address: %w", err)
98+
}
99+
ips, err := net.DefaultResolver.LookupHost(ctx, host)
100+
if err != nil {
101+
return nil, fmt.Errorf("cannot resolve host: %w", err)
102+
}
103+
for _, ipStr := range ips {
104+
ip := net.ParseIP(ipStr)
105+
if ip != nil && isPrivateOrBlocked(ip) {
106+
return nil, fmt.Errorf("connection to private/loopback address blocked")
107+
}
108+
}
109+
// Dial using the resolved IPs to prevent rebinding
110+
var dialer net.Dialer
111+
for _, ipStr := range ips {
112+
conn, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(ipStr, port))
113+
if dialErr == nil {
114+
return conn, nil
115+
}
116+
err = dialErr
117+
}
118+
return nil, err
119+
},
120+
}
121+
}
122+
84123
// ConnectGerrit handles POST /api/auth/gerrit/connect
85124
// Validates and stores Gerrit credentials for a user instance
86125
func ConnectGerrit(c *gin.Context) {
@@ -201,6 +240,10 @@ func GetGerritStatus(c *gin.Context) {
201240
c.JSON(http.StatusUnauthorized, gin.H{"error": "User authentication required"})
202241
return
203242
}
243+
if !isValidUserID(userID) {
244+
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user identifier"})
245+
return
246+
}
204247

205248
instanceName := c.Param("instanceName")
206249
if instanceName == "" {
@@ -246,6 +289,10 @@ func DisconnectGerrit(c *gin.Context) {
246289
c.JSON(http.StatusUnauthorized, gin.H{"error": "User authentication required"})
247290
return
248291
}
292+
if !isValidUserID(userID) {
293+
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user identifier"})
294+
return
295+
}
249296

250297
instanceName := c.Param("instanceName")
251298
if instanceName == "" {
@@ -277,6 +324,10 @@ func ListGerritInstances(c *gin.Context) {
277324
c.JSON(http.StatusUnauthorized, gin.H{"error": "User authentication required"})
278325
return
279326
}
327+
if !isValidUserID(userID) {
328+
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user identifier"})
329+
return
330+
}
280331

281332
instances, err := listGerritCredentials(c.Request.Context(), userID)
282333
if err != nil {

components/backend/handlers/gerrit_auth_test.go

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -274,8 +274,7 @@ var _ = Describe("Gerrit Auth Handler", Label(test_constants.LabelUnit, test_con
274274

275275
ConnectGerrit(context)
276276

277-
status := httpUtils.GetResponseRecorder().Code
278-
Expect(status).NotTo(Equal(http.StatusBadRequest), "Should accept valid http_basic credentials")
277+
httpUtils.AssertHTTPStatus(http.StatusOK)
279278
})
280279

281280
It("Should return 401 when credentials are invalid", func() {
@@ -361,8 +360,7 @@ var _ = Describe("Gerrit Auth Handler", Label(test_constants.LabelUnit, test_con
361360

362361
GetGerritStatus(context)
363362

364-
status := httpUtils.GetResponseRecorder().Code
365-
Expect(status).To(BeElementOf(http.StatusOK, http.StatusNotFound))
363+
httpUtils.AssertHTTPStatus(http.StatusOK)
366364
})
367365
})
368366

components/backend/handlers/integration_validation.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,10 @@ func ValidateGerritToken(ctx context.Context, gerritURL, authMethod, username, h
153153
return false, fmt.Errorf("Gerrit URL is required")
154154
}
155155

156-
client := &http.Client{Timeout: 15 * time.Second}
156+
client := &http.Client{
157+
Timeout: 15 * time.Second,
158+
Transport: ssrfSafeTransport(),
159+
}
157160
apiURL := fmt.Sprintf("%s/a/accounts/self", gerritURL)
158161

159162
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
@@ -191,16 +194,16 @@ func ValidateGerritToken(ctx context.Context, gerritURL, authMethod, username, h
191194
}
192195
defer resp.Body.Close()
193196

194-
// 200 = valid, 401/403 = invalid
197+
// 200 = valid credentials; anything else (including 5xx, redirects,
198+
// wrong base URL) is treated as invalid to fail closed.
195199
if resp.StatusCode == http.StatusOK {
196200
return true, nil
197201
}
198202
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
199203
return false, nil
200204
}
201205

202-
// Other status codes - can't validate, assume valid to avoid false negatives
203-
return true, nil
206+
return false, fmt.Errorf("unexpected status %d from Gerrit /a/accounts/self", resp.StatusCode)
204207
}
205208

206209
// parseGitcookies extracts the cookie value for a given Gerrit URL from gitcookies content.

components/frontend/src/app/api/auth/gerrit/test/route.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,21 @@ export async function POST(request: Request) {
55
const headers = await buildForwardHeadersAsync(request)
66
const body = await request.text()
77

8-
const resp = await fetch(`${BACKEND_URL}/auth/gerrit/test`, {
9-
method: 'POST',
10-
headers,
11-
body,
12-
})
8+
try {
9+
const resp = await fetch(`${BACKEND_URL}/auth/gerrit/test`, {
10+
method: 'POST',
11+
headers,
12+
body,
13+
signal: AbortSignal.timeout(15_000),
14+
})
1315

14-
const data = await resp.text()
15-
return new Response(data, { status: resp.status, headers: { 'Content-Type': 'application/json' } })
16+
const data = await resp.text()
17+
return new Response(data, { status: resp.status, headers: { 'Content-Type': 'application/json' } })
18+
} catch (err) {
19+
const message = err instanceof Error ? err.message : 'Backend request failed'
20+
return new Response(JSON.stringify({ valid: false, error: message }), {
21+
status: 502,
22+
headers: { 'Content-Type': 'application/json' },
23+
})
24+
}
1625
}

components/frontend/src/services/api/gerrit-auth.ts

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,25 @@ import { apiClient } from './client'
22

33
export type GerritAuthMethod = 'http_basic' | 'git_cookies'
44

5+
type GerritHttpBasicFields = {
6+
authMethod: 'http_basic'
7+
username: string
8+
httpToken: string
9+
}
10+
11+
type GerritGitCookiesFields = {
12+
authMethod: 'git_cookies'
13+
gitcookiesContent: string
14+
}
15+
516
export type GerritConnectRequest = {
617
instanceName: string
718
url: string
8-
authMethod: GerritAuthMethod
9-
username?: string
10-
httpToken?: string
11-
gitcookiesContent?: string
12-
}
19+
} & (GerritHttpBasicFields | GerritGitCookiesFields)
1320

1421
export type GerritTestRequest = {
1522
url: string
16-
authMethod: GerritAuthMethod
17-
username?: string
18-
httpToken?: string
19-
gitcookiesContent?: string
20-
}
23+
} & (GerritHttpBasicFields | GerritGitCookiesFields)
2124

2225
export type GerritTestResponse = {
2326
valid: boolean

components/runners/ambient-runner/ambient_runner/bridges/claude/mcp.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@ def generate_gerrit_config(instances: list[dict]) -> None:
3939
- .gitcookies: Combined gitcookies content (if any instances use git_cookies auth)
4040
4141
Sets GERRIT_CONFIG_PATH env var to point to the generated config.
42+
43+
This is called by populate_runtime_credentials() before each SDK run,
44+
so the config is always written before the CLI subprocess (and its MCP
45+
servers) are spawned. On credential refresh the config files on disk
46+
are regenerated; the Gerrit MCP server re-reads the config from disk
47+
on each request, so changes take effect without restarting it.
4248
"""
4349
config_dir = Path("/tmp/gerrit-mcp")
4450

components/runners/ambient-runner/ambient_runner/platform/auth.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -419,14 +419,19 @@ async def populate_runtime_credentials(context: RunnerContext) -> None:
419419
git_user_email = github_creds["email"]
420420

421421
# Gerrit credentials (generate config file for MCP server)
422+
# Always call generate_gerrit_config (even with empty list) so stale
423+
# config from a previous refresh is cleaned up when all instances are removed.
422424
if isinstance(gerrit_instances, Exception):
423425
logger.warning(f"Failed to fetch Gerrit credentials: {gerrit_instances}")
424-
elif gerrit_instances:
426+
else:
425427
try:
426428
from ambient_runner.bridges.claude.mcp import generate_gerrit_config
427429

428-
generate_gerrit_config(gerrit_instances)
429-
logger.info("Generated Gerrit MCP config from backend credentials")
430+
generate_gerrit_config(gerrit_instances if gerrit_instances else [])
431+
if gerrit_instances:
432+
logger.info("Generated Gerrit MCP config from backend credentials")
433+
else:
434+
logger.info("Cleared stale Gerrit MCP config (no instances)")
430435
except Exception as e:
431436
logger.warning(f"Failed to generate Gerrit config: {e}")
432437

docs/internal/integrations/gerrit-integration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -439,7 +439,7 @@ A: Use the `/api/auth/gerrit/test` endpoint. It validates credentials against th
439439
A: The instance name is a user-chosen identifier that distinguishes between multiple Gerrit instances. It is used in API paths (e.g., `/api/auth/gerrit/openstack/status`) and as part of the credential storage key.
440440

441441
**Q: Can two users connect the same Gerrit instance with the same instance name?**
442-
A: Yes. Instance names are scoped per user. Two different users can both have an instance named "openstack" without conflict, as credentials are stored with a compound key of `instanceName.userID`.
442+
A: Yes. Instance names are scoped per user. Each user has their own Kubernetes Secret (`gerrit-credentials-{userID}`), so two different users can both have an instance named "openstack" without conflict.
443443

444444
---
445445

specs/001-gerrit-integration/contracts/frontend-types.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,19 @@ export type GerritConnectRequest =
2222
gitcookiesContent: string
2323
}
2424

25+
export type GerritTestRequest =
26+
| {
27+
url: string
28+
authMethod: 'http_basic'
29+
username: string
30+
httpToken: string
31+
}
32+
| {
33+
url: string
34+
authMethod: 'git_cookies'
35+
gitcookiesContent: string
36+
}
37+
2538
export interface GerritConnectResponse {
2639
message: string
2740
instanceName: string

specs/001-gerrit-integration/contracts/gerrit-api.yaml

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ paths:
9797
content:
9898
application/json:
9999
schema:
100-
$ref: '#/components/schemas/GerritConnectRequest'
100+
$ref: '#/components/schemas/GerritTestRequest'
101101
responses:
102102
'200':
103103
description: Validation result
@@ -177,15 +177,51 @@ components:
177177
type: string
178178
enum: [http_basic, git_cookies]
179179
description: Authentication method
180-
username:
181-
type: string
182-
description: Gerrit username (required for http_basic)
183-
httpToken:
180+
oneOf:
181+
- properties:
182+
authMethod:
183+
const: http_basic
184+
username:
185+
type: string
186+
httpToken:
187+
type: string
188+
required: [username, httpToken]
189+
- properties:
190+
authMethod:
191+
const: git_cookies
192+
gitcookiesContent:
193+
type: string
194+
required: [gitcookiesContent]
195+
196+
GerritTestRequest:
197+
description: Test Gerrit credentials without storing (no instanceName required)
198+
type: object
199+
required: [url, authMethod]
200+
properties:
201+
url:
184202
type: string
185-
description: HTTP password/access token (required for http_basic)
186-
gitcookiesContent:
203+
format: uri
204+
description: Gerrit instance base URL
205+
example: https://review.opendev.org
206+
authMethod:
187207
type: string
188-
description: Raw gitcookies file content (required for git_cookies)
208+
enum: [http_basic, git_cookies]
209+
description: Authentication method
210+
oneOf:
211+
- properties:
212+
authMethod:
213+
const: http_basic
214+
username:
215+
type: string
216+
httpToken:
217+
type: string
218+
required: [username, httpToken]
219+
- properties:
220+
authMethod:
221+
const: git_cookies
222+
gitcookiesContent:
223+
type: string
224+
required: [gitcookiesContent]
189225

190226
GerritConnectResponse:
191227
type: object

0 commit comments

Comments
 (0)