Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions internal/effects/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import (
"bufio"
"context"
"io"
"net"
"net/http"
"net/url"
"os"
"strconv"
"time"
Expand Down Expand Up @@ -196,6 +199,17 @@ type NetContext struct {
AllowMetadata bool // Allow cloud metadata server at 169.254.169.254 (default: false)
AllowedDomains []string // Domain allowlist (empty = all allowed)
UserAgent string // User-Agent header

// The following hooks are unexported and nil in production. They let
// package-internal tests inject resolver/dial/proxy-selection behavior so
// the proxied-vs-direct routing guarantees (exactly-once direct resolution;
// zero proxy-route resolution; proxy address never fed to the target-IP
// substitution) are falsifiable with call counters and are order-independent
// in-process. When nil, the real net.LookupIP / net.Dialer /
// http.ProxyFromEnvironment are used.
lookupIP func(hostname string) ([]net.IP, error)
dialContext func(ctx context.Context, network, addr string) (net.Conn, error)
proxySelector func(req *http.Request) (*url.URL, error)
}

// NewNetContext creates a new net context with secure defaults
Expand Down
100 changes: 31 additions & 69 deletions internal/effects/net.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,8 @@ package effects

import (
"bytes"
"context"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
Expand Down Expand Up @@ -81,36 +79,18 @@ func netHTTPGet(ctx *EffContext, args []eval.Value) (eval.Value, error) {
return nil, fmt.Errorf("E_NET_DOMAIN_BLOCKED: domain not in allowlist: %s", u.Hostname())
}

// Step 4: DNS resolution + IP validation (prevent DNS rebinding)
validatedIP, err := resolveAndValidateIP(u.Hostname(), ctx)
if err != nil {
return nil, err
}

// Step 5: Build HTTP client with security config
// Step 4: Build HTTP client with security config. Route selection (direct
// IP-pinned vs proxied) and target resolution+validation happen inside the
// request-aware RoundTripper, once per round trip (see net_proxy.go).
client := &http.Client{
Timeout: ctx.Net.Timeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return validateRedirect(req, via, ctx)
},
Transport: &http.Transport{
// Force connection to validated IP (prevent DNS rebinding mid-request)
DialContext: func(ctxDial context.Context, network, addr string) (net.Conn, error) {
// Replace hostname with validated IP in dial address
_, port, _ := net.SplitHostPort(addr)
if port == "" {
port = "443" // Default HTTPS port
if u.Scheme == "http" {
port = "80"
}
}
dialAddr := net.JoinHostPort(validatedIP, port)
return (&net.Dialer{}).DialContext(ctxDial, network, dialAddr)
},
},
Transport: &netProxyRoundTripper{ctx: ctx},
}

// Step 6: Make request with proper headers
// Step 5: Make request with proper headers
req, err := http.NewRequest("GET", urlStr.Value, nil)
if err != nil {
return nil, fmt.Errorf("E_NET_REQUEST_FAILED: %w", err)
Expand All @@ -120,6 +100,9 @@ func netHTTPGet(ctx *EffContext, args []eval.Value) (eval.Value, error) {

resp, err := client.Do(req)
if err != nil {
if orig := unwrapTargetValidation(err); orig != nil {
return nil, orig
}
return nil, fmt.Errorf("E_NET_REQUEST_FAILED: %w", err)
}
defer resp.Body.Close()
Expand Down Expand Up @@ -197,34 +180,17 @@ func netHTTPPost(ctx *EffContext, args []eval.Value) (eval.Value, error) {
return nil, fmt.Errorf("E_NET_DOMAIN_BLOCKED: domain not in allowlist: %s", u.Hostname())
}

// Step 4: DNS resolution + IP validation
validatedIP, err := resolveAndValidateIP(u.Hostname(), ctx)
if err != nil {
return nil, err
}

// Step 5: Build HTTP client with security config
// Step 4: Build HTTP client with security config (see net_proxy.go for
// direct/proxy routing and once-per-round-trip target resolution).
client := &http.Client{
Timeout: ctx.Net.Timeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return validateRedirect(req, via, ctx)
},
Transport: &http.Transport{
DialContext: func(ctxDial context.Context, network, addr string) (net.Conn, error) {
_, port, _ := net.SplitHostPort(addr)
if port == "" {
port = "443"
if u.Scheme == "http" {
port = "80"
}
}
dialAddr := net.JoinHostPort(validatedIP, port)
return (&net.Dialer{}).DialContext(ctxDial, network, dialAddr)
},
},
Transport: &netProxyRoundTripper{ctx: ctx},
}

// Step 6: Make POST request
// Step 5: Make POST request
req, err := http.NewRequest("POST", urlStr.Value, strings.NewReader(bodyStr.Value))
if err != nil {
return nil, fmt.Errorf("E_NET_REQUEST_FAILED: %w", err)
Expand All @@ -235,6 +201,9 @@ func netHTTPPost(ctx *EffContext, args []eval.Value) (eval.Value, error) {

resp, err := client.Do(req)
if err != nil {
if orig := unwrapTargetValidation(err); orig != nil {
return nil, orig
}
return nil, fmt.Errorf("E_NET_REQUEST_FAILED: %w", err)
}
defer resp.Body.Close()
Expand Down Expand Up @@ -313,9 +282,11 @@ func validateRedirect(req *http.Request, via []*http.Request, ctx *EffContext) e
return err
}

// Re-validate IP for redirect target (prevent DNS rebinding via redirect)
_, err := resolveAndValidateIP(req.URL.Hostname(), ctx)
return err
// NB: redirect target resolution + IP validation is deliberately NOT done
// here. It happens per round trip inside the request-aware RoundTripper
// (net_proxy.go): proxied redirects resolve nothing; direct redirects
// resolve+validate the new target exactly once before dialing it.
return nil
}

// isAllowedDomain checks if a hostname is in the domain allowlist
Expand Down Expand Up @@ -561,19 +532,19 @@ func buildSecureRequest(
return nil, nil, makeResultErr("DisallowedHost", u.Hostname())
}

// DNS resolution + IP validation (prevent DNS rebinding)
validatedIP, err := resolveAndValidateIP(u.Hostname(), ctx)
if err != nil {
return nil, nil, makeResultErr("Transport", err.Error())
}
// NB: DNS resolution + IP validation is NOT done here as preflight. It
// happens exactly once per direct round trip inside the request-aware
// RoundTripper before dialing (see net_proxy.go). Proxied round trips
// perform no local target resolution at all.

// Parse and validate headers
userHeaders, err := parseHeaders(headersList)
if err != nil {
return nil, nil, makeResultErr("InvalidHeader", err.Error())
}

// Build HTTP client with security config (DialContext pinned to validated IP)
// Build HTTP client with security config (direct/proxy routing lives in the
// request-aware RoundTripper, net_proxy.go).
originalHost := u.Host // Save for cross-origin detection
client := &http.Client{
Timeout: ctx.Net.Timeout,
Expand All @@ -584,19 +555,7 @@ func buildSecureRequest(
}
return validateRedirect(req, via, ctx)
},
Transport: &http.Transport{
DialContext: func(ctxDial context.Context, network, addr string) (net.Conn, error) {
_, port, _ := net.SplitHostPort(addr)
if port == "" {
port = "443"
if u.Scheme == "http" {
port = "80"
}
}
dialAddr := net.JoinHostPort(validatedIP, port)
return (&net.Dialer{}).DialContext(ctxDial, network, dialAddr)
},
},
Transport: &netProxyRoundTripper{ctx: ctx},
}

// Build request
Expand Down Expand Up @@ -628,7 +587,10 @@ func buildSecureRequest(
func executeAndBuildResponse(ctx *EffContext, client *http.Client, req *http.Request) (eval.Value, error) {
resp, err := client.Do(req)
if err != nil {
return makeResultErr("Transport", err.Error()), nil
// transportMessage unwraps the typed target-validation error out of the
// *url.Error wrapper to preserve the original E_NET_DNS_FAILED /
// E_NET_IP_BLOCKED category text from the direct route.
return makeResultErr("Transport", transportMessage(err)), nil
}
defer resp.Body.Close()

Expand Down
150 changes: 150 additions & 0 deletions internal/effects/net_proxy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package effects

import (
"context"
"errors"
"net"
"net/http"
"net/url"
)

// targetValidationError is a typed, internal error returned by the direct
// route of the request-aware RoundTripper when target resolution or IP
// validation fails (before any dial is attempted). It carries the original
// E_NET_DNS_FAILED / E_NET_IP_BLOCKED category so that public callers can
// surface the stable legacy category even after http.Client.Do wraps it in a
// *url.Error. Production code in this package never treats it as a user
// visible error type — it is unwrapped through url.Error instead.
type targetValidationError struct {
cause error
}

func (e *targetValidationError) Error() string { return e.cause.Error() }
func (e *targetValidationError) Unwrap() error { return e.cause }

// netProxyRoundTripper is the package-private, request-aware RoundTripper that
// routes every Net request through either a direct (IP-pinned) transport or a
// proxy transport, decided per request by http.ProxyFromEnvironment(req).
//
// Security contract:
// - no proxy selected: resolveAndValidateIP is called exactly once, and the
// returned IP is handed to a direct transport whose dialer connects to that
// IP with no hostname re-resolution (anti-DNS-rebinding pinning).
// - proxy selected: ordinary proxy dialing only — zero local target
// resolution and zero IP validation. The proxy address never enters any
// target-IP substitution closure.
//
// It owns separate transport creation paths for the two modes and never
// mutates one shared transport between them. Each round trip builds a fresh
// transport so no pin or route decision can bleed across requests.
type netProxyRoundTripper struct {
ctx *EffContext
}

// RoundTrip implements http.RoundTripper.
func (rt *netProxyRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
proxyURL, err := rt.selectProxy(req)
if err != nil {
return nil, err
}
if proxyURL != nil {
return rt.proxyRoundTrip(req, proxyURL)
}
return rt.directRoundTrip(req)
}

// selectProxy returns the proxy to use for the request. It defaults to the
// real http.ProxyFromEnvironment; an injected test hook (ctx.Net.proxySelector)
// can override it for deterministic in-process tests.
func (rt *netProxyRoundTripper) selectProxy(req *http.Request) (*url.URL, error) {
if rt.ctx != nil && rt.ctx.Net != nil && rt.ctx.Net.proxySelector != nil {
return rt.ctx.Net.proxySelector(req)
}
return http.ProxyFromEnvironment(req)
}

// directRoundTrip resolves+validates the target exactly once and dials the
// validated IP with no hostname re-resolution.
func (rt *netProxyRoundTripper) directRoundTrip(req *http.Request) (*http.Response, error) {
validatedIP, err := resolveAndValidateIP(req.URL.Hostname(), rt.ctx)
if err != nil {
return nil, &targetValidationError{cause: err}
}
tr := rt.directTransport(validatedIP, req.URL)
// A per-request transport has no shared keep-alive pool to preserve; close
// idle conns so the response body read is unaffected and nothing lingers.
defer tr.CloseIdleConnections()
return tr.RoundTrip(req)
}

// proxyRoundTrip performs ordinary proxy dialing and performs no local target
// resolution or IP validation.
func (rt *netProxyRoundTripper) proxyRoundTrip(req *http.Request, proxyURL *url.URL) (*http.Response, error) {
tr := rt.proxyTransport(proxyURL)
defer tr.CloseIdleConnections()
return tr.RoundTrip(req)
}

// directTransport builds a transport whose dialer replaces the requested dial
// host with the pre-validated target IP. It never uses a proxy.
func (rt *netProxyRoundTripper) directTransport(validatedIP string, u *url.URL) *http.Transport {
return &http.Transport{
// nil Proxy: this route was already selected as the direct path.
DialContext: func(ctxDial context.Context, network, addr string) (net.Conn, error) {
_, port, _ := net.SplitHostPort(addr)
if port == "" {
port = "443"
if u.Scheme == "http" {
port = "80"
}
}
dialAddr := net.JoinHostPort(validatedIP, port)
return rt.dial(ctxDial, network, dialAddr)
},
}
}

// proxyTransport builds a transport that dials the operator-selected proxy
// using ordinary proxy semantics (CONNECT for TLS, absolute-form otherwise).
// The proxy address never enters a target-IP substitution closure.
func (rt *netProxyRoundTripper) proxyTransport(proxyURL *url.URL) *http.Transport {
return &http.Transport{
Proxy: http.ProxyURL(proxyURL),
DialContext: func(ctxDial context.Context, network, addr string) (net.Conn, error) {
// In proxy mode addr is the proxy's address (CONNECT/absolute-form
// dial destination). No target-IP substitution is applied here.
return rt.dial(ctxDial, network, addr)
},
}
}

// dial dispatches to the injected dialer hook when configured (tests), else to
// the ordinary net.Dialer.
func (rt *netProxyRoundTripper) dial(ctx context.Context, network, addr string) (net.Conn, error) {
if rt.ctx != nil && rt.ctx.Net != nil && rt.ctx.Net.dialContext != nil {
return rt.ctx.Net.dialContext(ctx, network, addr)
}
return (&net.Dialer{}).DialContext(ctx, network, addr)
}

// unwrapTargetValidation returns the original typed target-validation error if
// err (which http.Client.Do returns wrapped in a *url.Error) carries a
// targetValidationError from the direct route; otherwise it returns nil.
func unwrapTargetValidation(err error) error {
var tve *targetValidationError
if errors.As(err, &tve) {
return tve.cause
}
return nil
}

// transportMessage returns the public message to surface for a client.Do error,
// preserving the original E_NET_DNS_FAILED / E_NET_IP_BLOCKED text when the
// failure is our typed target-validation error, and the url.Error text
// otherwise.
func transportMessage(err error) string {
if orig := unwrapTargetValidation(err); orig != nil {
return orig.Error()
}
return err.Error()
}
Loading
Loading