Skip to content

Commit 3bad3bc

Browse files
fix(http): make server lockdown mode an upper bound over requests (#3112)
1 parent 769340d commit 3bad3bc

8 files changed

Lines changed: 158 additions & 12 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1619,6 +1619,8 @@ docker run -i --rm \
16191619
ghcr.io/github/github-mcp-server
16201620
```
16211621

1622+
In HTTP mode, this flag (or `GITHUB_LOCKDOWN_MODE`) is an upper bound: the `X-MCP-Lockdown` request header can enable lockdown mode when the operator has not, but it cannot disable lockdown mode the operator has already enabled. See the [Server Configuration Guide](docs/server-configuration.md#lockdown-mode) for details.
1623+
16221624
The behavior of lockdown mode depends on the tool invoked.
16231625

16241626
Following tools will return an error when the author lacks the push access:

docs/remote-server.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,10 @@ The Remote GitHub MCP server has optional headers equivalent to the Local server
6767
- `X-MCP-Readonly`: Enables only "read" tools.
6868
- Equivalent to `GITHUB_READ_ONLY` env var for Local server.
6969
- If this header is empty, "false", "f", "no", "n", "0", or "off" (ignoring whitespace and case), it will be interpreted as false. All other values are interpreted as true.
70-
- `X-MCP-Lockdown`: Enables lockdown mode, hiding public issue details created by users without push access.
70+
- `X-MCP-Lockdown`: Enables lockdown mode, hiding public issue details created by users without push access. Lockdown mode is a best-effort content filter, not a security boundary.
7171
- Equivalent to `GITHUB_LOCKDOWN_MODE` env var for Local server.
7272
- If this header is empty, "false", "f", "no", "n", "0", or "off" (ignoring whitespace and case), it will be interpreted as false. All other values are interpreted as true.
73+
- Server-side lockdown configuration is an upper bound: if the operator has already enabled lockdown mode, this header cannot disable it for a request. The header can only enable (or redundantly re-enable) lockdown mode; it cannot relax lockdown mode below the operator's configuration.
7374
- `X-MCP-Insiders`: Enables insiders mode for early access to new features.
7475
- Equivalent to `GITHUB_INSIDERS` env var or `--insiders` flag for Local server.
7576
- If this header is empty, "false", "f", "no", "n", "0", or "off" (ignoring whitespace and case), it will be interpreted as false. All other values are interpreted as true.

docs/server-configuration.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ Note: **read-only** mode acts as a strict security filter that takes precedence
2929

3030
Note: **excluded tools** takes precedence over toolsets and individual tools — listed tools are always excluded, even if their toolset is enabled or they are explicitly added via `--tools` / `X-MCP-Tools`.
3131

32+
Note: server-side **lockdown mode** (`--lockdown-mode` / `GITHUB_LOCKDOWN_MODE`) is an upper bound in HTTP mode — once an operator enables it, the `X-MCP-Lockdown` header can no longer disable it for a given request. A request may still use the header to enable lockdown mode for itself when the operator has not already enabled it server-wide, but it can never relax lockdown mode below what the operator configured. Lockdown mode remains a best-effort content filter, not a security boundary.
33+
3234
---
3335

3436
## Configuration Examples
@@ -292,6 +294,8 @@ When active, this mode will disable all tools that are not read-only even if the
292294

293295
Lockdown mode ensures the server only surfaces content in public repositories from users with push access to that repository. Private repositories are unaffected, and collaborators retain full access to their own content.
294296

297+
> In HTTP mode, server-side lockdown mode (`--lockdown-mode` / `GITHUB_LOCKDOWN_MODE`) is an upper bound: the `X-MCP-Lockdown` header can enable lockdown mode for a request when the operator has not enabled it server-wide, but it cannot disable lockdown mode the operator has already enabled.
298+
295299
Lockdown mode is a best-effort content filter meant to reduce prompt-injection risk from untrusted repository content; it is not an authorization boundary. It does not restrict what the underlying credential can otherwise read or write, and content withheld from a filtered tool response may still be reachable through other tools or direct GitHub API access with the same credential.
296300

297301
As an intentional exception, content authored by trusted bot accounts (currently `github-actions[bot]` and `copilot`) is always treated as safe, regardless of push access, so routine automation output isn't filtered.

pkg/github/dependencies.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -439,9 +439,17 @@ func (d *RequestDeps) GetRawClient(ctx context.Context) (*raw.Client, error) {
439439
return rawClient, nil
440440
}
441441

442+
// effectiveLockdownMode reports whether lockdown mode is active for the
443+
// request. d.lockdownMode is an operator-set upper bound: the per-request
444+
// X-MCP-Lockdown header (ghcontext.IsLockdownMode) can only enable lockdown,
445+
// never disable one the operator already turned on.
446+
func (d *RequestDeps) effectiveLockdownMode(ctx context.Context) bool {
447+
return d.lockdownMode || ghcontext.IsLockdownMode(ctx)
448+
}
449+
442450
// GetRepoAccessCache implements ToolDependencies.
443451
func (d *RequestDeps) GetRepoAccessCache(ctx context.Context) (*lockdown.RepoAccessCache, error) {
444-
if !d.lockdownMode {
452+
if !d.effectiveLockdownMode(ctx) {
445453
return nil, nil
446454
}
447455

@@ -466,7 +474,7 @@ func (d *RequestDeps) GetT() translations.TranslationHelperFunc { return d.T }
466474
// GetFlags implements ToolDependencies.
467475
func (d *RequestDeps) GetFlags(ctx context.Context) FeatureFlags {
468476
return FeatureFlags{
469-
LockdownMode: d.lockdownMode && ghcontext.IsLockdownMode(ctx),
477+
LockdownMode: d.effectiveLockdownMode(ctx),
470478
}
471479
}
472480

pkg/github/dependencies_test.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,116 @@ func TestIsFeatureEnabled_EmptyFlagName(t *testing.T) {
198198
assert.False(t, result, "Expected false for empty flag name")
199199
}
200200

201+
// TestRequestDepsLockdownModeIsUpperBound verifies the X-MCP-Lockdown header
202+
// can only enable lockdown, never disable the operator's server-side setting.
203+
func TestRequestDepsLockdownModeIsUpperBound(t *testing.T) {
204+
t.Parallel()
205+
206+
resolver := newRequestDepsAPIHostResolver(t, "https://example.com")
207+
208+
newDeps := func(serverLockdown bool) *github.RequestDeps {
209+
return github.NewRequestDeps(
210+
resolver,
211+
"test",
212+
serverLockdown,
213+
nil,
214+
translations.NullTranslationHelper,
215+
0,
216+
nil,
217+
testExporters(),
218+
)
219+
}
220+
221+
tokenCtx := func(requestLockdown bool) context.Context {
222+
ctx := ghcontext.WithTokenInfo(context.Background(), &ghcontext.TokenInfo{Token: "request-token"})
223+
if requestLockdown {
224+
ctx = ghcontext.WithLockdownMode(ctx, true)
225+
}
226+
return ctx
227+
}
228+
229+
tests := []struct {
230+
name string
231+
serverLockdown bool
232+
requestLockdown bool
233+
wantLockdownMode bool
234+
}{
235+
{
236+
name: "neither server nor request enable lockdown",
237+
serverLockdown: false,
238+
requestLockdown: false,
239+
wantLockdownMode: false,
240+
},
241+
{
242+
name: "server-only lockdown is enforced without a request header",
243+
serverLockdown: true,
244+
requestLockdown: false,
245+
wantLockdownMode: true,
246+
},
247+
{
248+
name: "request-only lockdown can enable it when the server has not",
249+
serverLockdown: false,
250+
requestLockdown: true,
251+
wantLockdownMode: true,
252+
},
253+
{
254+
name: "server and request both enabling lockdown stays enabled",
255+
serverLockdown: true,
256+
requestLockdown: true,
257+
wantLockdownMode: true,
258+
},
259+
}
260+
261+
for _, tt := range tests {
262+
t.Run(tt.name, func(t *testing.T) {
263+
t.Parallel()
264+
265+
deps := newDeps(tt.serverLockdown)
266+
ctx := tokenCtx(tt.requestLockdown)
267+
268+
flags := deps.GetFlags(ctx)
269+
assert.Equal(t, tt.wantLockdownMode, flags.LockdownMode, "GetFlags().LockdownMode")
270+
271+
cache, err := deps.GetRepoAccessCache(ctx)
272+
require.NoError(t, err)
273+
if tt.wantLockdownMode {
274+
assert.NotNil(t, cache, "expected a repo access cache to be built when lockdown mode is effectively enabled")
275+
} else {
276+
assert.Nil(t, cache, "expected no repo access cache when lockdown mode is effectively disabled")
277+
}
278+
})
279+
}
280+
}
281+
282+
// TestRequestDepsLockdownModeCannotBeDisabledByOmittingHeader is a regression
283+
// test for #3104: omitting the X-MCP-Lockdown header must not disable
284+
// server-enabled lockdown mode.
285+
func TestRequestDepsLockdownModeCannotBeDisabledByOmittingHeader(t *testing.T) {
286+
t.Parallel()
287+
288+
resolver := newRequestDepsAPIHostResolver(t, "https://example.com")
289+
deps := github.NewRequestDeps(
290+
resolver,
291+
"test",
292+
true, // server-enabled lockdown
293+
nil,
294+
translations.NullTranslationHelper,
295+
0,
296+
nil,
297+
testExporters(),
298+
)
299+
300+
// No X-MCP-Lockdown header sent.
301+
ctx := ghcontext.WithTokenInfo(context.Background(), &ghcontext.TokenInfo{Token: "request-token"})
302+
303+
flags := deps.GetFlags(ctx)
304+
assert.True(t, flags.LockdownMode, "server-enabled lockdown mode must remain enabled when a request omits the lockdown header")
305+
306+
cache, err := deps.GetRepoAccessCache(ctx)
307+
require.NoError(t, err)
308+
assert.NotNil(t, cache, "repo access cache must still be built so server-enabled lockdown mode can be enforced")
309+
}
310+
201311
func TestIsFeatureEnabled_CheckerError(t *testing.T) {
202312
t.Parallel()
203313

pkg/http/transport/bearer_test.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ func TestBearerAuthTransport(t *testing.T) {
5858
defer server.Close()
5959

6060
rt := &BearerAuthTransport{
61-
Transport: http.DefaultTransport,
61+
Transport: newIsolatedTransport(t),
6262
Token: tc.token,
6363
TokenProvider: tc.tokenProvider,
6464
}
@@ -91,7 +91,7 @@ func TestBearerAuthTransport_TokenProviderResolvedPerRequest(t *testing.T) {
9191

9292
current := ""
9393
rt := &BearerAuthTransport{
94-
Transport: http.DefaultTransport,
94+
Transport: newIsolatedTransport(t),
9595
TokenProvider: func() string { return current },
9696
}
9797

@@ -126,7 +126,7 @@ func TestBearerAuthTransport_PassesGraphQLFeaturesHeader(t *testing.T) {
126126
defer server.Close()
127127

128128
rt := &BearerAuthTransport{
129-
Transport: http.DefaultTransport,
129+
Transport: newIsolatedTransport(t),
130130
Token: "token",
131131
}
132132

@@ -150,7 +150,7 @@ func TestBearerAuthTransport_DoesNotMutateOriginalRequest(t *testing.T) {
150150
defer server.Close()
151151

152152
rt := &BearerAuthTransport{
153-
Transport: http.DefaultTransport,
153+
Transport: newIsolatedTransport(t),
154154
Token: "token",
155155
}
156156

@@ -356,7 +356,7 @@ func TestBearerAuthTransport_RedirectHostScoping(t *testing.T) {
356356
require.NoError(t, err)
357357

358358
client := &http.Client{Transport: &BearerAuthTransport{
359-
Transport: http.DefaultTransport,
359+
Transport: newIsolatedTransport(t),
360360
Token: "secret-token",
361361
AllowedHosts: []string{sourceURL.Host, allowedTargetURL.Host},
362362
}}

pkg/http/transport/graphql_features_test.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ func TestGraphQLFeaturesTransport(t *testing.T) {
6565

6666
// Create the transport
6767
transport := &GraphQLFeaturesTransport{
68-
Transport: http.DefaultTransport,
68+
Transport: newIsolatedTransport(t),
6969
}
7070

7171
// Create a request
@@ -91,9 +91,10 @@ func TestGraphQLFeaturesTransport(t *testing.T) {
9191
}
9292
}
9393

94+
// TestGraphQLFeaturesTransport_NilTransport exercises the real
95+
// http.DefaultTransport fallback, so it can't run in parallel with tests that
96+
// close their own servers (that closes DefaultTransport's idle conns too).
9497
func TestGraphQLFeaturesTransport_NilTransport(t *testing.T) {
95-
t.Parallel()
96-
9798
var capturedHeader string
9899

99100
// Create a test server
@@ -133,7 +134,7 @@ func TestGraphQLFeaturesTransport_DoesNotMutateOriginalRequest(t *testing.T) {
133134

134135
// Create the transport
135136
transport := &GraphQLFeaturesTransport{
136-
Transport: http.DefaultTransport,
137+
Transport: newIsolatedTransport(t),
137138
}
138139

139140
// Create a request with features

pkg/http/transport/helpers_test.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package transport
2+
3+
import (
4+
"net/http"
5+
"testing"
6+
)
7+
8+
// newIsolatedTransport returns an http.Transport owned by a single test.
9+
//
10+
// Sharing http.DefaultTransport across parallel tests is unsafe: closing one
11+
// test's httptest.Server also closes DefaultTransport's idle connections,
12+
// breaking other tests still using it. Tests asserting DefaultTransport
13+
// fallback behavior specifically must use it directly and not run in parallel.
14+
func newIsolatedTransport(t *testing.T) *http.Transport {
15+
t.Helper()
16+
17+
transport := &http.Transport{}
18+
t.Cleanup(transport.CloseIdleConnections)
19+
return transport
20+
}

0 commit comments

Comments
 (0)