Commit 02605c7
authored
fix(compliance): require explicit mount mode in volume mount validation (#1231)
Per MCP Gateway Specification v1.8.0 §4.1.5, volume mounts **MUST** use
the `host:container:mode` 3-part format — mode is not optional. The
implementation was accepting 2-part `host:container` mounts, which
silently defaults to Docker's `rw` mode, violating least-privilege
principles.
### Validation logic (`internal/config/rules/rules.go`,
`validation_schema.go`)
- `MountFormat`: changed `len(parts) < 2 || len(parts) > 3` →
`len(parts) != 3`; removed optional-mode branch; mode is always
validated against `ro`/`rw`
- `mountPattern` regex: `^[^:]+:[^:]+(:(ro|rw))?$` →
`^[^:]+:[^:]+:(ro|rw)$`
```go
// Before — accepted both formats
if len(parts) < 2 || len(parts) > 3 { ... }
if mode != "" && mode != "ro" && mode != "rw" { ... }
// After — exactly 3 parts required
if len(parts) != 3 { ... }
if mode != "ro" && mode != "rw" { ... }
```
### Tests
- Converted all `"valid mount without mode"` cases to `"invalid mount
without mode"` (now expect error) across `rules_test.go`,
`validation_test.go`, `validation_string_patterns_test.go`,
`validation_schema_test.go`
- Updated `config_stdin_test.go` fixtures to use valid 3-part mounts
(`/tmp:/tmp:rw`, `/host:/container:ro`)
> [!WARNING]
>
> <details>
> <summary>Firewall rules blocked me from connecting to one or more
addresses (expand for details)</summary>
>
> #### I tried to connect to the following addresses, but was blocked by
firewall rules:
>
> - `example.com`
> - Triggering command: `/tmp/go-build2535648412/b275/launcher.test
/tmp/go-build2535648412/b275/launcher.test
-test.testlogfile=/tmp/go-build2535648412/b275/testlog.txt
-test.paniconexit0 -test.timeout=10m0s -test.v=true go
flate/deflate.go--64 64/pkg/tool/linu-o pull.rebase` (dns block)
> - Triggering command: `/tmp/go-build2026882574/b279/launcher.test
/tmp/go-build2026882574/b279/launcher.test
-test.testlogfile=/tmp/go-build2026882574/b279/testlog.txt
-test.paniconexit0 -test.timeout=10m0s -test.v=true
5648412/b223/cmd.test -I ker/cli-plugins/docker-buildx --gdwarf-5 --64
-o ker/cli-plugins/docker-buildx n-me�� ry=1
/opt/hostedtoolcache/go/1.25.6/x64/src/net ker/docker-init
/tmp/go-build253/opt/hostedtoolcache/go/1.25.6/x64/pkg/tool/linux_amd64/compile
-imultiarch x86_64-linux-gnu/tmp/go-build2026882574/b278/_pkg_.a
ker/docker-init` (dns block)
> - `invalid-host-that-does-not-exist-12345.com`
> - Triggering command: `/tmp/go-build2535648412/b260/config.test
/tmp/go-build2535648412/b260/config.test
-test.testlogfile=/tmp/go-build2535648412/b260/testlog.txt
-test.paniconexit0 -test.timeout=10m0s -test.v=true go
ternal/fips140/c--64 64/pkg/tool/linu-o r` (dns block)
> - Triggering command: `/tmp/go-build3414178539/b260/config.test
/tmp/go-build3414178539/b260/config.test
-test.testlogfile=/tmp/go-build3414178539/b260/testlog.txt
-test.paniconexit0 -test.timeout=10m0s -test.v=true 64/src/runtime/cgo
main x_amd64/vet` (dns block)
> - Triggering command: `/tmp/go-build2026882574/b264/config.test
/tmp/go-build2026882574/b264/config.test
-test.testlogfile=/tmp/go-build2026882574/b264/testlog.txt
-test.paniconexit0 -test.timeout=10m0s -test.v=true se stmain.go
ache/go/1.25.6/x64/pkg/tool/linux_amd64/link . ctor
p=/opt/hostedtoo/tmp/go-build2784548960/b211/vet.cfg
ache/go/1.25.6/x64/pkg/tool/linux_amd64/link -I 5648412/b223/cmd.test -I
ker/cli-plugins/docker-buildx --gdwarf-5 --64 -o
ker/cli-plugins/docker-buildx` (dns block)
> - `nonexistent.local`
> - Triggering command: `/tmp/go-build2535648412/b275/launcher.test
/tmp/go-build2535648412/b275/launcher.test
-test.testlogfile=/tmp/go-build2535648412/b275/testlog.txt
-test.paniconexit0 -test.timeout=10m0s -test.v=true go
flate/deflate.go--64 64/pkg/tool/linu-o pull.rebase` (dns block)
> - Triggering command: `/tmp/go-build2026882574/b279/launcher.test
/tmp/go-build2026882574/b279/launcher.test
-test.testlogfile=/tmp/go-build2026882574/b279/testlog.txt
-test.paniconexit0 -test.timeout=10m0s -test.v=true
5648412/b223/cmd.test -I ker/cli-plugins/docker-buildx --gdwarf-5 --64
-o ker/cli-plugins/docker-buildx n-me�� ry=1
/opt/hostedtoolcache/go/1.25.6/x64/src/net ker/docker-init
/tmp/go-build253/opt/hostedtoolcache/go/1.25.6/x64/pkg/tool/linux_amd64/compile
-imultiarch x86_64-linux-gnu/tmp/go-build2026882574/b278/_pkg_.a
ker/docker-init` (dns block)
> - `slow.example.com`
> - Triggering command: `/tmp/go-build2535648412/b275/launcher.test
/tmp/go-build2535648412/b275/launcher.test
-test.testlogfile=/tmp/go-build2535648412/b275/testlog.txt
-test.paniconexit0 -test.timeout=10m0s -test.v=true go
flate/deflate.go--64 64/pkg/tool/linu-o pull.rebase` (dns block)
> - Triggering command: `/tmp/go-build2026882574/b279/launcher.test
/tmp/go-build2026882574/b279/launcher.test
-test.testlogfile=/tmp/go-build2026882574/b279/testlog.txt
-test.paniconexit0 -test.timeout=10m0s -test.v=true
5648412/b223/cmd.test -I ker/cli-plugins/docker-buildx --gdwarf-5 --64
-o ker/cli-plugins/docker-buildx n-me�� ry=1
/opt/hostedtoolcache/go/1.25.6/x64/src/net ker/docker-init
/tmp/go-build253/opt/hostedtoolcache/go/1.25.6/x64/pkg/tool/linux_amd64/compile
-imultiarch x86_64-linux-gnu/tmp/go-build2026882574/b278/_pkg_.a
ker/docker-init` (dns block)
> - `this-host-does-not-exist-12345.com`
> - Triggering command: `/tmp/go-build2535648412/b284/mcp.test
/tmp/go-build2535648412/b284/mcp.test
-test.testlogfile=/tmp/go-build2535648412/b284/testlog.txt
-test.paniconexit0 -test.timeout=10m0s -test.v=true 64/src/runtime/c-p o
ache/go/1.25.6/x-lang=go1.25 user.name` (dns block)
>
> If you need me to access, download, or install something from one of
these locations, you can either:
>
> - Configure [Actions setup
steps](https://gh.io/copilot/actions-setup-steps) to set up my
environment, which run before the firewall is enabled
> - Add the appropriate URLs or hosts to the custom allowlist in this
repository's [Copilot coding agent
settings](https://github.com/github/gh-aw-mcpg/settings/copilot/coding_agent)
(admins only)
>
> </details>
<!-- START COPILOT ORIGINAL PROMPT -->
<details>
<summary>Original prompt</summary>
----
*This section details on the original issue you should resolve*
<issue_title>[compliance] Compliance Gap: Volume Mount Mode Not Required
by Validation</issue_title>
<issue_description>## MCP Gateway Compliance Review - 2026-02-21
## Summary
Found 1 compliance issue (MUST violation) during daily review of commit
`e3e8080`.
## Recent Changes Reviewed
- Only commit `e3e8080` affected workflow/CI files; no changes to
`internal/`, `main.go`, or core gateway logic in the last 10 commits
- Full compliance audit of current codebase performed against MCP
Gateway Specification v1.8.0
---
## Critical Issues (MUST violations)
### 1. Volume Mount Mode Is Accepted As Optional But Spec Requires It
**Specification Section:** 4.1.5 Volume Mounts for Stdio Servers
**Deep Link:**
https://github.com/github/gh-aw/blob/main/docs/src/content/docs/reference/mcp-gateway.md#415-volume-mounts-for-stdio-servers
**Requirement:**
> "Volume mounts MUST use the format: `"host:container:mode"`"
>
> "Each mount string MUST conform to the 'host:container:mode' format"
>
> "The mode MUST be either 'ro' (read-only) or 'rw' (read-write)"
**Current State:**
In `internal/config/rules/rules.go:123-131`, the function is documented
and validated as accepting **either** the 2-part `"source:dest"` format
**or** the 3-part `"source:dest:mode"` format:
```go
// MountFormat validates a mount specification in the format "source:dest" or "source:dest:mode"
// ...
// - Mode (if provided) MUST be either "ro" (read-only) or "rw" (read-write)
func MountFormat(mount, jsonPath string, index int) *ValidationError {
parts := strings.Split(mount, ":")
if len(parts) < 2 || len(parts) > 3 { // accepts 2 OR 3 parts
...
}
...
// Validate mode if provided (mode treated as optional)
if mode != "" && mode != "ro" && mode != "rw" {
```
This behavior is tested and confirmed in multiple test files:
- `internal/config/rules/rules_test.go:178`: `"valid mount without
mode"` passes validation
- `internal/config/validation_test.go:313`: `"valid mount without mode"`
passes validation
- `internal/config/validation_string_patterns_test.go:139`: same
- `internal/config/validation_schema_test.go:395`: same
**Gap:**
The specification in Section 4.1.5 unambiguously requires the 3-part
`"host:container:mode"` format with mode being mandatory. The compliance
test **T-CFG-015** ("Valid volume mount format (host:container:mode)")
is defined in Section 10.1.1 and describes the required format with an
explicit mode component.
The implementation diverges from this by treating mode as optional,
accepting `"/host:/container"` where the spec requires
`"/host:/container:ro"` or `"/host:/container:rw"`.
**Security Impact:**
This gap has security implications: without a required mode, a user may
omit mode and rely on an undocumented default. Docker defaults to `rw`
for mounts without a mode, meaning read-write access may be granted when
read-only was intended. The spec explicitly recommends: _"Read-only
mounts ('ro') SHOULD be preferred when the server only needs to read
data."_ Forcing explicit mode declaration aligns with the principle of
least privilege.
**Severity:** Critical (MUST violation)
**File References:**
- `internal/config/rules/rules.go:123-128` — comment and function
signature treating mode as optional
- `internal/config/rules/rules.go:131` — condition `len(parts) < 2 ||
len(parts) > 3` accepts 2-part format
- `internal/config/rules/rules.go:178-180` — mode-only validation is
skipped if empty
- `internal/config/rules/rules_test.go:178-186` — "valid mount without
mode" test case
- `internal/config/validation_test.go:313-320` — "valid mount without
mode" test case
**Suggested Fix:**
1. Update `MountFormat` in `internal/config/rules/rules.go` to require
exactly 3 parts:
```go
// MountFormat validates a mount specification in the format "source:dest:mode"
// Per MCP Gateway specification v1.7.0 section 4.1.5:
// - Host path MUST be an absolute path
// - Container path MUST be an absolute path
// - Mode MUST be either "ro" (read-only) or "rw" (read-write)
func MountFormat(mount, jsonPath string, index int) *ValidationError {
parts := strings.Split(mount, ":")
if len(parts) != 3 { // require exactly 3 parts
return &ValidationError{
Field: "mounts",
Message: fmt.Sprintf("invalid mount format '%s' (expected 'source:dest:mode')", mount),
JSONPath: fmt.Sprintf("%s.mounts[%d]", jsonPath, index),
Suggestion: "Use format 'source:dest:mode' where mode is 'ro' (read-only) or 'rw' (read-write), e.g. '/host/path:/container/path:ro'",
}
}
...
```
2. Update the related test cases to remove "valid mount without mode"
cases and instead add tests that verify 2-part mounts are **rejected**.
3. Update the error message suggestion in `rules.go:136` to only show
the 3-part format.
---
## Compliance Status
| Section | Status |
|---------|--------|...
</details>
<!-- START COPILOT CODING AGENT SUFFIX -->
- Fixes #1224
<!-- START COPILOT CODING AGENT TIPS -->
---
💬 We'd love your input! Share your thoughts on Copilot coding agent in
our [2 minute survey](https://gh.io/copilot-coding-agent-survey).7 files changed
Lines changed: 22 additions & 24 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
16 | 16 | | |
17 | 17 | | |
18 | 18 | | |
19 | | - | |
| 19 | + | |
20 | 20 | | |
21 | 21 | | |
22 | 22 | | |
| |||
46 | 46 | | |
47 | 47 | | |
48 | 48 | | |
49 | | - | |
| 49 | + | |
50 | 50 | | |
51 | 51 | | |
52 | 52 | | |
| |||
380 | 380 | | |
381 | 381 | | |
382 | 382 | | |
383 | | - | |
| 383 | + | |
384 | 384 | | |
385 | 385 | | |
386 | 386 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
120 | 120 | | |
121 | 121 | | |
122 | 122 | | |
123 | | - | |
| 123 | + | |
124 | 124 | | |
125 | | - | |
| 125 | + | |
126 | 126 | | |
127 | 127 | | |
128 | | - | |
| 128 | + | |
129 | 129 | | |
130 | 130 | | |
131 | 131 | | |
132 | | - | |
| 132 | + | |
133 | 133 | | |
134 | 134 | | |
135 | 135 | | |
136 | | - | |
| 136 | + | |
137 | 137 | | |
138 | | - | |
| 138 | + | |
139 | 139 | | |
140 | 140 | | |
141 | 141 | | |
142 | 142 | | |
143 | 143 | | |
144 | | - | |
145 | | - | |
146 | | - | |
147 | | - | |
| 144 | + | |
148 | 145 | | |
149 | 146 | | |
150 | 147 | | |
| |||
186 | 183 | | |
187 | 184 | | |
188 | 185 | | |
189 | | - | |
190 | | - | |
| 186 | + | |
| 187 | + | |
191 | 188 | | |
192 | 189 | | |
193 | 190 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
175 | 175 | | |
176 | 176 | | |
177 | 177 | | |
178 | | - | |
| 178 | + | |
179 | 179 | | |
180 | 180 | | |
181 | 181 | | |
182 | | - | |
| 182 | + | |
| 183 | + | |
183 | 184 | | |
184 | 185 | | |
185 | 186 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
20 | 20 | | |
21 | 21 | | |
22 | 22 | | |
23 | | - | |
| 23 | + | |
24 | 24 | | |
25 | 25 | | |
26 | 26 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
392 | 392 | | |
393 | 393 | | |
394 | 394 | | |
395 | | - | |
| 395 | + | |
396 | 396 | | |
397 | 397 | | |
398 | 398 | | |
| |||
402 | 402 | | |
403 | 403 | | |
404 | 404 | | |
405 | | - | |
| 405 | + | |
406 | 406 | | |
407 | 407 | | |
408 | 408 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
136 | 136 | | |
137 | 137 | | |
138 | 138 | | |
139 | | - | |
| 139 | + | |
140 | 140 | | |
141 | | - | |
| 141 | + | |
142 | 142 | | |
143 | 143 | | |
144 | 144 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
310 | 310 | | |
311 | 311 | | |
312 | 312 | | |
313 | | - | |
| 313 | + | |
314 | 314 | | |
315 | 315 | | |
316 | 316 | | |
317 | 317 | | |
318 | 318 | | |
319 | | - | |
| 319 | + | |
320 | 320 | | |
321 | 321 | | |
322 | 322 | | |
| |||
0 commit comments