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
32 changes: 25 additions & 7 deletions docs/PROXY_MODE.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,14 +89,19 @@ The proxy reuses the same 6-phase pipeline as the MCP gateway, with Phase 3 adap

## REST Route Mapping

The proxy maps ~25 GitHub REST API URL patterns to guard tool names:
The proxy maps REST API URL patterns to guard tool names (see `internal/proxy/router.go` for the exact source of truth). Inbound paths are normalized first:

- `GH_HOST` style REST paths with `/api/v3/...` are normalized to `/...` for routing.
- Query strings are ignored for route matching and still forwarded upstream.

Supported path families include:

| URL Pattern | Guard Tool |
|-------------|-----------|
| `/repos/:owner/:repo/issues` | `list_issues` |
| `/repos/:owner/:repo/issues/:number` | `get_issue` |
| `/repos/:owner/:repo/issues/:number` | `issue_read` |
| `/repos/:owner/:repo/pulls` | `list_pull_requests` |
| `/repos/:owner/:repo/pulls/:number` | `get_pull_request` |
| `/repos/:owner/:repo/pulls/:number` | `pull_request_read` |
| `/repos/:owner/:repo/commits` | `list_commits` |
| `/repos/:owner/:repo/commits/:sha` | `get_commit` |
| `/repos/:owner/:repo/contents/:path` | `get_file_contents` |
Expand All @@ -106,21 +111,34 @@ The proxy maps ~25 GitHub REST API URL patterns to guard tool names:
| `/search/code` | `search_code` |
| `/search/repositories` | `search_repositories` |
| `/user` | `get_me` |
| ... | See `internal/proxy/router.go` for full list |
| `/notifications` | `list_notifications` |
| `/orgs/:owner/actions/(secrets|variables)[/:name]` | `actions_list` |
| `/repos/:owner/:repo/discussions...` | `list_discussions` / `get_discussion_comments` |
| `/repos/:owner/:repo/...` (fallback) | `get_file_contents` |
| ... | See `internal/proxy/router.go` for the complete regex list and precedence |

Unrecognized URLs pass through without DIFC filtering.
For **read operations** (GET and GraphQL POST), unmatched routes are denied (fail-closed) to avoid accidental unfiltered data exposure. For **write operations** (non-read methods), requests pass through unchanged.

## GraphQL Support

GraphQL queries to `/graphql` are parsed to extract the operation type and owner/repo context:
Inbound GraphQL endpoint paths accepted by the proxy:

- `/graphql` (github.com style)
- `/api/graphql` (GHES style used by `gh` when host is GHES/proxy)
- `/api/v3/graphql` (GH_HOST prefix style; normalized)

GraphQL queries are parsed to extract operation type and owner/repo context:

- **Repository-scoped queries** (issues, PRs, commits) — mapped to corresponding tool names
- **Search queries** — mapped to `search_issues` or `search_code`
- **Viewer queries** — mapped to `get_me`
- **Unknown queries** — passed through without filtering
- **Schema introspection (`__schema`, `__type`)** — passed through (safe metadata)
- **Unknown queries** — denied (fail-closed)

Owner and repo are extracted from GraphQL variables (`$owner`, `$name`/`$repo`) or inline string arguments.

When the upstream API base is GHES-style `.../api/v3`, GraphQL forwarding is rewritten to `.../api/graphql` to match GHES routing.

## Policy Notes

- **Repo names must be lowercase** in policies (e.g., `octocat/hello-world` not `octocat/Hello-World`). The guard performs case-insensitive matching against actual GitHub data.
Expand Down
4 changes: 2 additions & 2 deletions internal/proxy/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ func (h *proxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if match.ToolName == "graphql_introspection" {
logHandler.Printf("GraphQL introspection query, passing through")
clientAuth := r.Header.Get("Authorization")
resp, respBody := h.forwardAndReadBody(w, r.Context(), http.MethodPost, "/graphql", bytes.NewReader(graphQLBody), "application/json", clientAuth)
resp, respBody := h.forwardAndReadBody(w, r.Context(), http.MethodPost, fullPath, bytes.NewReader(graphQLBody), "application/json", clientAuth)
if resp == nil {
return
}
Expand Down Expand Up @@ -206,7 +206,7 @@ func (h *proxyHandler) handleWithDIFC(w http.ResponseWriter, r *http.Request, pa
oteltrace.WithSpanKind(oteltrace.SpanKindClient),
)
if graphQLBody != nil {
resp, respBody = h.forwardAndReadBody(w, fwdCtx, http.MethodPost, "/graphql", bytes.NewReader(graphQLBody), "application/json", clientAuth)
resp, respBody = h.forwardAndReadBody(w, fwdCtx, http.MethodPost, path, bytes.NewReader(graphQLBody), "application/json", clientAuth)
} else {
resp, respBody = h.forwardAndReadBody(w, fwdCtx, r.Method, path, nil, "", clientAuth)
}
Expand Down
41 changes: 41 additions & 0 deletions internal/proxy/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,47 @@ func TestServeHTTP_GraphQLIntrospectionPassthrough(t *testing.T) {
assert.Contains(t, w.Body.String(), "__schema")
}

func TestServeHTTP_GraphQLPreservesQueryString(t *testing.T) {
tests := []struct {
name string
path string
wantPath string
}{
{name: "graphql path", path: "/graphql?foo=bar", wantPath: "/graphql?foo=bar"},
{name: "ghes api graphql path", path: "/api/graphql?foo=bar", wantPath: "/api/graphql?foo=bar"},
{name: "gh host prefixed graphql path", path: "/api/v3/graphql?foo=bar", wantPath: "/graphql?foo=bar"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var receivedURL string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedURL = r.URL.RequestURI()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, err := w.Write([]byte(`{"data":{"repository":{"issues":{"nodes":[]}}}}`))
require.NoError(t, err)
}))
defer upstream.Close()

s := newTestServer(t, upstream.URL)
h := &proxyHandler{server: s}

gqlBody, err := json.Marshal(map[string]interface{}{
"query": `{ repository(owner:"org", name:"repo") { issues(first: 10) { nodes { id } } } }`,
})
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, tt.path, bytes.NewReader(gqlBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)

assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, tt.wantPath, receivedURL)
})
}
}

