From d4dd7d7d5c08f9a8306386ac35452ecf83d44c3e Mon Sep 17 00:00:00 2001 From: "Jiaxiao (mossaka) Zhou" Date: Wed, 25 Mar 2026 22:54:31 +0000 Subject: [PATCH] fix: allow host service ports for GitHub Actions services containers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds --allow-host-service-ports CLI flag that allows TCP connections to the host gateway IP on ports normally blocked as dangerous (e.g., PostgreSQL 5432, Redis 6379). Designed for GitHub Actions services: containers that publish to the host — the agent can reach these services but still cannot reach databases on the internet. Changes: - New CLI flag with strict port validation (single numeric ports 1-65535) - Host-level FW_WRAPPER ACCEPT rules for service ports on gateway IPs - Container-level iptables ACCEPT rules for service ports - Auto-enables --enable-host-access with explicit warning about 80/443 - Safety net in docker-manager: enables host access if not already set - SSH risk documented in help text - Bash validation uses single-port regex (no ranges), aligned with TS - Integration tests with actual dangerous ports (5432, 6379) Security: Service port traffic is restricted to host gateway IPs only (172.17.0.1 and 172.30.0.1). Dangerous port restrictions are bypassed intentionally since traffic cannot reach the internet. Both host-level (FW_WRAPPER) and container-level iptables enforce the restriction. Closes gh-aw#22939 Co-Authored-By: Claude Opus 4.6 (1M context) --- containers/agent/setup-iptables.sh | 48 ++++++ docs-site/package-lock.json | 115 ++++++------- package-lock.json | 18 +- src/cli-workflow.test.ts | 29 ++++ src/cli-workflow.ts | 2 +- src/cli.test.ts | 180 +++++++++++++++++++- src/cli.ts | 89 ++++++++++ src/docker-manager.ts | 12 ++ src/host-iptables.test.ts | 80 +++++++++ src/host-iptables.ts | 16 ++ src/types.ts | 25 +++ tests/fixtures/awf-runner.ts | 11 ++ tests/integration/host-tcp-services.test.ts | 170 ++++++++++++++++++ 13 files changed, 723 insertions(+), 72 deletions(-) create mode 100644 tests/integration/host-tcp-services.test.ts diff --git a/containers/agent/setup-iptables.sh b/containers/agent/setup-iptables.sh index e7db210f2..d8dbe55ee 100644 --- a/containers/agent/setup-iptables.sh +++ b/containers/agent/setup-iptables.sh @@ -236,6 +236,54 @@ if [ -n "$AWF_ENABLE_HOST_ACCESS" ]; then fi fi +# Allow host service ports (--allow-host-service-ports) ONLY to host gateway +# These ports bypass DANGEROUS_PORTS restrictions because they are restricted +# to the host gateway IP only (for GitHub Actions services containers). +# Must be applied BEFORE dangerous port RETURN rules so traffic to host gateway +# on these ports is accepted, not dropped. +if [ -n "$AWF_HOST_SERVICE_PORTS" ] && [ -n "$AWF_ENABLE_HOST_ACCESS" ]; then + # Parse port list once, before resolving gateway IPs, so both blocks can use it + IFS=',' read -ra HSP_PORTS <<< "$AWF_HOST_SERVICE_PORTS" + + # Resolve host gateway IP + HSP_HOST_GW_IP=$(getent hosts host.docker.internal 2>/dev/null | awk 'NR==1 { print $1 }') + HSP_NET_GW_IP=$(route -n 2>/dev/null | awk '/^0\.0\.0\.0/ { print $2; exit }') + + if [ -n "$HSP_HOST_GW_IP" ] && is_valid_ipv4 "$HSP_HOST_GW_IP"; then + echo "[iptables] Allowing host service ports to host gateway ($HSP_HOST_GW_IP): $AWF_HOST_SERVICE_PORTS" + for port in "${HSP_PORTS[@]}"; do + port=$(echo "$port" | xargs) + if ! [[ "$port" =~ ^[1-9][0-9]{0,4}$ ]] || [ "$port" -lt 1 ] || [ "$port" -gt 65535 ]; then + echo "[iptables] WARNING: Skipping invalid service port: $port" + continue + fi + if [ -n "$port" ]; then + echo "[iptables] Allow host service port $port to $HSP_HOST_GW_IP" + # FILTER: allow traffic to host gateway on this port + # (NAT bypass is already handled by the blanket RETURN rule in the host access block above) + iptables -A OUTPUT -p tcp -d "$HSP_HOST_GW_IP" --dport "$port" -j ACCEPT + fi + done + fi + + # Also allow to network gateway (same as the host access block does) + if [ -n "$HSP_NET_GW_IP" ] && is_valid_ipv4 "$HSP_NET_GW_IP" && [ "$HSP_NET_GW_IP" != "$HSP_HOST_GW_IP" ]; then + echo "[iptables] Allowing host service ports to network gateway ($HSP_NET_GW_IP): $AWF_HOST_SERVICE_PORTS" + for port in "${HSP_PORTS[@]}"; do + port=$(echo "$port" | xargs) + if ! [[ "$port" =~ ^[1-9][0-9]{0,4}$ ]] || [ "$port" -lt 1 ] || [ "$port" -gt 65535 ]; then + echo "[iptables] WARNING: Skipping invalid service port: $port" + continue + fi + if [ -n "$port" ]; then + # FILTER: allow traffic to network gateway on this port + # (NAT bypass is already handled by the blanket RETURN rule in the host access block above) + iptables -A OUTPUT -p tcp -d "$HSP_NET_GW_IP" --dport "$port" -j ACCEPT + fi + done + fi +fi + # Block dangerous ports at NAT level (defense-in-depth with Squid ACL filtering) # These ports are explicitly blocked to prevent access to sensitive services # even if Squid ACL filtering fails. The ports RETURN from NAT (not redirected) diff --git a/docs-site/package-lock.json b/docs-site/package-lock.json index ea29d837e..05f6bab3b 100644 --- a/docs-site/package-lock.json +++ b/docs-site/package-lock.json @@ -60,9 +60,9 @@ "license": "MIT" }, "node_modules/@astrojs/language-server": { - "version": "2.16.4", - "resolved": "https://registry.npmjs.org/@astrojs/language-server/-/language-server-2.16.4.tgz", - "integrity": "sha512-42oqz9uX+hU1/rFniJvtYW9FbfZJ6syM2fYZFi7Ub71/kOvF1GSeMS8sA3Ogs3iOeNUWefk/ImwBiiHeNmJfSA==", + "version": "2.16.6", + "resolved": "https://registry.npmjs.org/@astrojs/language-server/-/language-server-2.16.6.tgz", + "integrity": "sha512-N990lu+HSFiG57owR0XBkr02BYMgiLCshLf+4QG4v6jjSWkBeQGnzqi+E1L08xFPPJ7eEeXnxPXGLaVv5pa4Ug==", "license": "MIT", "dependencies": { "@astrojs/compiler": "^2.13.1", @@ -74,14 +74,14 @@ "@volar/language-service": "~2.4.28", "muggle-string": "^0.4.1", "tinyglobby": "^0.2.15", - "volar-service-css": "0.0.68", - "volar-service-emmet": "0.0.68", - "volar-service-html": "0.0.68", - "volar-service-prettier": "0.0.68", - "volar-service-typescript": "0.0.68", - "volar-service-typescript-twoslash-queries": "0.0.68", - "volar-service-yaml": "0.0.68", - "vscode-html-languageservice": "^5.6.1", + "volar-service-css": "0.0.70", + "volar-service-emmet": "0.0.70", + "volar-service-html": "0.0.70", + "volar-service-prettier": "0.0.70", + "volar-service-typescript": "0.0.70", + "volar-service-typescript-twoslash-queries": "0.0.70", + "volar-service-yaml": "0.0.70", + "vscode-html-languageservice": "^5.6.2", "vscode-uri": "^3.1.0" }, "bin": { @@ -2540,9 +2540,9 @@ } }, "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -3829,9 +3829,9 @@ } }, "node_modules/devalue": { - "version": "5.6.3", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.3.tgz", - "integrity": "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==", + "version": "5.6.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz", + "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==", "license": "MIT" }, "node_modules/devlop": { @@ -4323,9 +4323,9 @@ "license": "ISC" }, "node_modules/h3": { - "version": "1.15.6", - "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.6.tgz", - "integrity": "sha512-oi15ESLW5LRthZ+qPCi5GNasY/gvynSKUQxgiovrY63bPAtG59wtM+LSrlcwvOHAXzGrXVLnI97brbkdPF9WoQ==", + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.10.tgz", + "integrity": "sha512-YzJeWSkDZxAhvmp8dexjRK5hxziRO7I9m0N53WhvYL5NiWfkUkzssVzY9jvGu0HBoLFW6+duYmNSn6MaZBCCtg==", "license": "MIT", "dependencies": { "cookie-es": "^1.2.2", @@ -5019,12 +5019,6 @@ "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", "license": "MIT" }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "license": "MIT" - }, "node_modules/lodash-es": { "version": "4.17.23", "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", @@ -6490,9 +6484,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { "node": ">=12" @@ -7236,9 +7230,9 @@ } }, "node_modules/smol-toml": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.0.tgz", - "integrity": "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz", + "integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==", "license": "BSD-3-Clause", "engines": { "node": ">= 18" @@ -8439,9 +8433,9 @@ } }, "node_modules/volar-service-css": { - "version": "0.0.68", - "resolved": "https://registry.npmjs.org/volar-service-css/-/volar-service-css-0.0.68.tgz", - "integrity": "sha512-lJSMh6f3QzZ1tdLOZOzovLX0xzAadPhx8EKwraDLPxBndLCYfoTvnNuiFFV8FARrpAlW5C0WkH+TstPaCxr00Q==", + "version": "0.0.70", + "resolved": "https://registry.npmjs.org/volar-service-css/-/volar-service-css-0.0.70.tgz", + "integrity": "sha512-K1qyOvBpE3rzdAv3e4/6Rv5yizrYPy5R/ne3IWCAzLBuMO4qBMV3kSqWzj6KUVe6S0AnN6wxF7cRkiaKfYMYJw==", "license": "MIT", "dependencies": { "vscode-css-languageservice": "^6.3.0", @@ -8458,9 +8452,9 @@ } }, "node_modules/volar-service-emmet": { - "version": "0.0.68", - "resolved": "https://registry.npmjs.org/volar-service-emmet/-/volar-service-emmet-0.0.68.tgz", - "integrity": "sha512-nHvixrRQ83EzkQ4G/jFxu9Y4eSsXS/X2cltEPDM+K9qZmIv+Ey1w0tg1+6caSe8TU5Hgw4oSTwNMf/6cQb3LzQ==", + "version": "0.0.70", + "resolved": "https://registry.npmjs.org/volar-service-emmet/-/volar-service-emmet-0.0.70.tgz", + "integrity": "sha512-xi5bC4m/VyE3zy/n2CXspKeDZs3qA41tHLTw275/7dNWM/RqE2z3BnDICQybHIVp/6G1iOQj5c1qXMgQC08TNg==", "license": "MIT", "dependencies": { "@emmetio/css-parser": "^0.4.1", @@ -8478,9 +8472,9 @@ } }, "node_modules/volar-service-html": { - "version": "0.0.68", - "resolved": "https://registry.npmjs.org/volar-service-html/-/volar-service-html-0.0.68.tgz", - "integrity": "sha512-fru9gsLJxy33xAltXOh4TEdi312HP80hpuKhpYQD4O5hDnkNPEBdcQkpB+gcX0oK0VxRv1UOzcGQEUzWCVHLfA==", + "version": "0.0.70", + "resolved": "https://registry.npmjs.org/volar-service-html/-/volar-service-html-0.0.70.tgz", + "integrity": "sha512-eR6vCgMdmYAo4n+gcT7DSyBQbwB8S3HZZvSagTf0sxNaD4WppMCFfpqWnkrlGStPKMZvMiejRRVmqsX9dYcTvQ==", "license": "MIT", "dependencies": { "vscode-html-languageservice": "^5.3.0", @@ -8497,9 +8491,9 @@ } }, "node_modules/volar-service-prettier": { - "version": "0.0.68", - "resolved": "https://registry.npmjs.org/volar-service-prettier/-/volar-service-prettier-0.0.68.tgz", - "integrity": "sha512-grUmWHkHlebMOd6V8vXs2eNQUw/bJGJMjekh/EPf/p2ZNTK0Uyz7hoBRngcvGfJHMsSXZH8w/dZTForIW/4ihw==", + "version": "0.0.70", + "resolved": "https://registry.npmjs.org/volar-service-prettier/-/volar-service-prettier-0.0.70.tgz", + "integrity": "sha512-Z6BCFSpGVCd8BPAsZ785Kce1BGlWd5ODqmqZGVuB14MJvrR4+CYz6cDy4F+igmE1gMifqfvMhdgT8Aud4M5ngg==", "license": "MIT", "dependencies": { "vscode-uri": "^3.0.8" @@ -8518,9 +8512,9 @@ } }, "node_modules/volar-service-typescript": { - "version": "0.0.68", - "resolved": "https://registry.npmjs.org/volar-service-typescript/-/volar-service-typescript-0.0.68.tgz", - "integrity": "sha512-z7B/7CnJ0+TWWFp/gh2r5/QwMObHNDiQiv4C9pTBNI2Wxuwymd4bjEORzrJ/hJ5Yd5+OzeYK+nFCKevoGEEeKw==", + "version": "0.0.70", + "resolved": "https://registry.npmjs.org/volar-service-typescript/-/volar-service-typescript-0.0.70.tgz", + "integrity": "sha512-l46Bx4cokkUedTd74ojO5H/zqHZJ8SUuyZ0IB8JN4jfRqUM3bQFBHoOwlZCyZmOeO0A3RQNkMnFclxO4c++gsg==", "license": "MIT", "dependencies": { "path-browserify": "^1.0.1", @@ -8540,9 +8534,9 @@ } }, "node_modules/volar-service-typescript-twoslash-queries": { - "version": "0.0.68", - "resolved": "https://registry.npmjs.org/volar-service-typescript-twoslash-queries/-/volar-service-typescript-twoslash-queries-0.0.68.tgz", - "integrity": "sha512-NugzXcM0iwuZFLCJg47vI93su5YhTIweQuLmZxvz5ZPTaman16JCvmDZexx2rd5T/75SNuvvZmrTOTNYUsfe5w==", + "version": "0.0.70", + "resolved": "https://registry.npmjs.org/volar-service-typescript-twoslash-queries/-/volar-service-typescript-twoslash-queries-0.0.70.tgz", + "integrity": "sha512-IdD13Z9N2Bu8EM6CM0fDV1E69olEYGHDU25X51YXmq8Y0CmJ2LNj6gOiBJgpS5JGUqFzECVhMNBW7R0sPdRTMQ==", "license": "MIT", "dependencies": { "vscode-uri": "^3.0.8" @@ -8557,13 +8551,13 @@ } }, "node_modules/volar-service-yaml": { - "version": "0.0.68", - "resolved": "https://registry.npmjs.org/volar-service-yaml/-/volar-service-yaml-0.0.68.tgz", - "integrity": "sha512-84XgE02LV0OvTcwfqhcSwVg4of3MLNUWPMArO6Aj8YXqyEVnPu8xTEMY2btKSq37mVAPuaEVASI4e3ptObmqcA==", + "version": "0.0.70", + "resolved": "https://registry.npmjs.org/volar-service-yaml/-/volar-service-yaml-0.0.70.tgz", + "integrity": "sha512-0c8bXDBeoATF9F6iPIlOuYTuZAC4c+yi0siQo920u7eiBJk8oQmUmg9cDUbR4+Gl++bvGP4plj3fErbJuPqdcQ==", "license": "MIT", "dependencies": { "vscode-uri": "^3.0.8", - "yaml-language-server": "~1.19.2" + "yaml-language-server": "~1.20.0" }, "peerDependencies": { "@volar/language-service": "~2.4.0" @@ -8742,9 +8736,9 @@ } }, "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "license": "ISC", "bin": { "yaml": "bin.mjs" @@ -8757,15 +8751,14 @@ } }, "node_modules/yaml-language-server": { - "version": "1.19.2", - "resolved": "https://registry.npmjs.org/yaml-language-server/-/yaml-language-server-1.19.2.tgz", - "integrity": "sha512-9F3myNmJzUN/679jycdMxqtydPSDRAarSj3wPiF7pchEPnO9Dg07Oc+gIYLqXR4L+g+FSEVXXv2+mr54StLFOg==", + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/yaml-language-server/-/yaml-language-server-1.20.0.tgz", + "integrity": "sha512-qhjK/bzSRZ6HtTvgeFvjNPJGWdZ0+x5NREV/9XZWFjIGezew2b4r5JPy66IfOhd5OA7KeFwk1JfmEbnTvev0cA==", "license": "MIT", "dependencies": { "@vscode/l10n": "^0.0.18", "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", - "lodash": "4.17.21", "prettier": "^3.5.0", "request-light": "^0.5.7", "vscode-json-languageservice": "4.1.8", diff --git a/package-lock.json b/package-lock.json index 1163baf3b..d34952f60 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3871,9 +3871,9 @@ } }, "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -7537,9 +7537,9 @@ } }, "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -7875,9 +7875,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { diff --git a/src/cli-workflow.test.ts b/src/cli-workflow.test.ts index 43206c7a8..145dd882d 100644 --- a/src/cli-workflow.test.ts +++ b/src/cli-workflow.test.ts @@ -134,6 +134,35 @@ describe('runMainWorkflow', () => { ); }); + it('passes allowHostServicePorts in hostAccess config when set', async () => { + const configWithServicePorts: WrapperConfig = { + ...baseConfig, + enableHostAccess: true, + allowHostPorts: '3000', + allowHostServicePorts: '5432,6379', + }; + const dependencies: WorkflowDependencies = { + ensureFirewallNetwork: jest.fn().mockResolvedValue({ squidIp: '172.30.0.10', proxyIp: '172.30.0.30' }), + setupHostIptables: jest.fn().mockResolvedValue(undefined), + writeConfigs: jest.fn().mockResolvedValue(undefined), + startContainers: jest.fn().mockResolvedValue(undefined), + runAgentCommand: jest.fn().mockResolvedValue({ exitCode: 0 }), + }; + const performCleanup = jest.fn().mockResolvedValue(undefined); + const logger = createLogger(); + + await runMainWorkflow(configWithServicePorts, dependencies, { logger, performCleanup }); + + const expectedHostAccess: HostAccessConfig = { + enabled: true, + allowHostPorts: '3000', + allowHostServicePorts: '5432,6379', + }; + expect(dependencies.setupHostIptables).toHaveBeenCalledWith( + '172.30.0.10', 3128, ['8.8.8.8', '8.8.4.4'], undefined, undefined, expectedHostAccess + ); + }); + it('passes undefined hostAccess when enableHostAccess is not set', async () => { const dependencies: WorkflowDependencies = { ensureFirewallNetwork: jest.fn().mockResolvedValue({ squidIp: '172.30.0.10', proxyIp: '172.30.0.30' }), diff --git a/src/cli-workflow.ts b/src/cli-workflow.ts index 81b310798..cce4b7a87 100644 --- a/src/cli-workflow.ts +++ b/src/cli-workflow.ts @@ -51,7 +51,7 @@ export async function runMainWorkflow( // When DoH is enabled, the DoH proxy needs direct HTTPS access to the resolver const dohProxyIp = config.dnsOverHttps ? '172.30.0.40' : undefined; const hostAccess: HostAccessConfig | undefined = config.enableHostAccess - ? { enabled: true, allowHostPorts: config.allowHostPorts } + ? { enabled: true, allowHostPorts: config.allowHostPorts, allowHostServicePorts: config.allowHostServicePorts } : undefined; await dependencies.setupHostIptables(networkConfig.squidIp, 3128, dnsServers, apiProxyIp, dohProxyIp, hostAccess); onHostIptablesSetup?.(); diff --git a/src/cli.test.ts b/src/cli.test.ts index 5751f37c3..8b1349ca0 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1,5 +1,5 @@ import { Command } from 'commander'; -import { parseEnvironmentVariables, parseDomains, parseDomainsFile, escapeShellArg, joinShellArgs, parseVolumeMounts, isValidIPv4, isValidIPv6, parseDnsServers, parseDnsOverHttps, validateAgentImage, isAgentImagePreset, AGENT_IMAGE_PRESETS, processAgentImageOption, processLocalhostKeyword, validateSkipPullWithBuildLocal, validateAllowHostPorts, parseMemoryLimit, validateFormat, validateApiProxyConfig, buildRateLimitConfig, validateRateLimitFlags, hasRateLimitOptions, collectRulesetFile, validateApiTargetInAllowedDomains, DEFAULT_OPENAI_API_TARGET, DEFAULT_ANTHROPIC_API_TARGET, DEFAULT_COPILOT_API_TARGET, emitApiProxyTargetWarnings, formatItem, program, parseAgentTimeout, applyAgentTimeout, handlePredownloadAction, resolveApiTargetsToAllowedDomains, extractGhesDomainsFromEngineApiTarget, extractGhecDomainsFromServerUrl } from './cli'; +import { parseEnvironmentVariables, parseDomains, parseDomainsFile, escapeShellArg, joinShellArgs, parseVolumeMounts, isValidIPv4, isValidIPv6, parseDnsServers, parseDnsOverHttps, validateAgentImage, isAgentImagePreset, AGENT_IMAGE_PRESETS, processAgentImageOption, processLocalhostKeyword, validateSkipPullWithBuildLocal, validateAllowHostPorts, validateAllowHostServicePorts, applyHostServicePortsConfig, parseMemoryLimit, validateFormat, validateApiProxyConfig, buildRateLimitConfig, validateRateLimitFlags, hasRateLimitOptions, collectRulesetFile, validateApiTargetInAllowedDomains, DEFAULT_OPENAI_API_TARGET, DEFAULT_ANTHROPIC_API_TARGET, DEFAULT_COPILOT_API_TARGET, emitApiProxyTargetWarnings, formatItem, program, parseAgentTimeout, applyAgentTimeout, handlePredownloadAction, resolveApiTargetsToAllowedDomains, extractGhesDomainsFromEngineApiTarget, extractGhecDomainsFromServerUrl } from './cli'; import { redactSecrets } from './redact-secrets'; import * as fs from 'fs'; import * as path from 'path'; @@ -1580,6 +1580,184 @@ describe('cli', () => { }); }); + describe('validateAllowHostServicePorts', () => { + it('should pass when no service ports are provided', () => { + const result = validateAllowHostServicePorts(undefined, undefined); + expect(result.valid).toBe(true); + expect(result.autoEnableHostAccess).toBeUndefined(); + }); + + it('should pass for valid single port', () => { + const result = validateAllowHostServicePorts('5432', undefined); + expect(result.valid).toBe(true); + }); + + it('should pass for valid multiple ports', () => { + const result = validateAllowHostServicePorts('5432,6379,3306', undefined); + expect(result.valid).toBe(true); + }); + + it('should auto-enable host access when not already enabled', () => { + const result = validateAllowHostServicePorts('5432', undefined); + expect(result.valid).toBe(true); + expect(result.autoEnableHostAccess).toBe(true); + }); + + it('should auto-enable host access when enableHostAccess is false', () => { + const result = validateAllowHostServicePorts('5432', false); + expect(result.valid).toBe(true); + expect(result.autoEnableHostAccess).toBe(true); + }); + + it('should not auto-enable host access when already enabled', () => { + const result = validateAllowHostServicePorts('5432', true); + expect(result.valid).toBe(true); + expect(result.autoEnableHostAccess).toBe(false); + }); + + it('should fail for non-numeric port', () => { + const result = validateAllowHostServicePorts('abc', undefined); + expect(result.valid).toBe(false); + expect(result.error).toContain('Invalid port'); + expect(result.error).toContain('Must be a numeric value'); + }); + + it('should fail for port with letters mixed in', () => { + const result = validateAllowHostServicePorts('54a32', undefined); + expect(result.valid).toBe(false); + expect(result.error).toContain('Must be a numeric value'); + }); + + it('should fail for port 0', () => { + const result = validateAllowHostServicePorts('0', undefined); + expect(result.valid).toBe(false); + expect(result.error).toContain('Must be a number between 1 and 65535'); + }); + + it('should fail for port above 65535', () => { + const result = validateAllowHostServicePorts('65536', undefined); + expect(result.valid).toBe(false); + expect(result.error).toContain('Must be a number between 1 and 65535'); + }); + + it('should fail if any port in comma-separated list is invalid', () => { + const result = validateAllowHostServicePorts('5432,abc,6379', undefined); + expect(result.valid).toBe(false); + expect(result.error).toContain('abc'); + }); + + it('should allow dangerous ports (by design, for host-local services)', () => { + // Ports like 22 (SSH), 25 (SMTP), 5432 (Postgres), 6379 (Redis) are allowed + // because they are restricted to host gateway only + const result = validateAllowHostServicePorts('22,25,5432,6379,27017', undefined); + expect(result.valid).toBe(true); + }); + + it('should handle ports with whitespace around them', () => { + const result = validateAllowHostServicePorts(' 5432 , 6379 ', undefined); + expect(result.valid).toBe(true); + }); + + it('should pass for port 1 (minimum valid)', () => { + const result = validateAllowHostServicePorts('1', undefined); + expect(result.valid).toBe(true); + }); + + it('should pass for port 65535 (maximum valid)', () => { + const result = validateAllowHostServicePorts('65535', undefined); + expect(result.valid).toBe(true); + }); + + it('should fail for negative port number', () => { + const result = validateAllowHostServicePorts('-1', undefined); + expect(result.valid).toBe(false); + expect(result.error).toContain('Must be a numeric value'); + }); + + it('should fail for decimal port number', () => { + const result = validateAllowHostServicePorts('80.5', undefined); + expect(result.valid).toBe(false); + expect(result.error).toContain('Must be a numeric value'); + }); + }); + + describe('applyHostServicePortsConfig', () => { + let warnings: string[]; + let infos: string[]; + let mockLog: { warn: (msg: string) => void; info: (msg: string) => void }; + + beforeEach(() => { + warnings = []; + infos = []; + mockLog = { + warn: (msg: string) => warnings.push(msg), + info: (msg: string) => infos.push(msg), + }; + }); + + it('should return valid with no changes when no service ports provided', () => { + const result = applyHostServicePortsConfig(undefined, undefined, mockLog); + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.enableHostAccess).toBeUndefined(); + } + expect(warnings).toHaveLength(0); + expect(infos).toHaveLength(0); + }); + + it('should return error for invalid port', () => { + const result = applyHostServicePortsConfig('abc', undefined, mockLog); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.error).toContain('Invalid port'); + } + }); + + it('should auto-enable host access and emit warnings when ports provided without host access', () => { + const result = applyHostServicePortsConfig('5432,6379', undefined, mockLog); + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.enableHostAccess).toBe(true); + } + expect(warnings).toHaveLength(3); + expect(warnings[0]).toContain('bypasses dangerous port restrictions'); + expect(warnings[1]).toContain('Ensure host services'); + expect(warnings[2]).toContain('automatically enabling host access'); + expect(warnings[2]).toContain('80/443'); + expect(infos).toHaveLength(1); + expect(infos[0]).toContain('5432,6379'); + }); + + it('should not auto-enable host access when already enabled', () => { + const result = applyHostServicePortsConfig('5432', true, mockLog); + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.enableHostAccess).toBe(true); + } + // Should still warn but not log auto-enable message + expect(warnings).toHaveLength(2); + expect(infos).toHaveLength(1); + expect(infos[0]).toContain('5432'); + }); + + it('should auto-enable host access when enableHostAccess is false', () => { + const result = applyHostServicePortsConfig('3306', false, mockLog); + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.enableHostAccess).toBe(true); + } + expect(warnings.some(m => m.includes('automatically enabling'))).toBe(true); + }); + + it('should return error for out-of-range port', () => { + const result = applyHostServicePortsConfig('70000', undefined, mockLog); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.error).toContain('Must be a number between 1 and 65535'); + } + }); + }); + describe('parseMemoryLimit', () => { it('accepts valid memory limits', () => { expect(parseMemoryLimit('2g')).toEqual({ value: '2g' }); diff --git a/src/cli.ts b/src/cli.ts index e7c49e503..0aa743f88 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -701,6 +701,75 @@ export function validateAllowHostPorts( return { valid: true }; } +/** + * Validates --allow-host-service-ports values. + * Ports must be numeric and in the range 1-65535. + * Unlike --allow-host-ports, dangerous ports are intentionally allowed because + * these ports are restricted to the host gateway IP only (not the internet). + * Returns an object indicating whether host access should be auto-enabled. + */ +export function validateAllowHostServicePorts( + allowHostServicePorts: string | undefined, + enableHostAccess: boolean | undefined +): FlagValidationResult & { autoEnableHostAccess?: boolean } { + if (!allowHostServicePorts) { + return { valid: true }; + } + + const servicePorts = allowHostServicePorts.split(',').map(p => p.trim()); + for (const port of servicePorts) { + if (!/^\d+$/.test(port)) { + return { + valid: false, + error: `Invalid port in --allow-host-service-ports: ${port}. Must be a numeric value`, + }; + } + const portNum = parseInt(port, 10); + if (portNum < 1 || portNum > 65535) { + return { + valid: false, + error: `Invalid port in --allow-host-service-ports: ${port}. Must be a number between 1 and 65535`, + }; + } + } + + return { + valid: true, + autoEnableHostAccess: !enableHostAccess, + }; +} + +/** + * Applies --allow-host-service-ports validation and config mutations. + * Extracted from the main command handler for testability. + * + * Returns { valid: false, error } if validation fails (caller should exit). + * Returns { valid: true, enableHostAccess } with the (possibly mutated) value. + */ +export function applyHostServicePortsConfig( + allowHostServicePorts: string | undefined, + enableHostAccess: boolean | undefined, + log: { warn: (msg: string) => void; info: (msg: string) => void } +): { valid: true; enableHostAccess: boolean | undefined } | { valid: false; error: string } { + const validation = validateAllowHostServicePorts(allowHostServicePorts, enableHostAccess); + if (!validation.valid) { + return { valid: false, error: validation.error! }; + } + + if (allowHostServicePorts) { + log.warn('--allow-host-service-ports bypasses dangerous port restrictions for host-local traffic.'); + log.warn('Ensure host services on these ports do not provide external network access.'); + + if (validation.autoEnableHostAccess) { + log.warn('--allow-host-service-ports automatically enabling host access (ports 80/443 to host gateway also opened)'); + enableHostAccess = true; + } + log.info(`Host service ports allowed (host gateway only): ${allowHostServicePorts}`); + } + + return { valid: true, enableHostAccess }; +} + /** * Parses and validates a Docker memory limit string. * Valid formats: positive integer followed by b, k, m, or g (e.g., "2g", "512m", "4g"). @@ -1242,6 +1311,13 @@ program 'Ports/ranges to allow with --enable-host-access (default: 80,443).\n' + ' Example: 3000,8080 or 3000-3010,8000-8090' ) + .option( + '--allow-host-service-ports ', + 'Ports to allow ONLY to host gateway (for GitHub Actions services).\n' + + ' Bypasses dangerous port restrictions. Auto-enables host access.\n' + + ' WARNING: Allowing port 22 grants SSH access to the host.\n' + + ' Example: 5432,6379' + ) .option( '--enable-dind', @@ -1628,6 +1704,7 @@ program enableHostAccess: options.enableHostAccess, localhostDetected: localhostResult.localhostDetected, allowHostPorts: options.allowHostPorts, + allowHostServicePorts: options.allowHostServicePorts, sslBump: options.sslBump, enableDind: options.enableDind, enableDlp: options.enableDlp, @@ -1670,6 +1747,18 @@ program logger.warn(' This may expose sensitive credentials if logs or configs are shared'); } + // Validate --allow-host-service-ports (port format & range) + const servicePortsResult = applyHostServicePortsConfig( + config.allowHostServicePorts, + config.enableHostAccess, + logger + ); + if (!servicePortsResult.valid) { + logger.error(`❌ ${servicePortsResult.error}`); + process.exit(1); + } + config.enableHostAccess = servicePortsResult.enableHostAccess; + // Validate --allow-host-ports requires --enable-host-access const hostPortsValidation = validateAllowHostPorts(config.allowHostPorts, config.enableHostAccess); if (!hostPortsValidation.valid) { diff --git a/src/docker-manager.ts b/src/docker-manager.ts index 3d037374e..e09a36ff5 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -644,6 +644,17 @@ export function generateDockerCompose( environment.AWF_ALLOW_HOST_PORTS = config.allowHostPorts; } + // Pass host service ports to container for setup-iptables.sh (if specified) + // These ports bypass DANGEROUS_PORTS validation and are only allowed to host gateway + if (config.allowHostServicePorts) { + environment.AWF_HOST_SERVICE_PORTS = config.allowHostServicePorts; + // Ensure host access is enabled (setup-iptables.sh requires AWF_ENABLE_HOST_ACCESS) + // The CLI auto-enables this, but this is a safety net for programmatic usage + if (!environment.AWF_ENABLE_HOST_ACCESS) { + environment.AWF_ENABLE_HOST_ACCESS = 'true'; + } + } + // Pass chroot mode flag to container for entrypoint.sh capability drop environment.AWF_CHROOT_ENABLED = 'true'; // Pass the container working directory for chroot mode @@ -1228,6 +1239,7 @@ export function generateDockerCompose( AWF_BLOCKED_PORTS: environment.AWF_BLOCKED_PORTS || '', AWF_ENABLE_HOST_ACCESS: environment.AWF_ENABLE_HOST_ACCESS || '', AWF_ALLOW_HOST_PORTS: environment.AWF_ALLOW_HOST_PORTS || '', + AWF_HOST_SERVICE_PORTS: environment.AWF_HOST_SERVICE_PORTS || '', AWF_API_PROXY_IP: environment.AWF_API_PROXY_IP || '', AWF_DOH_PROXY_IP: environment.AWF_DOH_PROXY_IP || '', AWF_SSL_BUMP_ENABLED: environment.AWF_SSL_BUMP_ENABLED || '', diff --git a/src/host-iptables.test.ts b/src/host-iptables.test.ts index d2df1e819..0370b0ad6 100644 --- a/src/host-iptables.test.ts +++ b/src/host-iptables.test.ts @@ -806,6 +806,86 @@ describe('host-iptables', () => { '-j', 'ACCEPT', ]); }); + + it('should add service port rules when allowHostServicePorts is specified', async () => { + mockedExeca + .mockResolvedValueOnce({ stdout: 'fw-bridge', stderr: '', exitCode: 0 } as any) + .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as any) + .mockResolvedValueOnce({ exitCode: 1 } as any); + + mockedExeca.mockImplementation(((cmd: string, args: string[]) => { + if (cmd === 'docker' && args.includes('bridge')) { + return Promise.resolve({ stdout: '172.17.0.1', stderr: '', exitCode: 0 }); + } + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + }) as any); + + const hostAccess: HostAccessConfig = { enabled: true, allowHostServicePorts: '5432,6379' }; + await setupHostIptables('172.30.0.10', 3128, ['8.8.8.8', '8.8.4.4'], undefined, undefined, hostAccess); + + // Verify service ports get ACCEPT rules on both gateway IPs + expect(mockedExeca).toHaveBeenCalledWith('iptables', [ + '-t', 'filter', '-A', 'FW_WRAPPER', + '-p', 'tcp', '-d', '172.30.0.1', '--dport', '5432', + '-j', 'ACCEPT', + ]); + expect(mockedExeca).toHaveBeenCalledWith('iptables', [ + '-t', 'filter', '-A', 'FW_WRAPPER', + '-p', 'tcp', '-d', '172.30.0.1', '--dport', '6379', + '-j', 'ACCEPT', + ]); + expect(mockedExeca).toHaveBeenCalledWith('iptables', [ + '-t', 'filter', '-A', 'FW_WRAPPER', + '-p', 'tcp', '-d', '172.17.0.1', '--dport', '5432', + '-j', 'ACCEPT', + ]); + expect(mockedExeca).toHaveBeenCalledWith('iptables', [ + '-t', 'filter', '-A', 'FW_WRAPPER', + '-p', 'tcp', '-d', '172.17.0.1', '--dport', '6379', + '-j', 'ACCEPT', + ]); + }); + + it('should deduplicate service ports with regular host ports', async () => { + mockedExeca + .mockResolvedValueOnce({ stdout: 'fw-bridge', stderr: '', exitCode: 0 } as any) + .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as any) + .mockResolvedValueOnce({ exitCode: 1 } as any); + + mockedExeca.mockImplementation(((cmd: string, args: string[]) => { + if (cmd === 'docker' && args.includes('bridge')) { + return Promise.resolve({ stdout: '172.17.0.1', stderr: '', exitCode: 0 }); + } + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + }) as any); + + // Both allowHostPorts and allowHostServicePorts include 5432 + const hostAccess: HostAccessConfig = { + enabled: true, + allowHostPorts: '5432,3000', + allowHostServicePorts: '5432,6379', + }; + await setupHostIptables('172.30.0.10', 3128, ['8.8.8.8', '8.8.4.4'], undefined, undefined, hostAccess); + + // Count how many times port 5432 rule was called for 172.30.0.1 + const port5432Calls = mockedExeca.mock.calls.filter( + (call) => call[0] === 'iptables' && + Array.isArray(call[1]) && + call[1].includes('--dport') && + call[1][call[1].indexOf('--dport') + 1] === '5432' && + call[1].includes('-d') && + call[1][call[1].indexOf('-d') + 1] === '172.30.0.1' + ); + // Should only be called once (deduplicated) + expect(port5432Calls).toHaveLength(1); + + // Verify 6379 also got a rule + expect(mockedExeca).toHaveBeenCalledWith('iptables', [ + '-t', 'filter', '-A', 'FW_WRAPPER', + '-p', 'tcp', '-d', '172.30.0.1', '--dport', '6379', + '-j', 'ACCEPT', + ]); + }); }); describe('isValidPortSpec', () => { diff --git a/src/host-iptables.ts b/src/host-iptables.ts index 117d2a8c6..bb8e98243 100644 --- a/src/host-iptables.ts +++ b/src/host-iptables.ts @@ -16,6 +16,7 @@ const AWF_NETWORK_GATEWAY = '172.30.0.1'; export interface HostAccessConfig { enabled: boolean; allowHostPorts?: string; + allowHostServicePorts?: string; } /** @@ -453,6 +454,21 @@ export async function setupHostIptables(squidIp: string, squidPort: number, dnsS } } + // Also include host service ports (--allow-host-service-ports) + // These intentionally bypass dangerous port restrictions since traffic is host-gateway-only + if (hostAccess.allowHostServicePorts) { + for (const entry of hostAccess.allowHostServicePorts.split(',')) { + const trimmed = entry.trim(); + if (trimmed) { + if (!isValidPortSpec(trimmed)) { + logger.warn(`Skipping invalid host service port spec: ${trimmed}`); + continue; + } + customPorts.push(trimmed); + } + } + } + const allPorts = [...new Set([...defaultPorts, ...customPorts])]; for (const gwIp of gatewayIps) { diff --git a/src/types.ts b/src/types.ts index 3c67e146b..0d387dc08 100644 --- a/src/types.ts +++ b/src/types.ts @@ -421,6 +421,31 @@ export interface WrapperConfig { */ allowHostPorts?: string; + /** + * Ports to allow for host service access (e.g., GitHub Actions services containers) + * + * Comma-separated list of ports that are allowed ONLY to the host gateway IP + * (host.docker.internal). Unlike --allow-host-ports, this flag bypasses the + * DANGEROUS_PORTS validation because traffic is restricted to the host machine. + * + * This is designed for GitHub Actions `services:` containers (e.g., Postgres on + * port 5432) which publish to the host via port mapping. The agent can reach + * these services on the host but still cannot reach databases on the internet. + * + * Automatically enables host access (--enable-host-access). + * + * @default undefined + * @example + * ```bash + * # Allow Postgres service container on host + * awf --allow-host-service-ports 5432 --allow-domains github.com -- psql -h host.docker.internal + * + * # Allow multiple service containers + * awf --allow-host-service-ports 5432,6379,3306 --allow-domains github.com -- command + * ``` + */ + allowHostServicePorts?: string; + /** * Whether to enable SSL Bump for HTTPS content inspection * diff --git a/tests/fixtures/awf-runner.ts b/tests/fixtures/awf-runner.ts index 4540ad2f2..ac7a148e2 100644 --- a/tests/fixtures/awf-runner.ts +++ b/tests/fixtures/awf-runner.ts @@ -18,6 +18,7 @@ export interface AwfOptions { tty?: boolean; // Allocate pseudo-TTY (required for interactive tools like Claude Code) dnsServers?: string[]; // DNS servers to use (e.g., ['8.8.8.8', '2001:4860:4860::8888']) allowHostPorts?: string; // Ports or port ranges to allow for host access (e.g., '3000' or '3000-8000') + allowHostServicePorts?: string; // Ports to allow ONLY to host gateway (bypasses dangerous port restrictions) enableApiProxy?: boolean; // Enable API proxy sidecar for LLM credential management rateLimitRpm?: number; // Requests per minute per provider rateLimitRph?: number; // Requests per hour per provider @@ -119,6 +120,11 @@ export class AwfRunner { args.push('--allow-host-ports', options.allowHostPorts); } + // Add allow-host-service-ports + if (options.allowHostServicePorts) { + args.push('--allow-host-service-ports', options.allowHostServicePorts); + } + // Add enable-api-proxy flag if (options.enableApiProxy) { args.push('--enable-api-proxy'); @@ -327,6 +333,11 @@ export class AwfRunner { args.push('--allow-host-ports', options.allowHostPorts); } + // Add allow-host-service-ports + if (options.allowHostServicePorts) { + args.push('--allow-host-service-ports', options.allowHostServicePorts); + } + // Add enable-api-proxy flag if (options.enableApiProxy) { args.push('--enable-api-proxy'); diff --git a/tests/integration/host-tcp-services.test.ts b/tests/integration/host-tcp-services.test.ts new file mode 100644 index 000000000..25f6c5477 --- /dev/null +++ b/tests/integration/host-tcp-services.test.ts @@ -0,0 +1,170 @@ +/** + * Host TCP Service Ports Tests + * + * These tests verify the --allow-host-service-ports flag, which allows + * TCP connections to the host gateway on ports that are normally blocked + * as "dangerous" (e.g., database ports like 5432, 6379, 3306). + * + * This is designed for GitHub Actions `services:` containers that publish + * to the host via port mapping. The agent can reach these services on the + * host but still cannot reach databases on the internet. + */ + +/// + +import { describe, test, expect, beforeAll, afterAll } from '@jest/globals'; +import { createRunner, AwfRunner } from '../fixtures/awf-runner'; +import { cleanup } from '../fixtures/cleanup'; +import * as net from 'net'; + +/** + * Start a TCP echo server on a given port. + * Returns a function to close the server. + */ +function startTcpEchoServer(port: number): Promise<{ close: () => Promise }> { + return new Promise((resolve, reject) => { + const server = net.createServer((socket) => { + socket.on('data', (data) => { + socket.write(`ECHO:${data.toString()}`); + socket.end(); + }); + socket.on('error', () => { + // Ignore client errors + }); + }); + + server.on('error', reject); + + server.listen(port, '0.0.0.0', () => { + resolve({ + close: () => new Promise((res, rej) => { + server.close((err) => err ? rej(err) : res()); + }), + }); + }); + }); +} + +describe('Host TCP Service Ports', () => { + let runner: AwfRunner; + + beforeAll(async () => { + await cleanup(false); + runner = createRunner(); + }); + + afterAll(async () => { + await cleanup(false); + }); + + test('should auto-enable host access when --allow-host-service-ports is used', async () => { + const result = await runner.runWithSudo('echo "test"', { + allowDomains: ['github.com'], + allowHostServicePorts: '5432', + logLevel: 'debug', + timeout: 60000, + }); + + expect(result).toSucceed(); + expect(result.stderr).toContain('automatically enabling host access'); + expect(result.stderr).toContain('Host service ports allowed (host gateway only): 5432'); + }, 120000); + + test('should allow TCP connection to host service on a dangerous port (Redis 6379)', async () => { + // Start a TCP echo server on a dangerous port (Redis 6379, in DANGEROUS_PORTS list) + const TEST_PORT = 6379; + let server: { close: () => Promise }; + try { + server = await startTcpEchoServer(TEST_PORT); + } catch { + // If we can't bind to 6379 (e.g., already in use), skip + console.log(`Skipping test: could not bind to port ${TEST_PORT}`); + return; + } + + try { + // Run a command inside AWF that connects to host.docker.internal on the test port + const result = await runner.runWithSudo( + `bash -c 'echo "HELLO" | nc -w 5 host.docker.internal ${TEST_PORT}'`, + { + allowDomains: ['github.com'], + allowHostServicePorts: String(TEST_PORT), + logLevel: 'debug', + timeout: 60000, + } + ); + + expect(result).toSucceed(); + expect(result.stdout).toContain('ECHO:HELLO'); + } finally { + await server.close(); + } + }, 120000); + + test('should allow TCP connection to actual dangerous port (5432) on host', async () => { + // Start a TCP echo server on PostgreSQL port 5432 + // This requires the test to run with sufficient privileges + const TEST_PORT = 5432; + let server: { close: () => Promise } | null = null; + + try { + server = await startTcpEchoServer(TEST_PORT); + } catch { + // If we can't bind to 5432 (e.g., already in use or no privileges), skip + console.log(`Skipping test: could not bind to port ${TEST_PORT}`); + return; + } + + try { + const result = await runner.runWithSudo( + `bash -c 'echo "PGTEST" | nc -w 5 host.docker.internal ${TEST_PORT}'`, + { + allowDomains: ['github.com'], + allowHostServicePorts: String(TEST_PORT), + logLevel: 'debug', + timeout: 60000, + } + ); + + expect(result).toSucceed(); + expect(result.stdout).toContain('ECHO:PGTEST'); + } finally { + if (server) await server.close(); + } + }, 120000); + + test('should block dangerous port to non-host destinations (internet)', async () => { + // Even with --allow-host-service-ports 5432, traffic to external IPs + // on port 5432 should still be blocked + const result = await runner.runWithSudo( + 'bash -c \'curl -s --connect-timeout 5 http://example.com:5432/ 2>&1 || echo "BLOCKED"\'', + { + allowDomains: ['example.com'], + allowHostServicePorts: '5432', + logLevel: 'debug', + timeout: 60000, + } + ); + + expect(result).toSucceed(); + // The connection to example.com:5432 should fail because the port is only + // allowed to the host gateway, not to internet destinations + expect(result.stdout).toContain('BLOCKED'); + }, 120000); + + test('should allow multiple service ports', async () => { + const result = await runner.runWithSudo('echo "test"', { + allowDomains: ['github.com'], + allowHostServicePorts: '5432,6379,3306', + logLevel: 'debug', + timeout: 60000, + }); + + expect(result).toSucceed(); + expect(result.stderr).toContain('Host service ports allowed (host gateway only): 5432,6379,3306'); + // Should show iptables rules for each port + expect(result.stderr).toContain('Allow host service port 5432'); + expect(result.stderr).toContain('Allow host service port 6379'); + expect(result.stderr).toContain('Allow host service port 3306'); + }, 120000); +});