Skip to content

Deny by Default Firewall

Configure deny-all firewall rules with explicit allow-lists for GitHub and required package registries. Default-allow rules permit data exfiltration and lateral movement. Block cloud metadata endpoints to prevent credential theft.

Firewall Configuration

UFW (Uncomplicated Firewall)

Configure deny-by-default firewall rules with explicit allow-lists.

#!/bin/bash
# UFW firewall configuration for GitHub Actions runner

set -euo pipefail

echo "==> Configuring firewall for runner network isolation"

# Reset to clean state
ufw --force reset

# Default policies: deny all incoming and outgoing
ufw default deny incoming
ufw default deny outgoing
ufw default deny routed

# Allow outbound to GitHub (required for runner operation)
# GitHub IP ranges: https://api.github.com/meta
ufw allow out to 140.82.112.0/20 port 443 proto tcp comment 'GitHub API'
ufw allow out to 143.55.64.0/20 port 443 proto tcp comment 'GitHub API'
ufw allow out to 185.199.108.0/22 port 443 proto tcp comment 'GitHub Pages/Assets'
ufw allow out to 192.30.252.0/22 port 443 proto tcp comment 'GitHub API'

# Allow outbound DNS
ufw allow out to any port 53 proto udp comment 'DNS'

# Allow outbound NTP (if time sync required)
ufw allow out to any port 123 proto udp comment 'NTP'

# Allow outbound to package registries (add as needed)
# Example: PyPI
ufw allow out to any port 443 proto tcp comment 'PyPI'

# Deny outbound to cloud metadata endpoints
ufw deny out to 169.254.169.254 comment 'Block AWS/GCP metadata'
ufw deny out to fd00:ec2::254 comment 'Block AWS IMDSv2 IPv6'

# Deny outbound to private networks (prevent internal reconnaissance)
ufw deny out to 10.0.0.0/8 comment 'Block RFC1918 10.0.0.0/8'
ufw deny out to 172.16.0.0/12 comment 'Block RFC1918 172.16.0.0/12'
ufw deny out to 192.168.0.0/16 comment 'Block RFC1918 192.168.0.0/16'

# Enable firewall
ufw --force enable

# Display rules
ufw status verbose

echo "==> Firewall configured with deny-by-default policies"

Network Namespace Isolation

Use Linux network namespaces to isolate runner network access (advanced).

#!/bin/bash
# Create isolated network namespace for runner jobs

set -euo pipefail

NAMESPACE="runner-isolated"
VETH_HOST="veth-runner0"
VETH_NS="veth-runner1"

# Create network namespace
ip netns add "$NAMESPACE"

# Create veth pair
ip link add "$VETH_HOST" type veth peer name "$VETH_NS"

# Move one end to namespace
ip link set "$VETH_NS" netns "$NAMESPACE"

# Configure host side
ip addr add 10.200.1.1/24 dev "$VETH_HOST"
ip link set "$VETH_HOST" up

# Configure namespace side
ip netns exec "$NAMESPACE" ip addr add 10.200.1.2/24 dev "$VETH_NS"
ip netns exec "$NAMESPACE" ip link set "$VETH_NS" up
ip netns exec "$NAMESPACE" ip link set lo up

# Set default route in namespace
ip netns exec "$NAMESPACE" ip route add default via 10.200.1.1

# Enable NAT for outbound traffic
iptables -t nat -A POSTROUTING -s 10.200.1.0/24 -j MASQUERADE

# Run runner in isolated namespace
ip netns exec "$NAMESPACE" sudo -u github-runner /opt/github-runner/bin/Runner.Listener

Cloud Metadata Endpoint Blocking

Prevent workflows from stealing cloud credentials via metadata endpoints.

iptables Rules

#!/bin/bash
# Block access to cloud metadata endpoints

set -euo pipefail

# AWS IMDSv1 (169.254.169.254)
iptables -A OUTPUT -d 169.254.169.254 -j REJECT --reject-with icmp-port-unreachable

