Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS
Mirrored from https://github.com/Slazee/caddy.git
Synced Nov 11, 01:00 UTC
| 1 | package reverseproxy |
| 2 | |
| 3 | import ( |
| 4 | "encoding/base64" |
| 5 | "io" |
| 6 | "net/http" |
| 7 | "net/http/httptest" |
| 8 | "strconv" |
| 9 | "strings" |
| 10 | "sync" |
| 11 | "testing" |
| 12 | |
| 13 | "go.uber.org/zap" |
| 14 | |
| 15 | "github.com/caddyserver/caddy/v2/modules/caddyhttp" |
| 16 | "github.com/caddyserver/caddy/v2/modules/caddyhttp/headers" |
| 17 | ) |
| 18 | |
| 19 | type extendedConnectCapture struct { |
| 20 | method string |
| 21 | headers http.Header |
| 22 | body []byte |
| 23 | extendedBodyPresent bool |
| 24 | extendedConnectBody []byte |
| 25 | } |
| 26 | |
| 27 | type extendedConnectCaptureTransport struct { |
| 28 | mu sync.Mutex |
| 29 | capture extendedConnectCapture |
| 30 | } |
| 31 | |
| 32 | func (tr *extendedConnectCaptureTransport) RoundTrip(req *http.Request) (*http.Response, error) { |
| 33 | body, err := io.ReadAll(req.Body) |
| 34 | if err != nil { |
| 35 | return nil, err |
| 36 | } |
| 37 | |
| 38 | c := extendedConnectCapture{ |
| 39 | method: req.Method, |
| 40 | headers: req.Header.Clone(), |
| 41 | body: body, |
| 42 | } |
| 43 | if rc, ok := caddyhttp.GetVar(req.Context(), "extended_connect_websocket_body").(io.ReadCloser); ok { |
| 44 | c.extendedBodyPresent = true |
| 45 | c.extendedConnectBody, err = io.ReadAll(rc) |
| 46 | if err != nil { |
| 47 | return nil, err |
| 48 | } |
| 49 | _ = rc.Close() |
| 50 | } |
| 51 | |
| 52 | tr.mu.Lock() |
| 53 | tr.capture = c |
| 54 | tr.mu.Unlock() |
| 55 | |
| 56 | return &http.Response{ |
| 57 | StatusCode: http.StatusOK, |
| 58 | Header: make(http.Header), |
| 59 | Body: io.NopCloser(strings.NewReader("ok")), |
| 60 | Request: req, |
| 61 | }, nil |
| 62 | } |
| 63 | |
| 64 | func (tr *extendedConnectCaptureTransport) Snapshot() extendedConnectCapture { |
| 65 | tr.mu.Lock() |
| 66 | defer tr.mu.Unlock() |
| 67 | return tr.capture |
| 68 | } |
| 69 | |
| 70 | func TestServeHTTPRewritesExtendedConnectWebsocketRequest(t *testing.T) { |
| 71 | tests := []struct { |
| 72 | name string |
| 73 | protoMajor int |
| 74 | proto string |
| 75 | headers map[string]string |
| 76 | }{ |
| 77 | { |
| 78 | name: "h2 extended connect", |
| 79 | protoMajor: 2, |
| 80 | proto: "HTTP/2.0", |
| 81 | headers: map[string]string{ |
| 82 | ":protocol": "websocket", |
| 83 | }, |
| 84 | }, |
| 85 | { |
| 86 | name: "h3 extended connect", |
| 87 | protoMajor: 3, |
| 88 | proto: "websocket", |
| 89 | headers: map[string]string{}, |
| 90 | }, |
| 91 | } |
| 92 | |
| 93 | for _, tc := range tests { |
| 94 | t.Run(tc.name, func(t *testing.T) { |
| 95 | const payload = "extended-connect-body" |
| 96 | |
| 97 | transport := new(extendedConnectCaptureTransport) |
| 98 | h := &Handler{ |
| 99 | logger: zap.NewNop(), |
| 100 | Transport: transport, |
| 101 | Upstreams: UpstreamPool{ |
| 102 | &Upstream{Host: new(Host), Dial: "127.0.0.1:8443"}, |
| 103 | }, |
| 104 | LoadBalancing: &LoadBalancing{ |
| 105 | SelectionPolicy: &RoundRobinSelection{}, |
| 106 | }, |
| 107 | } |
| 108 | |
| 109 | req := httptest.NewRequest(http.MethodConnect, "http://example.test/upgrade", strings.NewReader(payload)) |
| 110 | req.ProtoMajor = tc.protoMajor |
| 111 | req.Proto = tc.proto |
| 112 | for key, value := range tc.headers { |
| 113 | req.Header.Set(key, value) |
| 114 | } |
| 115 | req = prepareTestRequest(req) |
| 116 | |
| 117 | rr := httptest.NewRecorder() |
| 118 | err := h.ServeHTTP(rr, req, caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { |
| 119 | return nil |
| 120 | })) |
| 121 | if err != nil { |
| 122 | t.Fatalf("ServeHTTP() error = %v", err) |
| 123 | } |
| 124 | |
| 125 | captured := transport.Snapshot() |
| 126 | if captured.method != http.MethodGet { |
| 127 | t.Fatalf("upstream method = %s, want %s", captured.method, http.MethodGet) |
| 128 | } |
| 129 | if got := captured.headers.Get("Upgrade"); !strings.EqualFold(got, "websocket") { |
| 130 | t.Fatalf("Upgrade header = %q, want websocket", got) |
| 131 | } |
| 132 | if got := captured.headers.Get("Connection"); !strings.EqualFold(got, "Upgrade") { |
| 133 | t.Fatalf("Connection header = %q, want Upgrade", got) |
| 134 | } |
| 135 | if got := captured.headers.Get(":protocol"); got != "" { |
| 136 | t.Fatalf(":protocol header should be removed, got %q", got) |
| 137 | } |
| 138 | if len(captured.body) != 0 { |
| 139 | t.Fatalf("upstream request body length = %d, want 0", len(captured.body)) |
| 140 | } |
| 141 | if !captured.extendedBodyPresent { |
| 142 | t.Fatal("extended_connect_websocket_body variable missing from request context") |
| 143 | } |
| 144 | if string(captured.extendedConnectBody) != payload { |
| 145 | t.Fatalf("extended_connect_websocket_body = %q, want %q", string(captured.extendedConnectBody), payload) |
| 146 | } |
| 147 | }) |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | func TestServeHTTPRewritesExtendedConnectWebsocketSecWebSocketKeyMatrix(t *testing.T) { |
| 152 | const clientKey = "dGhlIHNhbXBsZSBub25jZQ==" |
| 153 | |
| 154 | protoCases := []struct { |
| 155 | protoName string |
| 156 | protoMajor int |
| 157 | proto string |
| 158 | headers map[string]string |
| 159 | }{ |
| 160 | { |
| 161 | protoName: "h2", |
| 162 | protoMajor: 2, |
| 163 | proto: "HTTP/2.0", |
| 164 | headers: map[string]string{ |
| 165 | ":protocol": "websocket", |
| 166 | }, |
| 167 | }, |
| 168 | { |
| 169 | protoName: "h3", |
| 170 | protoMajor: 3, |
| 171 | proto: "websocket", |
| 172 | headers: map[string]string{}, |
| 173 | }, |
| 174 | } |
| 175 | |
| 176 | headerOpCases := []struct { |
| 177 | opName string |
| 178 | userOps *headers.HeaderOps |
| 179 | transportOps *headers.HeaderOps |
| 180 | }{ |
| 181 | { |
| 182 | opName: "no header ops", |
| 183 | }, |
| 184 | { |
| 185 | opName: "user ops", |
| 186 | userOps: &headers.HeaderOps{ |
| 187 | Set: http.Header{"X-User-Op": []string{"yes"}}, |
| 188 | }, |
| 189 | }, |
| 190 | { |
| 191 | opName: "transport ops", |
| 192 | transportOps: &headers.HeaderOps{ |
| 193 | Set: http.Header{"X-Transport-Op": []string{"yes"}}, |
| 194 | }, |
| 195 | }, |
| 196 | { |
| 197 | opName: "both user and transport ops", |
| 198 | userOps: &headers.HeaderOps{ |
| 199 | Set: http.Header{"X-User-Op": []string{"yes"}}, |
| 200 | }, |
| 201 | transportOps: &headers.HeaderOps{ |
| 202 | Set: http.Header{"X-Transport-Op": []string{"yes"}}, |
| 203 | }, |
| 204 | }, |
| 205 | } |
| 206 | |
| 207 | clientKeyCases := []struct { |
| 208 | clientKeyName string |
| 209 | sendClientKey bool |
| 210 | }{ |
| 211 | { |
| 212 | clientKeyName: "client sent key", |
| 213 | sendClientKey: true, |
| 214 | }, |
| 215 | { |
| 216 | clientKeyName: "client without key", |
| 217 | sendClientKey: false, |
| 218 | }, |
| 219 | } |
| 220 | |
| 221 | for _, pc := range protoCases { |
| 222 | for _, hoc := range headerOpCases { |
| 223 | for _, ckc := range clientKeyCases { |
| 224 | testName := pc.protoName + "/" + hoc.opName + "/" + ckc.clientKeyName |
| 225 | t.Run(testName, func(t *testing.T) { |
| 226 | transport := new(extendedConnectCaptureTransport) |
| 227 | h := &Handler{ |
| 228 | logger: zap.NewNop(), |
| 229 | Transport: transport, |
| 230 | Upstreams: UpstreamPool{ |
| 231 | &Upstream{Host: new(Host), Dial: "127.0.0.1:8443"}, |
| 232 | }, |
| 233 | LoadBalancing: &LoadBalancing{ |
| 234 | SelectionPolicy: &RoundRobinSelection{}, |
| 235 | }, |
| 236 | transportHeaderOps: hoc.transportOps, |
| 237 | } |
| 238 | if hoc.userOps != nil { |
| 239 | h.Headers = &headers.Handler{ |
| 240 | Request: hoc.userOps, |
| 241 | } |
| 242 | } |
| 243 | |
| 244 | req := httptest.NewRequest(http.MethodConnect, "http://example.test/upgrade", strings.NewReader("ws-data")) |
| 245 | req.ProtoMajor = pc.protoMajor |
| 246 | req.Proto = pc.proto |
| 247 | for k, v := range pc.headers { |
| 248 | req.Header.Set(k, v) |
| 249 | } |
| 250 | if ckc.sendClientKey { |
| 251 | req.Header.Set("Sec-WebSocket-Key", clientKey) |
| 252 | } |
| 253 | req = prepareTestRequest(req) |
| 254 | |
| 255 | rr := httptest.NewRecorder() |
| 256 | err := h.ServeHTTP(rr, req, caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { |
| 257 | return nil |
| 258 | })) |
| 259 | if err != nil { |
| 260 | t.Fatalf("ServeHTTP() error = %v", err) |
| 261 | } |
| 262 | |
| 263 | captured := transport.Snapshot() |
| 264 | |
| 265 | // Collect all Sec-WebSocket-Key values across canonical and non-canonical headers |
| 266 | var keyValues []string |
| 267 | for k, v := range captured.headers { |
| 268 | if strings.EqualFold(k, "Sec-WebSocket-Key") { |
| 269 | if k != "Sec-WebSocket-Key" { |
| 270 | t.Fatalf("upstream WebSocket key spelling = %q, want Sec-WebSocket-Key", k) |
| 271 | } |
| 272 | keyValues = append(keyValues, v...) |
| 273 | } |
| 274 | } |
| 275 | |
| 276 | if len(keyValues) != 1 { |
| 277 | t.Fatalf("expected exactly 1 Sec-WebSocket-Key value, got %d: %v (headers: %#v)", |
| 278 | len(keyValues), keyValues, captured.headers) |
| 279 | } |
| 280 | |
| 281 | upstreamKey := keyValues[0] |
| 282 | if ckc.sendClientKey && upstreamKey == clientKey { |
| 283 | t.Fatalf("upstream Sec-WebSocket-Key was not replaced by generated key; still got client key %q", clientKey) |
| 284 | } |
| 285 | |
| 286 | decoded, err := base64.StdEncoding.DecodeString(upstreamKey) |
| 287 | if err != nil { |
| 288 | t.Fatalf("upstream Sec-WebSocket-Key %q is not valid base64: %v", upstreamKey, err) |
| 289 | } |
| 290 | if len(decoded) != 16 { |
| 291 | t.Fatalf("upstream Sec-WebSocket-Key decoded length = %d, want 16 bytes", len(decoded)) |
| 292 | } |
| 293 | |
| 294 | if hoc.userOps != nil { |
| 295 | if got := captured.headers.Get("X-User-Op"); got != "yes" { |
| 296 | t.Fatalf("X-User-Op header = %q, want 'yes'", got) |
| 297 | } |
| 298 | } |
| 299 | if hoc.transportOps != nil { |
| 300 | if got := captured.headers.Get("X-Transport-Op"); got != "yes" { |
| 301 | t.Fatalf("X-Transport-Op header = %q, want 'yes'", got) |
| 302 | } |
| 303 | } |
| 304 | }) |
| 305 | } |
| 306 | } |
| 307 | } |
| 308 | } |
| 309 | |
| 310 | func TestUpgradeResponseWebsocketHeaders(t *testing.T) { |
| 311 | for _, proto := range []int{1, 2, 3} { |
| 312 | t.Run("h"+strconv.Itoa(proto), func(t *testing.T) { |
| 313 | h := &Handler{logger: zap.NewNop()} |
| 314 | req := prepareTestRequest(httptest.NewRequest(http.MethodGet, "http://example.test/upgrade", nil)) |
| 315 | req.ProtoMajor = proto |
| 316 | req.Header.Set("Connection", "Upgrade") |
| 317 | req.Header.Set("Upgrade", "websocket") |
| 318 | wantStatus := http.StatusSwitchingProtocols |
| 319 | if proto > 1 { |
| 320 | caddyhttp.SetVar(req.Context(), "extended_connect_websocket_body", http.NoBody) |
| 321 | wantStatus = http.StatusOK |
| 322 | } |
| 323 | res := &http.Response{ |
| 324 | StatusCode: http.StatusSwitchingProtocols, |
| 325 | Header: req.Header.Clone(), |
| 326 | Body: fakeRWC{strings.NewReader("")}, |
| 327 | } |
| 328 | res.Header.Set("Sec-WebSocket-Accept", "backend-accept") |
| 329 | res.Header.Set("Sec-WebSocket-Protocol", "chat") |
| 330 | rw := upgradeHeaderRecorder{httptest.NewRecorder()} |
| 331 | h.handleUpgradeResponse(h.logger, rw, req, res, "test-upstream") |
| 332 | response := rw.Result() |
| 333 | defer response.Body.Close() |
| 334 | if response.StatusCode != wantStatus { |
| 335 | t.Fatalf("status = %d, want %d", response.StatusCode, wantStatus) |
| 336 | } |
| 337 | var accept, protocol string |
| 338 | for key, values := range response.Header { |
| 339 | if strings.EqualFold(key, "Sec-WebSocket-Accept") { |
| 340 | if proto > 1 { |
| 341 | t.Fatalf("extended CONNECT response contains %s", key) |
| 342 | } |
| 343 | if key != "Sec-WebSocket-Accept" { |
| 344 | t.Fatalf("WebSocket accept spelling = %q, want Sec-WebSocket-Accept", key) |
| 345 | } |
| 346 | accept = strings.Join(values, ",") |
| 347 | } |
| 348 | if strings.EqualFold(key, "Sec-WebSocket-Protocol") { |
| 349 | protocol = strings.Join(values, ",") |
| 350 | } |
| 351 | } |
| 352 | if proto == 1 && accept != "backend-accept" { |
| 353 | t.Fatalf("accept = %q, want backend-accept", accept) |
| 354 | } |
| 355 | if protocol != "chat" { |
| 356 | t.Fatalf("subprotocol = %q, want chat", protocol) |
| 357 | } |
| 358 | }) |
| 359 | } |
| 360 | } |
| 361 | |
| 362 | type upgradeHeaderRecorder struct{ *httptest.ResponseRecorder } |
| 363 | |
| 364 | // Stop after the headers are committed, before the tunnel starts. |
| 365 | func (upgradeHeaderRecorder) FlushError() error { return io.ErrClosedPipe } |