mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 11:40:53 +08:00
ProxyAddr returns a URL that already carries a scheme (the recording proxy and the validated global proxy), but httpProxyTransport wrote "http://" before it, so use_recording_proxy produced http://http://... proxyURLString now adds http:// only to a bare host:port, for both the operator proxy and ProxyAddr. httpToolTransport falls back to a fresh http.Transport when http.DefaultTransport is not an *http.Transport.
359 lines
15 KiB
Go
359 lines
15 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Autumn-27/artex/db"
|
|
"github.com/Autumn-27/artex/intercept"
|
|
)
|
|
|
|
// Step 2.1: the custom http tool connects directly through the pinned dialer
|
|
// in a gated scope, and a gated worker's WebFetch end to end.
|
|
|
|
// routeDialer is the base dialer of the gate in these tests: it records every
|
|
// address, connects the routed TEST-NET addresses to local listeners, and
|
|
// refuses the rest.
|
|
type routeDialer struct {
|
|
mu sync.Mutex
|
|
routes map[string]string
|
|
dialed []string
|
|
}
|
|
|
|
func newRouteDialer(g *gateServer) *routeDialer {
|
|
d := &routeDialer{routes: map[string]string{}}
|
|
g.m.interceptor.SetBaseDialer(d.dial)
|
|
return d
|
|
}
|
|
|
|
func (d *routeDialer) route(addr, to string) {
|
|
d.mu.Lock()
|
|
d.routes[addr] = to
|
|
d.mu.Unlock()
|
|
}
|
|
|
|
func (d *routeDialer) dial(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
d.mu.Lock()
|
|
d.dialed = append(d.dialed, addr)
|
|
to, ok := d.routes[addr]
|
|
d.mu.Unlock()
|
|
if !ok {
|
|
return nil, &net.OpError{Op: "dial", Net: network, Err: errors.New("no route in the test dialer")}
|
|
}
|
|
return (&net.Dialer{}).DialContext(ctx, network, to)
|
|
}
|
|
|
|
func (d *routeDialer) calls() []string {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
return slices.Clone(d.dialed)
|
|
}
|
|
|
|
func (r *countingResolver) set(host string, ips ...string) {
|
|
r.mu.Lock()
|
|
r.answers[host] = ips
|
|
r.mu.Unlock()
|
|
}
|
|
|
|
// approveTarget opens the request of a gated http tool call on key and approves it.
|
|
func approveTarget(t *testing.T, g *gateServer, task *Task, tool interface {
|
|
Name() string
|
|
}, call func() (string, bool), key string) {
|
|
t.Helper()
|
|
if out, isErr := call(); !isErr || !strings.Contains(out, "TARGET_APPROVAL_PENDING") {
|
|
t.Fatalf("%s before the approval: error=%v out=%q", tool.Name(), isErr, out)
|
|
}
|
|
id, _, _, ok := openTargetRequest(t, g.m.pg, task.ID, key)
|
|
if !ok {
|
|
t.Fatalf("no request on %s", key)
|
|
}
|
|
decide(t, g, id, "allowed")
|
|
}
|
|
|
|
// A gated direct call reaches its target at the pinned address; once the name
|
|
// answers another address, the rendered-URL gate passes on the cached answer
|
|
// and the dial connects nowhere.
|
|
func TestCustomHTTPDirectDialPinned(t *testing.T) {
|
|
g := newGateServer(t)
|
|
on := true
|
|
task := g.newTask(t, db.TaskCreateOptions{TargetGate: &on})
|
|
srv, hits, _, port := countingServer(t)
|
|
res := &countingResolver{answers: map[string][]string{"api.example.com": {"203.0.113.7"}}}
|
|
g.m.interceptor.SetResolver(res)
|
|
dialer := newRouteDialer(g)
|
|
dialer.route("203.0.113.7:"+port, srv.Listener.Addr().String())
|
|
dialer.route("203.0.113.9:"+port, srv.Listener.Addr().String())
|
|
key := "api.example.com:" + port
|
|
tool := g.s.buildCustomTool(&db.Tool{Key: "probe_dial", Kind: "http",
|
|
Exec: execJSON(t, map[string]any{"url": "http://" + key + "/dial"})})
|
|
call := func() (string, bool) { return callTool(t, tool, gatedCtx(task, 0), `{}`, nil) }
|
|
approveTarget(t, g, task, tool, call, key)
|
|
|
|
if out, isErr := call(); isErr || !strings.Contains(out, "probe-reached") || hits.Load() != 1 {
|
|
t.Fatalf("pinned call: error=%v out=%q hits=%d", isErr, out, hits.Load())
|
|
}
|
|
res.set("api.example.com", "203.0.113.9")
|
|
out, isErr := call()
|
|
if !isErr || !strings.Contains(out, "TARGET_APPROVAL_PENDING: the connection to "+key+" was not opened") || hits.Load() != 1 {
|
|
t.Fatalf("rebound name: error=%v out=%q hits=%d", isErr, out, hits.Load())
|
|
}
|
|
if got := dialer.calls(); !slices.Equal(got, []string{"203.0.113.7:" + port}) {
|
|
t.Fatalf("dialled %v, want the pinned address once", got)
|
|
}
|
|
if _, toolName, input, ok := openTargetRequest(t, g.m.pg, task.ID, key); !ok || toolName != "probe_dial" || !strings.Contains(input, "dial") {
|
|
t.Fatalf("request at the dial: ok=%v tool=%q input=%s", ok, toolName, input)
|
|
}
|
|
}
|
|
|
|
// With the gate off (and without a scope) the call keeps http.DefaultTransport:
|
|
// no transport is built. A spec with a proxy keeps its proxy transport, without
|
|
// a dialer. Gate on and direct: a transport with the pinned dialer, no
|
|
// environment proxy, keep-alive off.
|
|
func TestCustomHTTPGateOffTransportUnchanged(t *testing.T) {
|
|
s := &Server{}
|
|
direct := httpExec{URL: "http://api.example.com/"}
|
|
scope := func(on bool) context.Context {
|
|
return intercept.WithTargetScope(context.Background(), intercept.TargetScope{TaskID: "7", Enabled: func() bool { return on }})
|
|
}
|
|
for name, ctx := range map[string]context.Context{"no scope": context.Background(), "flag off": scope(false)} {
|
|
if tr := s.httpToolTransport(ctx, "probe", direct); tr != nil {
|
|
t.Fatalf("%s: transport %+v, want nil (http.DefaultTransport)", name, tr)
|
|
}
|
|
}
|
|
if tr := s.httpToolTransport(scope(true), "probe", httpExec{URL: direct.URL, Proxy: "http://127.0.0.1:1"}); tr == nil || tr.Proxy == nil || tr.DialContext != nil {
|
|
t.Fatalf("operator proxy: transport %+v, want the proxy transport without a dialer", tr)
|
|
}
|
|
if tr := s.httpToolTransport(scope(true), "probe", direct); tr == nil || tr.Proxy != nil || tr.DialContext == nil || !tr.DisableKeepAlives {
|
|
t.Fatalf("gated direct call: transport %+v", tr)
|
|
}
|
|
}
|
|
|
|
// The proxy URL of the http tool's transport: an address with a scheme is kept
|
|
// as is, a bare host:port gets "http://"; never a doubled scheme.
|
|
func TestHTTPProxyTransportProxyScheme(t *testing.T) {
|
|
req := httptest.NewRequest("GET", "http://api.example.com/", nil)
|
|
for _, c := range []struct {
|
|
name, globalProxy string
|
|
spec httpExec
|
|
want string
|
|
}{
|
|
{"recording proxy, global proxy with a scheme", "http://127.0.0.1:3128", httpExec{UseRecordingProxy: true}, "http://127.0.0.1:3128"},
|
|
{"operator proxy with a scheme", "", httpExec{Proxy: "http://10.0.0.5:8080"}, "http://10.0.0.5:8080"},
|
|
{"operator proxy as bare host:port", "", httpExec{Proxy: " 10.0.0.5:8080 "}, "http://10.0.0.5:8080"},
|
|
} {
|
|
s := &Server{m: &Manager{globalProxy: c.globalProxy}}
|
|
tr := s.httpProxyTransport(c.spec)
|
|
if tr == nil || tr.Proxy == nil {
|
|
t.Fatalf("%s: transport %+v, want a proxy transport", c.name, tr)
|
|
}
|
|
u, err := tr.Proxy(req)
|
|
if err != nil || u == nil || u.String() != c.want {
|
|
t.Fatalf("%s: Proxy(req) = %v, %v; want %s", c.name, u, err, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// passRoundTripper stands in for an http.DefaultTransport that is not an
|
|
// *http.Transport; it hands every request to the original transport.
|
|
type passRoundTripper struct{ next http.RoundTripper }
|
|
|
|
func (p passRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) {
|
|
return p.next.RoundTrip(r)
|
|
}
|
|
|
|
// With http.DefaultTransport replaced by another RoundTripper, a gated direct
|
|
// call still gets a transport with the pinned dialer instead of a panic.
|
|
func TestHTTPToolTransportDefaultTransportNotStd(t *testing.T) {
|
|
orig := http.DefaultTransport
|
|
http.DefaultTransport = passRoundTripper{next: orig}
|
|
defer func() { http.DefaultTransport = orig }()
|
|
ctx := intercept.WithTargetScope(context.Background(), intercept.TargetScope{TaskID: "7", Enabled: func() bool { return true }})
|
|
tr := (&Server{}).httpToolTransport(ctx, "probe", httpExec{URL: "http://api.example.com/"})
|
|
if tr == nil || tr.DialContext == nil {
|
|
t.Fatalf("transport %+v, want one with the pinned dialer", tr)
|
|
}
|
|
}
|
|
|
|
// A redirect of a pinned origin to a pinned hop: the hop is dialled at the
|
|
// hop's pinned address.
|
|
func TestCustomHTTPRedirectHopDialPinned(t *testing.T) {
|
|
g := newGateServer(t)
|
|
on := true
|
|
task := g.newTask(t, db.TaskCreateOptions{TargetGate: &on})
|
|
final, finalHits, _, finalPort := countingServer(t)
|
|
hopKey := "hop.example.com:" + finalPort
|
|
start, _, startAddr := redirectingServer(t, "http://"+hopKey+"/final")
|
|
_, startPort, _ := net.SplitHostPort(startAddr)
|
|
originKey := "api.example.com:" + startPort
|
|
res := &countingResolver{answers: map[string][]string{"api.example.com": {"203.0.113.7"}, "hop.example.com": {"203.0.113.8"}}}
|
|
g.m.interceptor.SetResolver(res)
|
|
dialer := newRouteDialer(g)
|
|
dialer.route("203.0.113.7:"+startPort, start.Listener.Addr().String())
|
|
dialer.route("203.0.113.8:"+finalPort, final.Listener.Addr().String())
|
|
// Were the hop dialled at the origin's address, it would still reach it.
|
|
dialer.route("203.0.113.7:"+finalPort, final.Listener.Addr().String())
|
|
tool := g.s.buildCustomTool(&db.Tool{Key: "probe_hop", Kind: "http",
|
|
Exec: execJSON(t, map[string]any{"url": "http://" + originKey + "/start"})})
|
|
call := func() (string, bool) { return callTool(t, tool, gatedCtx(task, 0), `{}`, nil) }
|
|
approveTarget(t, g, task, tool, call, originKey)
|
|
approveTarget(t, g, task, tool, call, hopKey)
|
|
|
|
before := len(dialer.calls())
|
|
if out, isErr := call(); isErr || !strings.Contains(out, "probe-reached") || finalHits.Load() != 1 {
|
|
t.Fatalf("pinned hop: error=%v out=%q", isErr, out)
|
|
}
|
|
if got := dialer.calls()[before:]; !slices.Equal(got, []string{"203.0.113.7:" + startPort, "203.0.113.8:" + finalPort}) {
|
|
t.Fatalf("dialled %v, want the origin's then the hop's pinned address", got)
|
|
}
|
|
}
|
|
|
|
// The custom-tool test endpoint has no scope (D14): its connection never goes
|
|
// through the gate's dialer, and nothing is written.
|
|
func TestCustomToolTestEndpointDialUngated(t *testing.T) {
|
|
g := newGateServer(t)
|
|
on := true
|
|
g.newTask(t, db.TaskCreateOptions{TargetGate: &on})
|
|
dialer := newRouteDialer(g)
|
|
_, hits, host, port := countingServer(t)
|
|
before := 0
|
|
if err := g.m.pg.QueryRow(`SELECT count(*) FROM intercept_pending WHERE decision_source='target' OR target_key IS NOT NULL`).Scan(&before); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
code, out := g.do(t, "POST", "/api/tools/custom/test", fmt.Sprintf(`{"kind":"http","exec":{"url":"http://%s:%s/test"}}`, host, port))
|
|
if code != 200 || out["is_error"] != false || hits.Load() != 1 || len(dialer.calls()) != 0 {
|
|
t.Fatalf("test endpoint: %d %v hits=%d dialled %v", code, out, hits.Load(), dialer.calls())
|
|
}
|
|
after := 0
|
|
if err := g.m.pg.QueryRow(`SELECT count(*) FROM intercept_pending WHERE decision_source='target' OR target_key IS NOT NULL`).Scan(&after); err != nil || after != before {
|
|
t.Fatalf("the test endpoint wrote %d target rows (%v)", after-before, err)
|
|
}
|
|
}
|
|
|
|
// Documents a known limit: a custom http tool that goes through a proxy is not
|
|
// bound to pins. The pinned dialer covers direct connections only; through a
|
|
// proxy the connection to the target is the proxy's, and the proxy here
|
|
// connects to a peer nobody pinned. Step 2.2 binds the recording proxy (and
|
|
// flips its half of this test); an operator-written proxy stays unbound
|
|
// (OPEN-6).
|
|
func TestBypass_CustomHTTPProxyPeerNotPinned(t *testing.T) {
|
|
g := newGateServer(t)
|
|
on := true
|
|
task := g.newTask(t, db.TaskCreateOptions{TargetGate: &on})
|
|
_, hits, _, port := countingServer(t)
|
|
peer := "127.0.0.1:" + port
|
|
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Whatever the request names, the proxy connects to peer.
|
|
resp, err := http.Get("http://" + peer + r.URL.Path)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadGateway)
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
_, _ = io.Copy(w, resp.Body)
|
|
}))
|
|
t.Cleanup(proxy.Close)
|
|
res := &countingResolver{answers: map[string][]string{"api.example.com": {"203.0.113.7"}}}
|
|
g.m.interceptor.SetResolver(res)
|
|
dialer := newRouteDialer(g)
|
|
key := "api.example.com:" + port
|
|
url := "http://" + key + "/bypass"
|
|
direct := g.s.buildCustomTool(&db.Tool{Key: "probe_bypass", Kind: "http", Exec: execJSON(t, map[string]any{"url": url})})
|
|
approveTarget(t, g, task, direct, func() (string, bool) { return callTool(t, direct, gatedCtx(task, 0), `{}`, nil) }, key)
|
|
|
|
// The global proxy carries a scheme, as in production (ProxyAddr returns it
|
|
// as is with capture off); httpProxyTransport must not write a second one.
|
|
g.m.mu.Lock()
|
|
g.m.globalProxy = proxy.URL
|
|
g.m.mu.Unlock()
|
|
t.Cleanup(func() {
|
|
g.m.mu.Lock()
|
|
g.m.globalProxy = ""
|
|
g.m.mu.Unlock()
|
|
})
|
|
for name, exec := range map[string]map[string]any{
|
|
"operator proxy": {"url": url, "proxy": proxy.URL},
|
|
"use_recording_proxy": {"url": url, "use_recording_proxy": true},
|
|
} {
|
|
before := hits.Load()
|
|
tool := g.s.buildCustomTool(&db.Tool{Key: "probe_bypass", Kind: "http", Exec: execJSON(t, exec)})
|
|
if out, isErr := callTool(t, tool, gatedCtx(task, 0), `{}`, nil); isErr || !strings.Contains(out, "probe-reached") || hits.Load() != before+1 {
|
|
t.Fatalf("%s: error=%v out=%q", name, isErr, out)
|
|
}
|
|
}
|
|
if got := dialer.calls(); len(got) != 0 {
|
|
t.Fatalf("dialled %v; the pinned address 203.0.113.7 is never dialled through a proxy", got)
|
|
}
|
|
}
|
|
|
|
// A gated worker's WebFetch end to end: approved, the next call passes; the
|
|
// name then answers another address from the next lookup on, so the following
|
|
// call passes the hook on the cached answer and fails at the dial with
|
|
// TARGET_APPROVAL_PENDING. The new_ip request is approved through the API and
|
|
// the call after passes.
|
|
func TestTargetGateEndToEndDialRebindBlocked(t *testing.T) {
|
|
g := newGateServer(t)
|
|
on := true
|
|
task := g.newTask(t, db.TaskCreateOptions{TargetGate: &on})
|
|
pg := g.m.pg
|
|
srv, hits, _, port := countingServer(t)
|
|
res := &countingResolver{answers: map[string][]string{"api.example.com": {"203.0.113.7"}}}
|
|
g.m.interceptor.SetResolver(res)
|
|
dialer := newRouteDialer(g)
|
|
dialer.route("203.0.113.7:"+port, srv.Listener.Addr().String())
|
|
dialer.route("203.0.113.9:"+port, srv.Listener.Addr().String())
|
|
key := "api.example.com:" + port
|
|
target := "http://" + key + "/e2e"
|
|
p := &toolScript{calls: fetches(target, target, target), results: make(chan string, 16),
|
|
hold: map[int64]chan struct{}{2: make(chan struct{}), 3: make(chan struct{})}}
|
|
done := runScriptedWorker(t, g, task, p)
|
|
|
|
var id int64
|
|
waitUntil(t, "the first request", func() bool {
|
|
var ok bool
|
|
id, _, _, ok = openTargetRequest(t, pg, task.ID, key)
|
|
return ok
|
|
})
|
|
decide(t, g, id, "allowed")
|
|
if r := p.next(t); !strings.Contains(r, "probe-reached") || hits.Load() != 1 {
|
|
t.Fatalf("first call after the approval: %q hits=%d", r, hits.Load())
|
|
}
|
|
|
|
res.set("api.example.com", "203.0.113.9")
|
|
close(p.hold[2])
|
|
if r := p.next(t); !strings.Contains(r, "TARGET_APPROVAL_PENDING: the connection to "+key+" was not opened") || hits.Load() != 1 {
|
|
t.Fatalf("second call, rebound at the dial: %q hits=%d", r, hits.Load())
|
|
}
|
|
newIP, toolName, _, ok := openTargetRequest(t, pg, task.ID, key)
|
|
if !ok || newIP == id || toolName != "WebFetch" {
|
|
t.Fatalf("new_ip request: ok=%v id=%d tool=%q", ok, newIP, toolName)
|
|
}
|
|
detail, err := pg.GetInterceptDetail(newIP)
|
|
if err != nil || detail == nil || detail.Audit == nil || detail.Audit.Targets[0].Reason != "new_ip" ||
|
|
!slices.Equal(detail.Audit.Targets[0].ResolvedIPs, []string{"203.0.113.9"}) {
|
|
t.Fatalf("new_ip request detail = %+v, %v", detail, err)
|
|
}
|
|
decide(t, g, newIP, "allowed")
|
|
close(p.hold[3])
|
|
if r := p.next(t); !strings.Contains(r, "probe-reached") || hits.Load() != 2 {
|
|
t.Fatalf("third call after the new_ip approval: %q hits=%d", r, hits.Load())
|
|
}
|
|
if got := dialer.calls(); !slices.Equal(got, []string{"203.0.113.7:" + port, "203.0.113.9:" + port}) {
|
|
t.Fatalf("dialled %v", got)
|
|
}
|
|
select {
|
|
case <-done:
|
|
case <-time.After(60 * time.Second):
|
|
t.Fatal("the worker run did not end")
|
|
}
|
|
}
|