# AWS IMDSv2 IPv6 (fd00:ec2::254)
ip6tables -A OUTPUT -d fd00:ec2::254/128 -j REJECT --reject-with icmp6-port-unreachable

# GCP metadata endpoint (metadata.google.internal)
iptables -A OUTPUT -d 169.254.169.254 -p tcp --dport 80 -j REJECT --reject-with tcp-reset
iptables -A OUTPUT -d 169.254.169.254 -p tcp --dport 8080 -j REJECT --reject-with tcp-reset

# Azure metadata endpoint (169.254.169.254)
iptables -A OUTPUT -d 169.254.169.254 -p tcp --dport 80 -j REJECT --reject-with tcp-reset

# Make persistent
iptables-save > /etc/iptables/rules.v4
ip6tables-save > /etc/iptables/rules.v6

IMDSv2 Enforcement (AWS)

If runners must access AWS metadata, enforce IMDSv2 with token requirement.

# Force IMDSv2 on EC2 instance (requires session token)
aws ec2 modify-instance-metadata-options \
  --instance-id i-1234567890abcdef0 \
  --http-tokens required \
  --http-put-response-hop-limit 1

IMDSv2 protection: Requires HTTP PUT to obtain session token before metadata access. Prevents SSRF-based credential theft.

Credential Protection

Eliminate long-lived credentials. Use short-lived tokens with minimal scope.

Use OpenID Connect federation to mint temporary credentials per job. No stored secrets.

# .github/workflows/deploy-with-oidc.yml
# Secretless authentication using OIDC

name: Deploy with OIDC
on:
  push:
    branches: [main]

permissions:
  id-token: write  # Required for OIDC token
  contents: read

jobs:
  deploy:
    runs-on: self-hosted
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11  # v4.1.1

      - name: Authenticate to GCP
        uses: google-github-actions/auth@55bd3a7c6e2ae7cf1877fd1ccb9d54c0503c457c  # v2.1.2
        with:
          workload_identity_provider: 'projects/123456789/locations/global/workloadIdentityPools/github-pool/providers/github-provider'
          service_account: 'github-runner@my-project.iam.gserviceaccount.com'

      - name: Deploy to Cloud Run
        run: |
          gcloud run deploy myapp \
            --image gcr.io/my-project/myapp:${{ github.sha }} \
            --region us-central1

Security benefits:

  • No stored secrets in repository or runner
  • Short-lived credentials (1 hour maximum)
  • Subject claim validation prevents token reuse
  • Audit trail via cloud IAM logs

See OIDC Federation Patterns for complete setup.

Environment Variable Injection

If secrets are required, inject via environment variables with minimal lifetime.

# Secure secret injection pattern

jobs:
  deploy:
    runs-on: self-hosted
    steps:
      - name: Deploy with secret
        env:
          # Secret available only during this step
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
        run: |
          # Use secret directly from environment
          curl -H "Authorization: Bearer $DEPLOY_TOKEN" https://api.example.com/deploy

      # Secret no longer available in subsequent steps
      - name: Cleanup
        run: echo "Secret was never written to disk"

Avoid:

  • Writing secrets to files (persists on disk, leaks to logs)
  • Exporting secrets to shell environment (accessible to subprocesses)
  • Passing secrets as command arguments (visible in process list)

Secrets Masking Verification

Verify GitHub masks secrets in logs automatically.

```yaml

Test secrets masking

jobs: test-masking: runs-on: self-hosted steps: - name: Verify secret masking env: TEST_SECRET: ${{ secrets.TEST_SECRET }} run: | # This will appear as *** in logs echo "Secret value: $TEST_SECRET"

      # This will also be masked
      echo "$TEST_SECRET" | base64

      # Verify masking works
      if echo "$TEST_SECRET" | grep -q "secret-value"; then
        echo "::error::Secret masking failed!"

Comments