From 9d8727450bf7bab0f94b1cb0f2771d05c7034e33 Mon Sep 17 00:00:00 2001 From: Ryan Fowler Date: Sat, 5 Sep 2026 14:28:37 +0000 Subject: [PATCH] fix(webtransport): sign and display CONNECT requests --- integration/integration_test.go | 33 ++++++++ internal/fetch/webtransport.go | 16 ++-- internal/fetch/webtransport_test.go | 124 ++++++++++++++++++++++++++++ 3 files changed, 168 insertions(+), 5 deletions(-) create mode 100644 internal/fetch/webtransport_test.go diff --git a/integration/integration_test.go b/integration/integration_test.go index 947a4ce9..852fc291 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -3466,6 +3466,39 @@ func TestMain(t *testing.T) { assertBufNotContains(t, res.stderr, "POST /chat") }) + t.Run("webtransport dry-run uses effective CONNECT method", func(t *testing.T) { + t.Parallel() + configHome := t.TempDir() + for _, test := range []struct { + name string + args []string + body bool + }{ + {name: "default", args: []string{"--webtransport", "--dry-run", "https://example.com/path"}}, + {name: "body-inferred POST", args: []string{"--webtransport", "--dry-run", "-d", "payload", "https://example.com/path"}, body: true}, + {name: "explicit POST", args: []string{"--webtransport", "--dry-run", "-X", "POST", "https://example.com/path"}}, + } { + t.Run(test.name, func(t *testing.T) { + res := runFetchOpts(t, fetchPath, fetchOpts{env: []string{ + "HOME=" + configHome, + "XDG_CONFIG_HOME=" + filepath.Join(configHome, "config"), + "HTTP_PROXY=", "http_proxy=", "HTTPS_PROXY=", "https_proxy=", + "ALL_PROXY=", "all_proxy=", "NO_PROXY=*", "no_proxy=*", + }}, test.args...) + assertExitCode(t, 0, res) + assertBufContains(t, res.stderr, "CONNECT /path HTTP/3.0") + assertBufNotContains(t, res.stderr, "GET /path") + assertBufNotContains(t, res.stderr, "POST /path") + if test.body { + assertBufContains(t, res.stderr, "payload") + } + if test.name == "explicit POST" { + assertBufContains(t, res.stderr, "ignoring method POST") + } + }) + } + }) + t.Run("websocket ctrl-c exits", func(t *testing.T) { t.Parallel() if runtime.GOOS == "windows" { diff --git a/internal/fetch/webtransport.go b/internal/fetch/webtransport.go index ad6b5dbe..6d6a7552 100644 --- a/internal/fetch/webtransport.go +++ b/internal/fetch/webtransport.go @@ -18,12 +18,18 @@ import ( "github.com/ryanfowler/fetch/internal/wt" ) -func handleWebTransport(ctx context.Context, r *Request, c *client.Client, req *http.Request) (int, error) { - if r.MethodExplicit && req.Method != http.MethodConnect { - p := r.PrinterHandle.Stderr() - core.WriteWarningMsgIf(p, "WebTransport requires CONNECT; ignoring method "+req.Method, r.Verbosity == core.VSilent) - req.Method = http.MethodConnect +func setWebTransportMethod(req *http.Request, explicit bool, p *core.Printer, silent bool) { + if req.Method == http.MethodConnect { + return + } + if explicit { + core.WriteWarningMsgIf(p, "WebTransport requires CONNECT; ignoring method "+req.Method, silent) } + req.Method = http.MethodConnect +} + +func handleWebTransport(ctx context.Context, r *Request, c *client.Client, req *http.Request) (int, error) { + setWebTransportMethod(req, r.MethodExplicit, r.PrinterHandle.Stderr(), r.Verbosity == core.VSilent) p := r.PrinterHandle.Stderr() if r.Timing { core.WriteWarningMsgIf(p, "--timing is not supported for WebTransport connections", r.Verbosity == core.VSilent) diff --git a/internal/fetch/webtransport_test.go b/internal/fetch/webtransport_test.go new file mode 100644 index 00000000..a911b99e --- /dev/null +++ b/internal/fetch/webtransport_test.go @@ -0,0 +1,124 @@ +package fetch + +import ( + "net/http" + "strings" + "testing" + "time" + + "github.com/ryanfowler/fetch/internal/aws" + "github.com/ryanfowler/fetch/internal/client" + "github.com/ryanfowler/fetch/internal/core" +) + +func TestSetWebTransportMethod(t *testing.T) { + tests := []struct { + name string + method string + explicit bool + warning string + }{ + {name: "default GET", method: http.MethodGet}, + {name: "body-inferred POST", method: http.MethodPost}, + {name: "explicit non-CONNECT", method: http.MethodPut, explicit: true, warning: "ignoring method PUT"}, + {name: "explicit CONNECT", method: http.MethodConnect, explicit: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + req, err := http.NewRequest(test.method, "https://example.com/path", nil) + if err != nil { + t.Fatal(err) + } + p := core.TestPrinter(false) + + setWebTransportMethod(req, test.explicit, p, false) + + if req.Method != http.MethodConnect { + t.Fatalf("method = %q, want CONNECT", req.Method) + } + output := string(p.Bytes()) + if test.warning != "" && !strings.Contains(output, test.warning) { + t.Fatalf("warning output = %q, want %q", output, test.warning) + } + if test.warning == "" && output != "" { + t.Fatalf("unexpected warning output: %q", output) + } + }) + } +} + +func TestSetWebTransportMethodUsesInferredBodyMethod(t *testing.T) { + c := client.NewClient(client.ClientConfig{}) + defer c.Close() + + req, err := c.NewRequest(t.Context(), client.RequestConfig{ + Data: strings.NewReader("payload"), + URL: mustParseURL("https://example.com/path"), + Method: "", + }) + if err != nil { + t.Fatal(err) + } + if req.Method != http.MethodPost { + t.Fatalf("inferred method = %q, want POST", req.Method) + } + + setWebTransportMethod(req, false, core.TestPrinter(false), false) + if req.Method != http.MethodConnect { + t.Fatalf("method = %q, want CONNECT", req.Method) + } +} + +func TestWebTransportAWSMethodSigningUsesCONNECT(t *testing.T) { + cfg := aws.Config{ + AccessKey: "AKIDEXAMPLE", + SecretKey: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + Region: "us-east-1", + Service: "execute-api", + } + when := time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC) + + req, err := http.NewRequest(http.MethodGet, "https://example.com/path", nil) + if err != nil { + t.Fatal(err) + } + setWebTransportMethod(req, false, core.TestPrinter(false), false) + if err := aws.Sign(req, cfg, when); err != nil { + t.Fatal(err) + } + got := req.Header.Get("Authorization") + + connectReq, _ := http.NewRequest(http.MethodConnect, "https://example.com/path", nil) + if err := aws.Sign(connectReq, cfg, when); err != nil { + t.Fatal(err) + } + if got != connectReq.Header.Get("Authorization") { + t.Fatalf("signature was not calculated for CONNECT: got %q, want %q", got, connectReq.Header.Get("Authorization")) + } + + getReq, _ := http.NewRequest(http.MethodGet, "https://example.com/path", nil) + if err := aws.Sign(getReq, cfg, when); err != nil { + t.Fatal(err) + } + if got == getReq.Header.Get("Authorization") { + t.Fatal("CONNECT and GET signatures are equal") + } +} + +func TestWebTransportDryRunMetadataUsesCONNECT(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, "https://example.com/path", nil) + if err != nil { + t.Fatal(err) + } + setWebTransportMethod(req, false, core.TestPrinter(false), false) + + p := core.TestPrinter(false) + printRequestMetadataWithURL(p, req, core.HTTP3, core.VSilent, true) + if got := string(p.Bytes()); !strings.Contains(got, "CONNECT /path HTTP/3.0") { + t.Fatalf("dry-run metadata = %q, want CONNECT request line", got) + } + if strings.Contains(string(p.Bytes()), "GET /path") { + t.Fatalf("dry-run metadata contains stale GET request line: %q", p.Bytes()) + } +}