diff --git a/.github/workflows/smoke-gemini.lock.yml b/.github/workflows/smoke-gemini.lock.yml index 059b86bde..3d515e0ec 100644 --- a/.github/workflows/smoke-gemini.lock.yml +++ b/.github/workflows/smoke-gemini.lock.yml @@ -117,7 +117,7 @@ jobs: GH_AW_INFO_EXPERIMENTAL: "false" GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true" GH_AW_INFO_STAGED: "false" - GH_AW_INFO_ALLOWED_DOMAINS: '["defaults","github"]' + GH_AW_INFO_ALLOWED_DOMAINS: '["defaults","github","generativelanguage.googleapis.com","aiplatform.googleapis.com"]' GH_AW_INFO_FIREWALL_ENABLED: "true" GH_AW_INFO_AWF_VERSION: "v0.25.41" GH_AW_INFO_AWMG_VERSION: "" @@ -186,7 +186,7 @@ jobs: id: sanitized uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - GH_AW_ALLOWED_DOMAINS: "*.githubusercontent.com,*.googleapis.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,docs.github.com,generativelanguage.googleapis.com,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.blog,github.com,github.githubassets.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" + GH_AW_ALLOWED_DOMAINS: "*.githubusercontent.com,*.googleapis.com,aiplatform.googleapis.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,docs.github.com,generativelanguage.googleapis.com,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.blog,github.com,github.githubassets.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); @@ -760,9 +760,9 @@ jobs: set -o pipefail touch /tmp/gh-aw/agent-step-summary.md (umask 177 && touch /tmp/gh-aw/agent-stdio.log) - printf '%s\n' '{"$schema":"https://github.com/github/gh-aw-firewall/releases/download/v0.25.41/awf-config.schema.json","network":{"allowDomains":["*.githubusercontent.com","*.googleapis.com","api.snapcraft.io","archive.ubuntu.com","azure.archive.ubuntu.com","codeload.github.com","crl.geotrust.com","crl.globalsign.com","crl.identrust.com","crl.sectigo.com","crl.thawte.com","crl.usertrust.com","crl.verisign.com","crl3.digicert.com","crl4.digicert.com","crls.ssl.com","docs.github.com","generativelanguage.googleapis.com","github-cloud.githubusercontent.com","github-cloud.s3.amazonaws.com","github.blog","github.com","github.githubassets.com","host.docker.internal","json-schema.org","json.schemastore.org","keyserver.ubuntu.com","lfs.github.com","objects.githubusercontent.com","ocsp.digicert.com","ocsp.geotrust.com","ocsp.globalsign.com","ocsp.identrust.com","ocsp.sectigo.com","ocsp.ssl.com","ocsp.thawte.com","ocsp.usertrust.com","ocsp.verisign.com","packagecloud.io","packages.cloud.google.com","packages.microsoft.com","ppa.launchpad.net","raw.githubusercontent.com","registry.npmjs.org","s.symcb.com","s.symcd.com","security.ubuntu.com","ts-crl.ws.symantec.com","ts-ocsp.ws.symantec.com","www.googleapis.com"]},"apiProxy":{"enabled":true,"targets":{"gemini":{"host":"generativelanguage.googleapis.com"}},"models":{"auto":["large"],"deep-research":["copilot/deep-research*","google/deep-research*"],"gemini-flash":["copilot/gemini-*flash*","google/gemini-*flash*"],"gemini-pro":["copilot/gemini-*pro*","google/gemini-*pro*"],"gpt-4.1":["copilot/gpt-4.1*","openai/gpt-4.1*"],"gpt-5":["copilot/gpt-5*","openai/gpt-5*"],"gpt-5-codex":["copilot/gpt-5*codex*","openai/gpt-5*codex*"],"gpt-5-mini":["copilot/gpt-5*mini*","openai/gpt-5*mini*"],"gpt-5-nano":["copilot/gpt-5*nano*","openai/gpt-5*nano*"],"gpt-5-pro":["copilot/gpt-5*pro*","openai/gpt-5*pro*"],"haiku":["copilot/*haiku*","anthropic/*haiku*"],"large":["sonnet","gpt-5-pro","gpt-5","gemini-pro"],"mini":["haiku","gpt-5-mini","gpt-5-nano","gemini-flash"],"opus":["copilot/*opus*","anthropic/*opus*"],"reasoning":["copilot/o1*","copilot/o3*","copilot/o4*","openai/o1*","openai/o3*","openai/o4*"],"small":["mini"],"sonnet":["copilot/*sonnet*","anthropic/*sonnet*"]}},"container":{"imageTag":"0.25.41,squid=sha256:1260445d25968dbf3ae70143964177a0e5914cf2ce07a6117f7d3caec6c3e3c4,agent=sha256:cb2b565d070116d4b67e355775340528b5a2c3cb18b2c9049638bcc2df681770,agent-act=sha256:06523b4c0bb2ad3df400e1c55a07cb3631b69a293ef887c5e48b5269e0c867d6,api-proxy=sha256:fadd0de387209f69a9a7a1b8722bb5e7fdfb80ba9749a5c60f0e4cd7582a74d0,cli-proxy=sha256:62171f2fa508667b8b0a9e096f826983f312e3da0ce894f80c0f83a875af60fe"}}' > "${RUNNER_TEMP}/gh-aw/awf-config.json" && cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json + printf '%s\n' '{"$schema":"https://github.com/github/gh-aw-firewall/releases/download/v0.25.41/awf-config.schema.json","network":{"allowDomains":["*.githubusercontent.com","*.googleapis.com","aiplatform.googleapis.com","api.snapcraft.io","archive.ubuntu.com","azure.archive.ubuntu.com","codeload.github.com","crl.geotrust.com","crl.globalsign.com","crl.identrust.com","crl.sectigo.com","crl.thawte.com","crl.usertrust.com","crl.verisign.com","crl3.digicert.com","crl4.digicert.com","crls.ssl.com","docs.github.com","generativelanguage.googleapis.com","github-cloud.githubusercontent.com","github-cloud.s3.amazonaws.com","github.blog","github.com","github.githubassets.com","host.docker.internal","json-schema.org","json.schemastore.org","keyserver.ubuntu.com","lfs.github.com","objects.githubusercontent.com","ocsp.digicert.com","ocsp.geotrust.com","ocsp.globalsign.com","ocsp.identrust.com","ocsp.sectigo.com","ocsp.ssl.com","ocsp.thawte.com","ocsp.usertrust.com","ocsp.verisign.com","packagecloud.io","packages.cloud.google.com","packages.microsoft.com","ppa.launchpad.net","raw.githubusercontent.com","registry.npmjs.org","s.symcb.com","s.symcd.com","security.ubuntu.com","ts-crl.ws.symantec.com","ts-ocsp.ws.symantec.com","www.googleapis.com"]},"apiProxy":{"enabled":true,"targets":{"gemini":{"host":"generativelanguage.googleapis.com"}},"models":{"auto":["large"],"deep-research":["copilot/deep-research*","google/deep-research*"],"gemini-flash":["copilot/gemini-*flash*","google/gemini-*flash*"],"gemini-pro":["copilot/gemini-*pro*","google/gemini-*pro*"],"gpt-4.1":["copilot/gpt-4.1*","openai/gpt-4.1*"],"gpt-5":["copilot/gpt-5*","openai/gpt-5*"],"gpt-5-codex":["copilot/gpt-5*codex*","openai/gpt-5*codex*"],"gpt-5-mini":["copilot/gpt-5*mini*","openai/gpt-5*mini*"],"gpt-5-nano":["copilot/gpt-5*nano*","openai/gpt-5*nano*"],"gpt-5-pro":["copilot/gpt-5*pro*","openai/gpt-5*pro*"],"haiku":["copilot/*haiku*","anthropic/*haiku*"],"large":["sonnet","gpt-5-pro","gpt-5","gemini-pro"],"mini":["haiku","gpt-5-mini","gpt-5-nano","gemini-flash"],"opus":["copilot/*opus*","anthropic/*opus*"],"reasoning":["copilot/o1*","copilot/o3*","copilot/o4*","openai/o1*","openai/o3*","openai/o4*"],"small":["mini"],"sonnet":["copilot/*sonnet*","anthropic/*sonnet*"]}},"container":{"imageTag":"0.25.41,squid=sha256:1260445d25968dbf3ae70143964177a0e5914cf2ce07a6117f7d3caec6c3e3c4,agent=sha256:cb2b565d070116d4b67e355775340528b5a2c3cb18b2c9049638bcc2df681770,agent-act=sha256:06523b4c0bb2ad3df400e1c55a07cb3631b69a293ef887c5e48b5269e0c867d6,api-proxy=sha256:fadd0de387209f69a9a7a1b8722bb5e7fdfb80ba9749a5c60f0e4cd7582a74d0,cli-proxy=sha256:62171f2fa508667b8b0a9e096f826983f312e3da0ce894f80c0f83a875af60fe"}}' > "${RUNNER_TEMP}/gh-aw/awf-config.json" && cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json # shellcheck disable=SC1003 - sudo -E awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --env-all --exclude-env GEMINI_API_KEY --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --session-state-dir /tmp/gh-aw/sandbox/agent/session-state --enable-host-access --allow-host-ports 80,443,8080 --skip-pull \ + sudo -E awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --env-all --exclude-env GEMINI_API_KEY --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --exclude-env MCP_GATEWAY_HOST_DOMAIN --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --session-state-dir /tmp/gh-aw/sandbox/agent/session-state --enable-host-access --allow-host-ports 80,443,8080 --skip-pull \ -- /bin/bash -c 'export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && export PATH="$(find /opt/hostedtoolcache /home/runner/work/_tool -maxdepth 4 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && gemini --yolo --skip-trust --output-format stream-json --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log env: DEBUG: gemini-cli:* @@ -837,7 +837,7 @@ jobs: uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} - GH_AW_ALLOWED_DOMAINS: "*.githubusercontent.com,*.googleapis.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,docs.github.com,generativelanguage.googleapis.com,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.blog,github.com,github.githubassets.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" + GH_AW_ALLOWED_DOMAINS: "*.githubusercontent.com,*.googleapis.com,aiplatform.googleapis.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,docs.github.com,generativelanguage.googleapis.com,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.blog,github.com,github.githubassets.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_API_URL: ${{ github.api_url }} with: @@ -1126,7 +1126,7 @@ jobs: uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} - GH_AW_ALLOWED_DOMAINS: "*.githubusercontent.com,*.googleapis.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,docs.github.com,generativelanguage.googleapis.com,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.blog,github.com,github.githubassets.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" + GH_AW_ALLOWED_DOMAINS: "*.githubusercontent.com,*.googleapis.com,aiplatform.googleapis.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,docs.github.com,generativelanguage.googleapis.com,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.blog,github.com,github.githubassets.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_API_URL: ${{ github.api_url }} GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"hide_older_comments\":true,\"max\":1},\"add_labels\":{\"allowed\":[\"smoke-gemini\"]},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"},\"report_incomplete\":{}}" diff --git a/.github/workflows/smoke-gemini.md b/.github/workflows/smoke-gemini.md index a449bceef..ec2094ce9 100644 --- a/.github/workflows/smoke-gemini.md +++ b/.github/workflows/smoke-gemini.md @@ -18,6 +18,8 @@ network: allowed: - defaults - github + - generativelanguage.googleapis.com + - aiplatform.googleapis.com tools: bash: - "*" diff --git a/src/container-lifecycle.ts b/src/container-lifecycle.ts index 0defdf1f6..22b56aba9 100644 --- a/src/container-lifecycle.ts +++ b/src/container-lifecycle.ts @@ -2,7 +2,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as yaml from 'js-yaml'; import execa from 'execa'; -import { WrapperConfig, BlockedTarget } from './types'; +import { WrapperConfig, BlockedTarget, API_PROXY_PORTS } from './types'; import { logger } from './logger'; import { generateSquidConfig, generatePolicyManifest } from './squid-config'; import { parseDomainWithProtocol, isWildcardPattern, wildcardToRegex } from './domain-patterns'; @@ -252,6 +252,13 @@ export async function writeConfigs(config: WrapperConfig): Promise { enableDlp: config.enableDlp, dnsServers: config.dnsServers, upstreamProxy: config.upstreamProxy, + // Allow the api-proxy sidecar IP through Squid before the raw-IP deny rule. + // Some HTTP clients (e.g., Node.js fetch / undici ProxyAgent) route requests + // to the api-proxy via HTTP_PROXY without honouring NO_PROXY for raw IPs. + ...(config.enableApiProxy && networkConfig.proxyIp ? { + apiProxyIp: networkConfig.proxyIp, + apiProxyPorts: Object.values(API_PROXY_PORTS), + } : {}), }); const squidConfigPath = path.join(config.workDir, 'squid.conf'); fs.writeFileSync(squidConfigPath, squidConfig, { mode: 0o644 }); @@ -297,6 +304,9 @@ export async function writeConfigs(config: WrapperConfig): Promise { allowHostPorts: config.allowHostPorts, enableDlp: config.enableDlp, dnsServers: config.dnsServers, + ...(config.enableApiProxy && networkConfig.proxyIp ? { + apiProxyIp: networkConfig.proxyIp, + } : {}), }); fs.writeFileSync( path.join(auditDir, 'policy-manifest.json'), diff --git a/src/services/agent-environment.ts b/src/services/agent-environment.ts index df73ab6cd..3d58d8648 100644 --- a/src/services/agent-environment.ts +++ b/src/services/agent-environment.ts @@ -61,6 +61,13 @@ export function buildAgentEnvironment(params: AgentEnvironmentParams): Record { expect(result).toContain('cache_peer 10.0.0.50 parent 8080 0 no-query default'); }); }); + + describe('Api-Proxy Sidecar Configuration', () => { + const apiProxyIp = '172.30.0.30'; + const apiProxyPorts = [10000, 10001, 10002, 10003, 10004]; + + it('should add api-proxy ports to Safe_ports when apiProxyPorts is set', () => { + const config: SquidConfig = { + domains: ['example.com'], + port: defaultPort, + apiProxyIp, + apiProxyPorts, + }; + const result = generateSquidConfig(config); + for (const p of apiProxyPorts) { + expect(result).toContain(`acl Safe_ports port ${p}`); + } + }); + + it('should insert allow_api_proxy_ip rule before http_access deny dst_ipv4', () => { + const config: SquidConfig = { + domains: ['example.com'], + port: defaultPort, + apiProxyIp, + apiProxyPorts, + }; + const result = generateSquidConfig(config); + expect(result).toContain(`acl allow_api_proxy_ip dst ${apiProxyIp}`); + expect(result).toContain('http_access allow allow_api_proxy_ip'); + const allowPos = result.indexOf('http_access allow allow_api_proxy_ip'); + const denyIpv4Pos = result.indexOf('http_access deny dst_ipv4'); + expect(allowPos).toBeLessThan(denyIpv4Pos); + }); + + it('should not emit api-proxy rules when apiProxyIp is not set', () => { + const config: SquidConfig = { + domains: ['example.com'], + port: defaultPort, + }; + const result = generateSquidConfig(config); + expect(result).not.toContain('allow_api_proxy_ip'); + }); + + it('should reject non-integer apiProxyPorts values', () => { + expect(() => { + generateSquidConfig({ + domains: ['example.com'], + port: defaultPort, + apiProxyIp, + apiProxyPorts: [10000, NaN], + }); + }).toThrow(/Invalid api-proxy port/); + }); + + it('should reject out-of-range apiProxyPorts values', () => { + expect(() => { + generateSquidConfig({ + domains: ['example.com'], + port: defaultPort, + apiProxyIp, + apiProxyPorts: [0], + }); + }).toThrow(/Invalid api-proxy port/); + + expect(() => { + generateSquidConfig({ + domains: ['example.com'], + port: defaultPort, + apiProxyIp, + apiProxyPorts: [65536], + }); + }).toThrow(/Invalid api-proxy port/); + }); + + it('should reject dangerous apiProxyPorts values', () => { + expect(() => { + generateSquidConfig({ + domains: ['example.com'], + port: defaultPort, + apiProxyIp, + apiProxyPorts: [22], + }); + }).toThrow(/blocked for security reasons/); + }); + + it('should reject invalid apiProxyIp (injection attempt)', () => { + expect(() => { + generateSquidConfig({ + domains: ['example.com'], + port: defaultPort, + apiProxyIp: '172.30.0.30\nhttp_access allow all', + apiProxyPorts, + }); + }).toThrow(/SECURITY.*apiProxyIp/); + }); + + it('should reject apiProxyIp with invalid octets', () => { + expect(() => { + generateSquidConfig({ + domains: ['example.com'], + port: defaultPort, + apiProxyIp: '999.30.0.30', + apiProxyPorts, + }); + }).toThrow(/SECURITY.*apiProxyIp/); + }); + }); +}); + +describe('generatePolicyManifest - Api-Proxy Rules', () => { + const defaultPort = 3128; + + it('should include allow-api-proxy-ip rule before deny-raw-ipv4 when apiProxyIp is set', () => { + const manifest = generatePolicyManifest({ + domains: ['example.com'], + port: defaultPort, + apiProxyIp: '172.30.0.30', + }); + + const apiProxyRule = manifest.rules.find(r => r.id === 'allow-api-proxy-ip'); + expect(apiProxyRule).toBeDefined(); + expect(apiProxyRule!.action).toBe('allow'); + expect(apiProxyRule!.domains).toContain('172.30.0.30'); + + const denyIpv4Rule = manifest.rules.find(r => r.id === 'deny-raw-ipv4'); + expect(denyIpv4Rule).toBeDefined(); + expect(apiProxyRule!.order).toBeLessThan(denyIpv4Rule!.order); + }); + + it('should not include allow-api-proxy-ip rule when apiProxyIp is not set', () => { + const manifest = generatePolicyManifest({ + domains: ['example.com'], + port: defaultPort, + }); + + const apiProxyRule = manifest.rules.find(r => r.id === 'allow-api-proxy-ip'); + expect(apiProxyRule).toBeUndefined(); + }); }); diff --git a/src/squid-config.ts b/src/squid-config.ts index 916733578..9a7a9726e 100644 --- a/src/squid-config.ts +++ b/src/squid-config.ts @@ -306,7 +306,17 @@ ${urlAclSection}${urlAccessRules}`; * // Blocked: internal.example.com -> acl blocked_domains dstdomain .internal.example.com */ export function generateSquidConfig(config: SquidConfig): string { - const { domains, blockedDomains, port, sslBump, caFiles, sslDbPath, urlPatterns, enableHostAccess, allowHostPorts, enableDlp, dnsServers, upstreamProxy } = config; + const { domains, blockedDomains, port, sslBump, caFiles, sslDbPath, urlPatterns, enableHostAccess, allowHostPorts, enableDlp, dnsServers, upstreamProxy, apiProxyIp, apiProxyPorts } = config; + + // Validate apiProxyIp if provided — must be a valid IPv4 address to prevent config injection. + // Each octet must be 0-255; the simple \d{1,3} pattern would wrongly accept 999.999.999.999. + if (apiProxyIp !== undefined) { + const octet = '(?:25[0-5]|2[0-4]\\d|1\\d{2}|[1-9]\\d|\\d)'; + const ipv4Re = new RegExp(`^(?:${octet}\\.){3}${octet}$`); + if (!ipv4Re.test(apiProxyIp)) { + throw new Error(`SECURITY: apiProxyIp must be a valid IPv4 address (0-255 octets), got: ${JSON.stringify(apiProxyIp)}`); + } + } // Parse, deduplicate, and group domains by protocol (shared logic) const { domainsByProto, patternsByProto } = parseDomainConfig(domains); @@ -573,6 +583,24 @@ acl Safe_ports port 443 # HTTPS`; portAclsSection += `\nacl CONNECT method CONNECT`; + // Add api-proxy ports to Safe_ports so that CONNECT / requests to those ports + // are not rejected by `deny CONNECT !Safe_ports` / `deny !Safe_ports` before + // the per-IP allow rule (below) has a chance to fire. + if (apiProxyPorts && apiProxyPorts.length > 0) { + for (const proxyPort of apiProxyPorts) { + if (!Number.isInteger(proxyPort) || proxyPort < 1 || proxyPort > 65535) { + throw new Error(`Invalid api-proxy port: ${proxyPort}. Must be an integer between 1 and 65535`); + } + if (DANGEROUS_PORTS.includes(proxyPort)) { + throw new Error( + `Api-proxy port ${proxyPort} is blocked for security reasons. ` + + `Dangerous ports (SSH, databases, etc.) cannot be added to Safe_ports.` + ); + } + portAclsSection += `\nacl Safe_ports port ${proxyPort} # AWF api-proxy sidecar`; + } + } + const portAclsAndRules = `${portAclsSection} # Access rules @@ -626,7 +654,15 @@ acl localnet src fc00::/7 acl localnet src fe80::/10 ${portAclsAndRules} - +${apiProxyIp ? ` +# Allow connections to the AWF api-proxy sidecar before raw-IP deny rules. +# Some HTTP clients (e.g., Node.js fetch / undici ProxyAgent) route requests to +# the api-proxy via HTTP_PROXY without honouring NO_PROXY for raw IP addresses, +# causing them to arrive at Squid and be rejected by the raw-IP deny rule below. +# This allow rule fires first for the known api-proxy IP. +acl allow_api_proxy_ip dst ${apiProxyIp} +http_access allow allow_api_proxy_ip +` : ''} # Deny CONNECT to raw IP addresses (IPv4 and IPv6) # Prevents bypassing domain-based filtering via direct IP connections acl dst_ipv4 dstdom_regex ^[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+$ @@ -709,7 +745,7 @@ shutdown_lifetime 0 seconds * enricher skips them and attributes those denials to "unknown". */ export function generatePolicyManifest(config: SquidConfig): PolicyManifest { - const { domains, blockedDomains, sslBump, enableHostAccess, allowHostPorts, enableDlp, dnsServers } = config; + const { domains, blockedDomains, sslBump, enableHostAccess, allowHostPorts, enableDlp, dnsServers, apiProxyIp } = config; // Parse, deduplicate, and group domains by protocol (shared logic with generateSquidConfig) const { domainsByProto, patternsByProto } = parseDomainConfig(domains); @@ -737,6 +773,19 @@ export function generatePolicyManifest(config: SquidConfig): PolicyManifest { description: 'Deny CONNECT (HTTPS) to ports not in Safe_ports ACL', }); + // --- api-proxy allow (before raw-IP deny) --- + if (apiProxyIp) { + rules.push({ + id: 'allow-api-proxy-ip', + order: ++order, + action: 'allow', + aclName: 'allow_api_proxy_ip', + protocol: 'both', + domains: [apiProxyIp], + description: 'Allow connections to the AWF api-proxy sidecar IP before raw-IP deny rules', + }); + } + // --- Raw IP blocking --- rules.push({ id: 'deny-raw-ipv4', diff --git a/src/types/docker.ts b/src/types/docker.ts index c23733f42..64cedbfc6 100644 --- a/src/types/docker.ts +++ b/src/types/docker.ts @@ -128,6 +128,26 @@ export interface SquidConfig { * directives so Squid forwards traffic through the parent proxy. */ upstreamProxy?: UpstreamProxyConfig; + + /** + * IP address of the AWF api-proxy sidecar container (e.g., "172.30.0.30"). + * + * When set, an explicit `http_access allow` rule is inserted for this IP + * *before* the `deny dst_ipv4` raw-IP block. This is required because some + * HTTP clients (e.g., Node.js fetch / undici ProxyAgent) route requests to + * the api-proxy through `HTTP_PROXY` without honouring `NO_PROXY` for raw IP + * addresses, causing Squid to deny them via the raw-IP rule. + */ + apiProxyIp?: string; + + /** + * Ports served by the AWF api-proxy sidecar (e.g., [10000, 10001, 10002, 10003]). + * + * When set, these ports are appended to Squid's `Safe_ports` ACL so that + * `http_access deny !Safe_ports` and `http_access deny CONNECT !Safe_ports` + * do not block connections to the api-proxy before the allow rule fires. + */ + apiProxyPorts?: number[]; } /**