// ─── ServeHTTP: query string is forwarded on REST GET ────────────────────────

func TestServeHTTP_QueryStringForwardedToUpstream(t *testing.T) {
Expand Down
7 changes: 7 additions & 0 deletions internal/proxy/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,13 @@ func (r *restBackendCaller) CallTool(ctx context.Context, toolName string, args
// if non-empty it is forwarded as-is, otherwise the configured fallback token is used.
func (s *Server) forwardToGitHub(ctx context.Context, method, path string, body io.Reader, contentType string, clientAuth string) (*http.Response, error) {
url := s.githubAPIURL + path
pathOnly, query, hasQuery := strings.Cut(path, "?")
if strings.HasSuffix(s.githubAPIURL, "/api/v3") && IsGraphQLPath(pathOnly) {
url = strings.TrimSuffix(s.githubAPIURL, "/api/v3") + "/api/graphql"
if hasQuery {
url += "?" + query
}
Comment on lines 345 to +351
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GraphQL query-string preservation appears incomplete end-to-end: forwardToGitHub now preserves ?… when path includes it, but the proxy’s GraphQL forwarding path is hard-coded elsewhere as "/graphql" (without the original query), so real /graphql?foo=bar requests will still drop the query. Consider plumbing the original request’s RawQuery into the GraphQL forward path (or switching the GraphQL forwarding call sites to pass the full path argument) so this logic is actually exercised in production.

See below for a potential fix:

// path should be the original request URI path, including any raw query string.
func (s *Server) forwardToGitHub(ctx context.Context, method, path string, body io.Reader, contentType string, clientAuth string) (*http.Response, error) {
	baseURL, err := url.Parse(s.githubAPIURL)
	if err != nil {
		return nil, fmt.Errorf("failed to parse GitHub API URL: %w", err)
	}

	requestURL, err := url.Parse(path)
	if err != nil {
		return nil, fmt.Errorf("failed to parse upstream path %q: %w", path, err)
	}

	pathOnly := requestURL.Path
	if strings.HasSuffix(baseURL.Path, "/api/v3") && pathOnly == "/graphql" {
		baseURL.Path = strings.TrimSuffix(baseURL.Path, "/api/v3") + "/api/graphql"
		baseURL.RawQuery = requestURL.RawQuery
	} else {
		baseURL = baseURL.ResolveReference(requestURL)
	}

	targetURL := baseURL.String()
	logProxy.Printf("forwarding %s %s → %s", method, path, targetURL)

	req, err := http.NewRequestWithContext(ctx, method, targetURL, body)
	if err != nil {

Copilot uses AI. Check for mistakes.
}
logProxy.Printf("forwarding %s %s → %s", method, path, url)

req, err := http.NewRequestWithContext(ctx, method, url, body)
Expand Down
43 changes: 43 additions & 0 deletions internal/proxy/proxy_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package proxy

import (
"context"
"io"
"net/http"
"net/http/httptest"
Expand Down Expand Up @@ -844,3 +845,45 @@ func TestUnwrapSingleObject(t *testing.T) {
})
}
}
func TestForwardToGitHub_RewritesGraphQLPathForGHESAPIBase(t *testing.T) {
var receivedPath string
var receivedQuery string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedPath = r.URL.Path
receivedQuery = r.URL.RawQuery
w.WriteHeader(http.StatusOK)
}))
defer upstream.Close()

s := &Server{
githubAPIURL: upstream.URL + "/api/v3",
httpClient: upstream.Client(),
}

tests := []struct {
name string
path string
wantPath string
wantQuery string
}{
{name: "plain graphql endpoint", path: "/graphql", wantPath: "/api/graphql"},
{name: "graphql endpoint with query string", path: "/graphql?foo=bar", wantPath: "/api/graphql", wantQuery: "foo=bar"},
{name: "ghes api graphql endpoint", path: "/api/graphql", wantPath: "/api/graphql"},
{name: "gh host prefixed graphql endpoint with query string", path: "/api/v3/graphql?foo=bar", wantPath: "/api/graphql", wantQuery: "foo=bar"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
receivedPath = ""
receivedQuery = ""

resp, err := s.forwardToGitHub(context.Background(), http.MethodPost, tt.path, nil, "application/json", "")
require.NoError(t, err)
require.NotNil(t, resp)
defer resp.Body.Close()

assert.Equal(t, tt.wantPath, receivedPath)
assert.Equal(t, tt.wantQuery, receivedQuery)
})
}
}
Loading