diff --git a/.changeset/patch-enforce-mcp-gateway-allowlist.md b/.changeset/patch-enforce-mcp-gateway-allowlist.md new file mode 100644 index 00000000000..6f90c77f74f --- /dev/null +++ b/.changeset/patch-enforce-mcp-gateway-allowlist.md @@ -0,0 +1,5 @@ +--- +"gh-aw": patch +--- + +Enforce MCP gateway tool allowlists at the gateway layer and harden permissions on credential-bearing gateway config files. diff --git a/actions/setup/sh/convert_gateway_config_claude.sh b/actions/setup/sh/convert_gateway_config_claude.sh index 639147cafe1..926d47065ee 100755 --- a/actions/setup/sh/convert_gateway_config_claude.sh +++ b/actions/setup/sh/convert_gateway_config_claude.sh @@ -5,6 +5,12 @@ set -e +# Restrict default file creation mode to owner-only (rw-------) for all new files. +# This prevents the race window between file creation via output redirection and +# a subsequent chmod, which would leave credential-bearing files world-readable +# (mode 0644) with a typical umask of 022. +umask 077 + # Required environment variables: # - MCP_GATEWAY_OUTPUT: Path to gateway output configuration file # - MCP_GATEWAY_DOMAIN: Domain to use for MCP server URLs (e.g., host.docker.internal) @@ -63,7 +69,8 @@ echo "Target domain: $MCP_GATEWAY_DOMAIN:$MCP_GATEWAY_PORT" # # The main differences: # 1. Claude uses "type": "http" for HTTP-based MCP servers -# 2. The "tools" field is removed as it's Copilot-specific +# 2. The "tools" field is preserved from the gateway config to enforce the tool allowlist +# at the gateway layer (not removed, unlike older versions that treated it as Copilot-specific) # 3. URLs must use the correct domain (host.docker.internal) for container access # Build the correct URL prefix using the configured domain and port @@ -73,13 +80,18 @@ jq --arg urlPrefix "$URL_PREFIX" ' .mcpServers |= with_entries( .value |= ( (.type = "http") | - (del(.tools)) | # Fix the URL to use the correct domain .url |= (. | sub("^http://[^/]+/mcp/"; $urlPrefix + "/mcp/")) ) ) ' "$MCP_GATEWAY_OUTPUT" > /tmp/gh-aw/mcp-config/mcp-servers.json +# Restrict permissions so only the runner process owner can read this file. +# mcp-servers.json contains the bearer token for the MCP gateway; an attacker +# who reads it could bypass the --allowed-tools constraint by issuing raw +# JSON-RPC calls directly to the gateway. +chmod 600 /tmp/gh-aw/mcp-config/mcp-servers.json + echo "Claude configuration written to /tmp/gh-aw/mcp-config/mcp-servers.json" echo "" echo "Converted configuration:" diff --git a/actions/setup/sh/convert_gateway_config_codex.sh b/actions/setup/sh/convert_gateway_config_codex.sh index b89ac2fa2a2..f172c0b206f 100755 --- a/actions/setup/sh/convert_gateway_config_codex.sh +++ b/actions/setup/sh/convert_gateway_config_codex.sh @@ -5,6 +5,12 @@ set -e +# Restrict default file creation mode to owner-only (rw-------) for all new files. +# This prevents the race window between file creation via output redirection and +# a subsequent chmod, which would leave credential-bearing files world-readable +# (mode 0644) with a typical umask of 022. +umask 077 + # Required environment variables: # - MCP_GATEWAY_OUTPUT: Path to gateway output configuration file # - MCP_GATEWAY_DOMAIN: Domain to use for MCP server URLs (e.g., host.docker.internal) @@ -85,6 +91,11 @@ jq -r --arg urlPrefix "$URL_PREFIX" ' "http_headers = { Authorization = \"\(.value.headers.Authorization)\" }\n" ' "$MCP_GATEWAY_OUTPUT" >> /tmp/gh-aw/mcp-config/config.toml +# Restrict permissions so only the runner process owner can read this file. +# config.toml contains the bearer token for the MCP gateway; an attacker +# who reads it could issue raw JSON-RPC calls directly to the gateway. +chmod 600 /tmp/gh-aw/mcp-config/config.toml + echo "Codex configuration written to /tmp/gh-aw/mcp-config/config.toml" echo "" echo "Converted configuration:" diff --git a/actions/setup/sh/convert_gateway_config_copilot.sh b/actions/setup/sh/convert_gateway_config_copilot.sh index 14f471bbd69..5544165dae9 100755 --- a/actions/setup/sh/convert_gateway_config_copilot.sh +++ b/actions/setup/sh/convert_gateway_config_copilot.sh @@ -5,6 +5,12 @@ set -e +# Restrict default file creation mode to owner-only (rw-------) for all new files. +# This prevents the race window between file creation via output redirection and +# a subsequent chmod, which would leave credential-bearing files world-readable +# (mode 0644) with a typical umask of 022. +umask 077 + # Required environment variables: # - MCP_GATEWAY_OUTPUT: Path to gateway output configuration file # - MCP_GATEWAY_DOMAIN: Domain to use for MCP server URLs (e.g., host.docker.internal) @@ -82,6 +88,12 @@ jq --arg urlPrefix "$URL_PREFIX" ' ) ' "$MCP_GATEWAY_OUTPUT" > /home/runner/.copilot/mcp-config.json +# Restrict permissions so only the runner process owner can read this file. +# mcp-config.json contains the bearer token for the MCP gateway; an attacker +# who reads it could bypass the --allowed-tools constraint by issuing raw +# JSON-RPC calls directly to the gateway. +chmod 600 /home/runner/.copilot/mcp-config.json + echo "Copilot configuration written to /home/runner/.copilot/mcp-config.json" echo "" echo "Converted configuration:" diff --git a/actions/setup/sh/convert_gateway_config_gemini.sh b/actions/setup/sh/convert_gateway_config_gemini.sh index 4b2b14d5711..353060b9c24 100644 --- a/actions/setup/sh/convert_gateway_config_gemini.sh +++ b/actions/setup/sh/convert_gateway_config_gemini.sh @@ -11,6 +11,12 @@ set -e +# Restrict default file creation mode to owner-only (rw-------) for all new files. +# This prevents the race window between file creation via output redirection and +# a subsequent chmod, which would leave credential-bearing files world-readable +# (mode 0644) with a typical umask of 022. +umask 077 + # Required environment variables: # - MCP_GATEWAY_OUTPUT: Path to gateway output configuration file # - MCP_GATEWAY_DOMAIN: Domain to use for MCP server URLs (e.g., host.docker.internal) @@ -74,7 +80,8 @@ echo "Target domain: $MCP_GATEWAY_DOMAIN:$MCP_GATEWAY_PORT" # # The main differences: # 1. Remove "type" field (Gemini uses transport auto-detection from url/httpUrl) -# 2. Remove "tools" field (Copilot-specific) +# 2. The "tools" field is preserved from the gateway config to enforce the tool allowlist +# at the gateway layer (not removed, unlike older versions that treated it as Copilot-specific) # 3. URLs must use the correct domain (host.docker.internal) for container access # Build the correct URL prefix using the configured domain and port @@ -90,7 +97,6 @@ jq --arg urlPrefix "$URL_PREFIX" ' .mcpServers |= with_entries( .value |= ( (del(.type)) | - (del(.tools)) | # Fix the URL to use the correct domain .url |= (. | sub("^http://[^/]+/mcp/"; $urlPrefix + "/mcp/")) ) @@ -99,6 +105,12 @@ jq --arg urlPrefix "$URL_PREFIX" ' .context.includeDirectories = ["/tmp/"] ' "$MCP_GATEWAY_OUTPUT" > "$GEMINI_SETTINGS_FILE" +# Restrict permissions so only the runner process owner can read this file. +# settings.json contains the bearer token for the MCP gateway; an attacker +# who reads it could bypass the --allowed-tools constraint by issuing raw +# JSON-RPC calls directly to the gateway. +chmod 600 "$GEMINI_SETTINGS_FILE" + echo "Gemini configuration written to $GEMINI_SETTINGS_FILE" echo "" echo "Converted configuration:" diff --git a/actions/setup/sh/start_mcp_gateway.sh b/actions/setup/sh/start_mcp_gateway.sh index 1da8cdc9242..979fb86aa4d 100755 --- a/actions/setup/sh/start_mcp_gateway.sh +++ b/actions/setup/sh/start_mcp_gateway.sh @@ -8,6 +8,12 @@ set -e +# Restrict default file creation mode to owner-only (rw-------) for all new files. +# This prevents the race window between file creation via output redirection and +# a subsequent chmod, which would leave credential-bearing files world-readable +# (mode 0644) with a typical umask of 022. +umask 077 + # Timing helper functions print_timing() { local start_time=$1 @@ -29,7 +35,29 @@ fi # Create logs directory for gateway mkdir -p /tmp/gh-aw/mcp-logs + +# Guard against symlink attacks on the predictable /tmp/gh-aw/mcp-config path. +# An attacker who can create files in /tmp could pre-create /tmp/gh-aw or +# /tmp/gh-aw/mcp-config as symlinks and redirect our credential writes to an +# arbitrary location. Check both path components before and after creation. +if [ -L /tmp/gh-aw ]; then + echo "ERROR: /tmp/gh-aw is a symlink — possible symlink attack, aborting" + exit 1 +fi +if [ -L /tmp/gh-aw/mcp-config ]; then + echo "ERROR: /tmp/gh-aw/mcp-config is a symlink — possible symlink attack, aborting" + exit 1 +fi mkdir -p /tmp/gh-aw/mcp-config +# Post-creation check: verify neither path was replaced with a symlink after mkdir +# (mitigates the TOCTOU window between the pre-check and mkdir). +if [ -L /tmp/gh-aw ] || [ -L /tmp/gh-aw/mcp-config ]; then + echo "ERROR: /tmp/gh-aw/mcp-config was replaced with a symlink — possible symlink attack, aborting" + exit 1 +fi +# Restrict directory permissions so only the runner process owner can read config files +# (which contain bearer tokens and API keys) +chmod 700 /tmp/gh-aw/mcp-config # Validate container syntax first (before accessing files) # Container should be a valid docker command starting with "docker run" @@ -310,6 +338,9 @@ if [ ! -s /tmp/gh-aw/mcp-config/gateway-output.json ]; then exit 1 fi +# Restrict gateway output file permissions - it contains the bearer token / API key +chmod 600 /tmp/gh-aw/mcp-config/gateway-output.json + # Check if output contains an error payload instead of valid configuration # Per MCP Gateway Specification v1.0.0 section 9.1, errors are written to stdout as error payloads if jq -e '.error' /tmp/gh-aw/mcp-config/gateway-output.json >/dev/null 2>&1; then