Created
July 4, 2026 01:02
-
-
Save mohashari/8a73ec2977ba8b9cb0dbf95243077d09 to your computer and use it in GitHub Desktop.
Mitigating SSRF in Serverless: Designing a Dynamic Envoy Proxy Sidecar for AWS ECS Tasks — code snippets
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| package main | |
| import ( | |
| "context" | |
| "errors" | |
| "fmt" | |
| "net" | |
| "net/http" | |
| "net/url" | |
| "time" | |
| ) | |
| // FlawedFetch attempts to prevent SSRF by checking the IP of a hostname before fetching. | |
| // This function is vulnerable to DNS Rebinding (TOCTOU) and HTTP Redirection bypasses. | |
| func FlawedFetch(rawURL string) ([]byte, error) { | |
| parsedURL, err := url.Parse(rawURL) | |
| if err != nil { | |
| return nil, fmt.Errorf("invalid URL: %w", err) | |
| } | |
| // 1. Resolve host to check IP (Time of Check) | |
| ips, err := net.LookupIP(parsedURL.Hostname()) | |
| if err != nil { | |
| return nil, fmt.Errorf("DNS resolution failed: %w", err) | |
| } | |
| for _, ip := range ips { | |
| if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsPrivate() { | |
| return nil, errors.New("request blocked: destination resolved to private IP") | |
| } | |
| } | |
| // 2. Perform the actual HTTP fetch (Time of Use) | |
| // The HTTP client will resolve the host AGAIN if the TTL expired or was 0. | |
| // If the host is attacker-controlled, it can yield an internal IP on the second lookup. | |
| // Furthermore, the client will follow 3xx redirects to internal resources. | |
| client := &http.Client{ | |
| Timeout: 5 * time.Second, | |
| } | |
| req, err := http.NewRequestWithContext(context.Background(), "GET", rawURL, nil) | |
| if err != nil { | |
| return nil, err | |
| } | |
| resp, err := client.Do(req) | |
| if err != nil { | |
| return nil, err | |
| } | |
| defer resp.Body.Close() | |
| if resp.StatusCode != http.StatusOK { | |
| return nil, fmt.Errorf("http error: %d", resp.StatusCode) | |
| } | |
| return []byte("data fetched"), nil | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # Configure iptables rules to redirect outgoing TCP traffic to Envoy. | |
| # Runs inside the ECS task initialization or via an init container. | |
| set -euo pipefail | |
| ENVOY_PORT="15001" | |
| ENVOY_USER_ID="1337" # UID of the Envoy container user | |
| # Create target chain | |
| iptables -t nat -N ENVOY_EGRESS | |
| # Rule 1: Allow Envoy container itself to bypass routing rules (prevents infinite loop) | |
| iptables -t nat -A ENVOY_EGRESS -m owner --uid-owner "${ENVOY_USER_ID}" -j RETURN | |
| # Rule 2: Skip redirection for local inter-container traffic (e.g. local APIs, databases) | |
| iptables -t nat -A ENVOY_EGRESS -d 127.0.0.1/32 -j RETURN | |
| # Rule 3: Redirect standard outbound HTTP (80) and HTTPS (443) to Envoy | |
| iptables -t nat -A ENVOY_EGRESS -p tcp --dport 80 -j REDIRECT --to-ports "${ENVOY_PORT}" | |
| iptables -t nat -A ENVOY_EGRESS -p tcp --dport 443 -j REDIRECT --to-ports "${ENVOY_PORT}" | |
| # Apply the egress chain to all outbound traffic | |
| iptables -t nat -A OUTPUT -p tcp -j ENVOY_EGRESS | |
| echo "iptables rules applied. Egress traffic redirected to port ${ENVOY_PORT}." |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| node: | |
| id: ecs-egress-proxy | |
| cluster: sidecar-egress | |
| static_resources: | |
| listeners: | |
| - name: egress_http_listener | |
| address: | |
| socket_address: | |
| address: 127.0.0.1 | |
| port_value: 15001 | |
| filter_chains: | |
| - filters: | |
| - name: envoy.filters.network.http_connection_manager | |
| typed_config: | |
| "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager | |
| stat_prefix: egress_http | |
| route_config: | |
| name: egress_route | |
| virtual_hosts: | |
| - name: any_external_host | |
| domains: ["*"] | |
| routes: | |
| - match: | |
| prefix: "/" | |
| route: | |
| cluster: secure_egress_cluster | |
| timeout: 10s | |
| http_filters: | |
| # 1. RBAC Filter: Blocks SSRF vectors based on request parameters | |
| - name: envoy.filters.http.rbac | |
| typed_config: | |
| "@type": type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBAC | |
| rules: | |
| action: DENY | |
| policies: | |
| "block-aws-metadata": | |
| permissions: | |
| - any: true | |
| principals: | |
| - any: true | |
| condition: | |
| or_rules: | |
| rules: | |
| # Block literal requests to AWS IMDSv2 | |
| - destination_ip: | |
| address_prefix: 169.254.169.254 | |
| prefix_len: 32 | |
| # Block literal requests to VPC RFC 1918 CIDRs | |
| - destination_ip: | |
| address_prefix: 10.0.0.0 | |
| prefix_len: 8 | |
| - destination_ip: | |
| address_prefix: 172.16.0.0 | |
| prefix_len: 12 | |
| - destination_ip: | |
| address_prefix: 192.168.0.0 | |
| prefix_len: 16 | |
| # 2. Dynamic Forward Proxy Filter: Resolves hostnames securely | |
| - name: envoy.filters.http.dynamic_forward_proxy | |
| typed_config: | |
| "@type": type.googleapis.com/envoy.extensions.filters.http.dynamic_forward_proxy.v3.FilterConfig | |
| dns_cache_config: | |
| name: secure_dns_cache | |
| dns_lookup_family: V4_ONLY | |
| dns_resolution_config: | |
| dns_resolver_options: | |
| use_tcp_for_dns_lookups: false | |
| no_default_search_domain: true | |
| # 3. Standard Router Filter | |
| - name: envoy.filters.http.router | |
| typed_config: | |
| "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router | |
| clusters: | |
| - name: secure_egress_cluster | |
| connect_timeout: 5s | |
| lb_policy: CLUSTER_PROVIDED | |
| cluster_type: | |
| name: envoy.clusters.dynamic_forward_proxy | |
| typed_config: | |
| "@type": type.googleapis.com/envoy.extensions.clusters.dynamic_forward_proxy.v3.ClusterConfig | |
| dns_cache_config: | |
| name: secure_dns_cache | |
| dns_lookup_family: V4_ONLY | |
| transport_socket: | |
| name: envoy.transport_sockets.tls | |
| typed_config: | |
| "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext | |
| common_tls_context: | |
| validation_context: | |
| trusted_ca: | |
| filename: /etc/ssl/certs/ca-certificates.crt |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Upstream network-level filter configuration within Envoy clusters. | |
| # This checks resolved IPs before opening TCP sockets, blocking DNS rebinding. | |
| upstream_config: | |
| upstream_filters: | |
| - name: envoy.filters.upstream.rbac | |
| typed_config: | |
| "@type": type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBAC | |
| rules: | |
| action: DENY | |
| policies: | |
| "enforce-public-ips-only": | |
| permissions: | |
| - any: true | |
| principals: | |
| - any: true | |
| condition: | |
| or_rules: | |
| rules: | |
| # Block connection if the resolved IP is private or link-local | |
| - destination_ip: | |
| address_prefix: 169.254.0.0 | |
| prefix_len: 16 | |
| - destination_ip: | |
| address_prefix: 10.0.0.0 | |
| prefix_len: 8 | |
| - destination_ip: | |
| address_prefix: 172.16.0.0 | |
| prefix_len: 12 | |
| - destination_ip: | |
| address_prefix: 192.168.0.0 | |
| prefix_len: 16 | |
| - destination_ip: | |
| address_prefix: 127.0.0.0 | |
| prefix_len: 8 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| { | |
| "family": "secure-app-task", | |
| "networkMode": "awsvpc", | |
| "requiresCompatibilities": [ | |
| "FARGATE" | |
| ], | |
| "cpu": "512", | |
| "memory": "1024", | |
| "containerDefinitions": [ | |
| { | |
| "name": "envoy-sidecar", | |
| "image": "envoyproxy/envoy:v1.28.0", | |
| "essential": true, | |
| "user": "1337", | |
| "portMappings": [ | |
| { | |
| "containerPort": 15001, | |
| "hostPort": 15001, | |
| "protocol": "tcp" | |
| } | |
| ], | |
| "healthCheck": { | |
| "command": [ | |
| "CMD-SHELL", | |
| "curl -f http://127.0.0.1:15001/ready || exit 1" | |
| ], | |
| "interval": 5, | |
| "timeout": 2, | |
| "retries": 3, | |
| "startPeriod": 10 | |
| }, | |
| "logConfiguration": { | |
| "logDriver": "awslogs", | |
| "options": { | |
| "awslogs-group": "/ecs/secure-app-envoy", | |
| "awslogs-region": "ap-southeast-1", | |
| "awslogs-stream-prefix": "envoy" | |
| } | |
| } | |
| }, | |
| { | |
| "name": "application-api", | |
| "image": "your-org/api-service:latest", | |
| "essential": true, | |
| "dependsOn": [ | |
| { | |
| "containerName": "envoy-sidecar", | |
| "condition": "HEALTHY" | |
| } | |
| ], | |
| "environment": [ | |
| { | |
| "name": "HTTP_PROXY", | |
| "value": "http://127.0.0.1:15001" | |
| }, | |
| { | |
| "name": "HTTPS_PROXY", | |
| "value": "http://127.0.0.1:15001" | |
| }, | |
| { | |
| "name": "NO_PROXY", | |
| "value": "127.0.0.1,localhost,169.254.170.2" | |
| } | |
| ], | |
| "logConfiguration": { | |
| "logDriver": "awslogs", | |
| "options": { | |
| "awslogs-group": "/ecs/secure-app", | |
| "awslogs-region": "ap-southeast-1", | |
| "awslogs-stream-prefix": "app" | |
| } | |
| } | |
| } | |
| ] | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| package main | |
| import ( | |
| "context" | |
| "crypto/tls" | |
| "fmt" | |
| "io" | |
| "net/http" | |
| "net/url" | |
| "time" | |
| ) | |
| // NewSecureClient returns an HTTP client configured to route traffic explicitly | |
| // through the local Envoy sidecar proxy. | |
| func NewSecureClient(proxyURLStr string) (*http.Client, error) { | |
| proxyURL, err := url.Parse(proxyURLStr) | |
| if err != nil { | |
| return nil, fmt.Errorf("invalid proxy URL: %w", err) | |
| } | |
| transport := &http.Transport{ | |
| Proxy: http.ProxyURL(proxyURL), | |
| // Configure TLS configuration to enforce safe defaults | |
| TLSClientConfig: &tls.Config{ | |
| MinVersion: tls.VersionTLS12, | |
| }, | |
| MaxIdleConns: 100, | |
| IdleConnTimeout: 90 * time.Second, | |
| TLSHandshakeTimeout: 5 * time.Second, | |
| ExpectContinueTimeout: 1 * time.Second, | |
| } | |
| return &http.Client{ | |
| Transport: transport, | |
| Timeout: 10 * time.Second, | |
| }, nil | |
| } | |
| func main() { | |
| // Rooted HTTP Proxy pointing to local Envoy sidecar | |
| client, err := NewSecureClient("http://127.0.0.1:15001") | |
| if err != nil { | |
| panic(err) | |
| } | |
| req, err := http.NewRequestWithContext(context.Background(), "GET", "https://api.stripe.com/v1/charges", nil) | |
| if err != nil { | |
| panic(err) | |
| } | |
| resp, err := client.Do(req) | |
| if err != nil { | |
| fmt.Printf("Connection error (blocked or offline): %v\n", err) | |
| return | |
| } | |
| defer resp.Body.Close() | |
| body, _ := io.ReadAll(resp.Body) | |
| fmt.Printf("Response code: %d, body length: %d\n", resp.StatusCode, len(body)) | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment