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 | // Copyright 2015 Matthew Holt and The Caddy Authors |
| 2 | // |
| 3 | // Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 | // you may not use this file except in compliance with the License. |
| 5 | // You may obtain a copy of the License at |
| 6 | // |
| 7 | // http://www.apache.org/licenses/LICENSE-2.0 |
| 8 | // |
| 9 | // Unless required by applicable law or agreed to in writing, software |
| 10 | // distributed under the License is distributed on an "AS IS" BASIS, |
| 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 12 | // See the License for the specific language governing permissions and |
| 13 | // limitations under the License. |
| 14 | |
| 15 | package caddyhttp |
| 16 | |
| 17 | import ( |
| 18 | "context" |
| 19 | "crypto/tls" |
| 20 | "crypto/x509" |
| 21 | "encoding/pem" |
| 22 | "errors" |
| 23 | "net" |
| 24 | "net/http" |
| 25 | "net/http/httptest" |
| 26 | "testing" |
| 27 | |
| 28 | "github.com/caddyserver/caddy/v2" |
| 29 | ) |
| 30 | |
| 31 | func TestMissingCookiePlaceholder(t *testing.T) { |
| 32 | req := httptest.NewRequest(http.MethodGet, "/", nil) |
| 33 | req = req.WithContext(context.WithValue(req.Context(), VarsCtxKey, map[string]any{})) |
| 34 | repl := NewTestReplacer(req) |
| 35 | const input = "before-{http.request.cookie.session}-after" |
| 36 | if got := repl.ReplaceKnown(input, ""); got != "before--after" { |
| 37 | t.Fatalf("missing cookie = %q, want %q", got, "before--after") |
| 38 | } |
| 39 | req.AddCookie(&http.Cookie{Name: "session", Value: "present"}) |
| 40 | if got := repl.ReplaceKnown(input, ""); got != "before-present-after" { |
| 41 | t.Fatalf("present cookie = %q, want %q", got, "before-present-after") |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | func TestTLSPlaceholdersWithoutTLS(t *testing.T) { |
| 46 | req := httptest.NewRequest(http.MethodGet, "/", nil) |
| 47 | req = req.WithContext(context.WithValue(req.Context(), VarsCtxKey, map[string]any{})) |
| 48 | repl := NewTestReplacer(req) |
| 49 | for _, field := range []string{ |
| 50 | "version", "cipher_suite", "resumed", "proto", "proto_mutual", "server_name", "ech", |
| 51 | "client.fingerprint", "client.public_key", "client.public_key_sha256", |
| 52 | "client.issuer", "client.serial", "client.subject", "client.certificate_pem", "client.certificate_der_base64", |
| 53 | "client.san.dns_names", "client.san.emails", "client.san.ips", "client.san.uris", |
| 54 | "client.san.dns_names.0", "client.san.emails.0", "client.san.ips.0", "client.san.uris.0", |
| 55 | } { |
| 56 | t.Run(field, func(t *testing.T) { |
| 57 | key := "http.request.tls." + field |
| 58 | value, known := repl.Get(key) |
| 59 | if !known || caddy.ToString(value) != "" { |
| 60 | t.Fatalf("Get(%q) = %v, %v; want empty, known", key, value, known) |
| 61 | } |
| 62 | if got := repl.ReplaceKnown("before-{"+key+"}-after", ""); got != "before--after" { |
| 63 | t.Fatalf("replacement = %q, want %q", got, "before--after") |
| 64 | } |
| 65 | }) |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | func TestUnknownTLSPlaceholdersRemainLiteral(t *testing.T) { |
| 70 | for _, state := range []*tls.ConnectionState{nil, {ServerName: "example.com"}} { |
| 71 | req := httptest.NewRequest(http.MethodGet, "/", nil) |
| 72 | req.TLS = state |
| 73 | req = req.WithContext(context.WithValue(req.Context(), VarsCtxKey, map[string]any{})) |
| 74 | repl := NewTestReplacer(req) |
| 75 | for _, field := range []string{ |
| 76 | "unknown", "client.unknown", "client.san.unknown", |
| 77 | "client.san.dns_names_extra", "client.san.dns_names.-1", "client.san.dns_names.nope", |
| 78 | } { |
| 79 | input := "{http.request.tls." + field + "}" |
| 80 | if got := repl.ReplaceKnown(input, ""); got != input { |
| 81 | t.Errorf("replacement = %q, want literal %q", got, input) |
| 82 | } |
| 83 | } |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | func TestHTTPVarReplacement(t *testing.T) { |
| 88 | req, _ := http.NewRequest(http.MethodGet, "/foo/bar.tar.gz?a=1&b=2", nil) |
| 89 | repl := caddy.NewReplacer() |
| 90 | localAddr, _ := net.ResolveTCPAddr("tcp", "192.168.159.1:80") |
| 91 | ctx := context.WithValue(req.Context(), caddy.ReplacerCtxKey, repl) |
| 92 | ctx = context.WithValue(ctx, http.LocalAddrContextKey, localAddr) |
| 93 | req = req.WithContext(ctx) |
| 94 | req.Host = "example.com:80" |
| 95 | req.RemoteAddr = "192.168.159.32:1234" |
| 96 | |
| 97 | clientCert := []byte(`-----BEGIN CERTIFICATE----- |
| 98 | MIIB9jCCAV+gAwIBAgIBAjANBgkqhkiG9w0BAQsFADAYMRYwFAYDVQQDDA1DYWRk |
| 99 | eSBUZXN0IENBMB4XDTE4MDcyNDIxMzUwNVoXDTI4MDcyMTIxMzUwNVowHTEbMBkG |
| 100 | A1UEAwwSY2xpZW50LmxvY2FsZG9tYWluMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCB |
| 101 | iQKBgQDFDEpzF0ew68teT3xDzcUxVFaTII+jXH1ftHXxxP4BEYBU4q90qzeKFneF |
| 102 | z83I0nC0WAQ45ZwHfhLMYHFzHPdxr6+jkvKPASf0J2v2HDJuTM1bHBbik5Ls5eq+ |
| 103 | fVZDP8o/VHKSBKxNs8Goc2NTsr5b07QTIpkRStQK+RJALk4x9QIDAQABo0swSTAJ |
| 104 | BgNVHRMEAjAAMAsGA1UdDwQEAwIHgDAaBgNVHREEEzARgglsb2NhbGhvc3SHBH8A |
| 105 | AAEwEwYDVR0lBAwwCgYIKwYBBQUHAwIwDQYJKoZIhvcNAQELBQADgYEANSjz2Sk+ |
| 106 | eqp31wM9il1n+guTNyxJd+FzVAH+hCZE5K+tCgVDdVFUlDEHHbS/wqb2PSIoouLV |
| 107 | 3Q9fgDkiUod+uIK0IynzIKvw+Cjg+3nx6NQ0IM0zo8c7v398RzB4apbXKZyeeqUH |
| 108 | 9fNwfEi+OoXR6s+upSKobCmLGLGi9Na5s5g= |
| 109 | -----END CERTIFICATE-----`) |
| 110 | |
| 111 | block, _ := pem.Decode(clientCert) |
| 112 | if block == nil { |
| 113 | t.Fatalf("failed to decode PEM certificate") |
| 114 | } |
| 115 | |
| 116 | cert, err := x509.ParseCertificate(block.Bytes) |
| 117 | if err != nil { |
| 118 | t.Fatalf("failed to decode PEM certificate: %v", err) |
| 119 | } |
| 120 | |
| 121 | req.TLS = &tls.ConnectionState{ |
| 122 | Version: tls.VersionTLS13, |
| 123 | HandshakeComplete: true, |
| 124 | ServerName: "example.com", |
| 125 | CipherSuite: tls.TLS_AES_256_GCM_SHA384, |
| 126 | PeerCertificates: []*x509.Certificate{cert}, |
| 127 | NegotiatedProtocol: "h2", |
| 128 | NegotiatedProtocolIsMutual: true, |
| 129 | } |
| 130 | |
| 131 | res := httptest.NewRecorder() |
| 132 | addHTTPVarsToReplacer(repl, req, res) |
| 133 | |
| 134 | for i, tc := range []struct { |
| 135 | get string |
| 136 | expect string |
| 137 | }{ |
| 138 | { |
| 139 | get: "http.request.scheme", |
| 140 | expect: "https", |
| 141 | }, |
| 142 | { |
| 143 | get: "http.request.method", |
| 144 | expect: http.MethodGet, |
| 145 | }, |
| 146 | { |
| 147 | get: "http.request.host", |
| 148 | expect: "example.com", |
| 149 | }, |
| 150 | { |
| 151 | get: "http.request.port", |
| 152 | expect: "80", |
| 153 | }, |
| 154 | { |
| 155 | get: "http.request.hostport", |
| 156 | expect: "example.com:80", |
| 157 | }, |
| 158 | { |
| 159 | get: "http.request.local.host", |
| 160 | expect: "192.168.159.1", |
| 161 | }, |
| 162 | { |
| 163 | get: "http.request.local.port", |
| 164 | expect: "80", |
| 165 | }, |
| 166 | { |
| 167 | get: "http.request.local", |
| 168 | expect: "192.168.159.1:80", |
| 169 | }, |
| 170 | { |
| 171 | get: "http.request.remote.host", |
| 172 | expect: "192.168.159.32", |
| 173 | }, |
| 174 | { |
| 175 | get: "http.request.remote.host/24", |
| 176 | expect: "192.168.159.0/24", |
| 177 | }, |
| 178 | { |
| 179 | get: "http.request.remote.host/24,32", |
| 180 | expect: "192.168.159.0/24", |
| 181 | }, |
| 182 | { |
| 183 | get: "http.request.remote.host/999", |
| 184 | expect: "", |
| 185 | }, |
| 186 | { |
| 187 | get: "http.request.remote.port", |
| 188 | expect: "1234", |
| 189 | }, |
| 190 | { |
| 191 | get: "http.request.host.labels.0", |
| 192 | expect: "com", |
| 193 | }, |
| 194 | { |
| 195 | get: "http.request.host.labels.1", |
| 196 | expect: "example", |
| 197 | }, |
| 198 | { |
| 199 | get: "http.request.host.labels.2", |
| 200 | expect: "", |
| 201 | }, |
| 202 | { |
| 203 | get: "http.request.uri", |
| 204 | expect: "/foo/bar.tar.gz?a=1&b=2", |
| 205 | }, |
| 206 | { |
| 207 | get: "http.request.uri_escaped", |
| 208 | expect: "%2Ffoo%2Fbar.tar.gz%3Fa%3D1%26b%3D2", |
| 209 | }, |
| 210 | { |
| 211 | get: "http.request.uri.path", |
| 212 | expect: "/foo/bar.tar.gz", |
| 213 | }, |
| 214 | { |
| 215 | get: "http.request.uri.path_escaped", |
| 216 | expect: "%2Ffoo%2Fbar.tar.gz", |
| 217 | }, |
| 218 | { |
| 219 | get: "http.request.uri.path.file", |
| 220 | expect: "bar.tar.gz", |
| 221 | }, |
| 222 | { |
| 223 | get: "http.request.uri.path.file.base", |
| 224 | expect: "bar.tar", |
| 225 | }, |
| 226 | { |
| 227 | // not ideal, but also most correct, given that files can have dots (example: index.<SHA>.html) TODO: maybe this isn't right.. |
| 228 | get: "http.request.uri.path.file.ext", |
| 229 | expect: ".gz", |
| 230 | }, |
| 231 | { |
| 232 | get: "http.request.uri.query", |
| 233 | expect: "a=1&b=2", |
| 234 | }, |
| 235 | { |
| 236 | get: "http.request.uri.query_escaped", |
| 237 | expect: "a%3D1%26b%3D2", |
| 238 | }, |
| 239 | { |
| 240 | get: "http.request.uri.query.a", |
| 241 | expect: "1", |
| 242 | }, |
| 243 | { |
| 244 | get: "http.request.uri.query.b", |
| 245 | expect: "2", |
| 246 | }, |
| 247 | { |
| 248 | get: "http.request.uri.prefixed_query", |
| 249 | expect: "?a=1&b=2", |
| 250 | }, |
| 251 | { |
| 252 | get: "http.request.tls.cipher_suite", |
| 253 | expect: "TLS_AES_256_GCM_SHA384", |
| 254 | }, |
| 255 | { |
| 256 | get: "http.request.tls.proto", |
| 257 | expect: "h2", |
| 258 | }, |
| 259 | { |
| 260 | get: "http.request.tls.proto_mutual", |
| 261 | expect: "true", |
| 262 | }, |
| 263 | { |
| 264 | get: "http.request.tls.resumed", |
| 265 | expect: "false", |
| 266 | }, |
| 267 | { |
| 268 | get: "http.request.tls.server_name", |
| 269 | expect: "example.com", |
| 270 | }, |
| 271 | { |
| 272 | get: "http.request.tls.version", |
| 273 | expect: "tls1.3", |
| 274 | }, |
| 275 | { |
| 276 | get: "http.request.tls.client.fingerprint", |
| 277 | expect: "9f57b7b497cceacc5459b76ac1c3afedbc12b300e728071f55f84168ff0f7702", |
| 278 | }, |
| 279 | { |
| 280 | get: "http.request.tls.client.issuer", |
| 281 | expect: "CN=Caddy Test CA", |
| 282 | }, |
| 283 | { |
| 284 | get: "http.request.tls.client.serial", |
| 285 | expect: "2", |
| 286 | }, |
| 287 | { |
| 288 | get: "http.request.tls.client.subject", |
| 289 | expect: "CN=client.localdomain", |
| 290 | }, |
| 291 | { |
| 292 | get: "http.request.tls.client.san.dns_names", |
| 293 | expect: "[localhost]", |
| 294 | }, |
| 295 | { |
| 296 | get: "http.request.tls.client.san.dns_names.0", |
| 297 | expect: "localhost", |
| 298 | }, |
| 299 | { |
| 300 | get: "http.request.tls.client.san.dns_names.1", |
| 301 | expect: "", |
| 302 | }, |
| 303 | { |
| 304 | get: "http.request.tls.client.san.ips", |
| 305 | expect: "[127.0.0.1]", |
| 306 | }, |
| 307 | { |
| 308 | get: "http.request.tls.client.san.ips.0", |
| 309 | expect: "127.0.0.1", |
| 310 | }, |
| 311 | { |
| 312 | get: "http.request.tls.client.certificate_pem", |
| 313 | expect: string(clientCert) + "\n", // returned value comes with a newline appended to it |
| 314 | }, |
| 315 | } { |
| 316 | actual, got := repl.GetString(tc.get) |
| 317 | if !got { |
| 318 | t.Errorf("Test %d: Expected to recognize the placeholder name, but didn't", i) |
| 319 | } |
| 320 | if actual != tc.expect { |
| 321 | t.Errorf("Test %d: Expected %s to be '%s' but got '%s'", |
| 322 | i, tc.get, tc.expect, actual) |
| 323 | } |
| 324 | } |
| 325 | } |
| 326 | |
| 327 | func TestHTTPProtoNameNormalization(t *testing.T) { |
| 328 | for _, tc := range []struct { |
| 329 | proto string |
| 330 | major int |
| 331 | expectRaw string |
| 332 | expectName string |
| 333 | }{ |
| 334 | {proto: "HTTP/1.0", major: 1, expectRaw: "HTTP/1.0", expectName: "HTTP/1.0"}, |
| 335 | {proto: "HTTP/1.1", major: 1, expectRaw: "HTTP/1.1", expectName: "HTTP/1.1"}, |
| 336 | {proto: "HTTP/2.0", major: 2, expectRaw: "HTTP/2.0", expectName: "HTTP/2"}, |
| 337 | {proto: "HTTP/3.0", major: 3, expectRaw: "HTTP/3.0", expectName: "HTTP/3"}, |
| 338 | } { |
| 339 | req := httptest.NewRequest(http.MethodGet, "/", nil) |
| 340 | req.Proto = tc.proto |
| 341 | req.ProtoMajor = tc.major |
| 342 | repl := caddy.NewReplacer() |
| 343 | addHTTPVarsToReplacer(repl, req, nil) |
| 344 | |
| 345 | gotRaw, okRaw := repl.GetString("http.request.proto") |
| 346 | if !okRaw || gotRaw != tc.expectRaw { |
| 347 | t.Errorf("proto=%s: expected http.request.proto to be %q, got %q (ok=%t)", tc.proto, tc.expectRaw, gotRaw, okRaw) |
| 348 | } |
| 349 | |
| 350 | gotName, okName := repl.GetString("http.request.proto_name") |
| 351 | if !okName || gotName != tc.expectName { |
| 352 | t.Errorf("proto=%s: expected http.request.proto_name to be %q, got %q (ok=%t)", tc.proto, tc.expectName, gotName, okName) |
| 353 | } |
| 354 | } |
| 355 | } |
| 356 | |
| 357 | // BenchmarkAddHTTPVarsToReplacer measures the per-request replacer setup, which |
| 358 | // is the common path where the request UUID is never referenced. |
| 359 | func BenchmarkAddHTTPVarsToReplacer(b *testing.B) { |
| 360 | req := httptest.NewRequest(http.MethodGet, "http://example.com/foo?a=b", nil) |
| 361 | req.Header.Set("User-Agent", "test-agent") |
| 362 | ctx := context.WithValue(req.Context(), VarsCtxKey, make(map[string]any)) |
| 363 | ctx = context.WithValue(ctx, ExtraLogFieldsCtxKey, new(ExtraLogFields)) |
| 364 | req = req.WithContext(ctx) |
| 365 | |
| 366 | b.ReportAllocs() |
| 367 | for b.Loop() { |
| 368 | repl := caddy.NewReplacer() |
| 369 | addHTTPVarsToReplacer(repl, req, nil) |
| 370 | } |
| 371 | } |
| 372 | |
| 373 | // TestHTTPVarReplacementUUID verifies the lazily-allocated request UUID is |
| 374 | // generated on first access and stays stable across references. |
| 375 | func TestHTTPVarReplacementUUID(t *testing.T) { |
| 376 | req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) |
| 377 | repl := caddy.NewReplacer() |
| 378 | ctx := context.WithValue(req.Context(), caddy.ReplacerCtxKey, repl) |
| 379 | ctx = context.WithValue(ctx, VarsCtxKey, make(map[string]any)) |
| 380 | ctx = context.WithValue(ctx, ExtraLogFieldsCtxKey, new(ExtraLogFields)) |
| 381 | req = req.WithContext(ctx) |
| 382 | addHTTPVarsToReplacer(repl, req, nil) |
| 383 | |
| 384 | first, ok := repl.GetString("http.request.uuid") |
| 385 | if !ok || first == "" { |
| 386 | t.Fatalf("expected a non-empty uuid, got %q (ok=%t)", first, ok) |
| 387 | } |
| 388 | |
| 389 | second, _ := repl.GetString("http.request.uuid") |
| 390 | if first != second { |
| 391 | t.Errorf("expected stable uuid across references: %q != %q", first, second) |
| 392 | } |
| 393 | } |
| 394 | |
| 395 | // failingBodyReader serves some bytes and then returns a fixed error, to |
| 396 | // exercise the read failure path of the request body placeholders. |
| 397 | type failingBodyReader struct { |
| 398 | data []byte |
| 399 | pos int |
| 400 | err error |
| 401 | } |
| 402 | |
| 403 | func (b *failingBodyReader) Read(p []byte) (int, error) { |
| 404 | if b.pos < len(b.data) { |
| 405 | n := copy(p, b.data[b.pos:]) |
| 406 | b.pos += n |
| 407 | return n, nil |
| 408 | } |
| 409 | return 0, b.err |
| 410 | } |
| 411 | |
| 412 | func (b *failingBodyReader) Close() error { return nil } |
| 413 | |
| 414 | // TestRequestBodyPlaceholderErrorScoping verifies that only a max_size-driven |
| 415 | // handler error from the request body read becomes a RequestBodyLimitError, |
| 416 | // while every other error keeps the placeholder's old ignore-and-truncate |
| 417 | // behavior. |
| 418 | func TestRequestBodyPlaceholderErrorScoping(t *testing.T) { |
| 419 | maxBytes := &http.MaxBytesError{Limit: 10} |
| 420 | for _, tc := range []struct { |
| 421 | name string |
| 422 | readErr error |
| 423 | wantMarker bool |
| 424 | }{ |
| 425 | {name: "max_size handler error becomes the marker", readErr: HandlerError{Err: maxBytes, StatusCode: http.StatusRequestEntityTooLarge}, wantMarker: true}, |
| 426 | {name: "unrelated handler error keeps old behavior", readErr: HandlerError{Err: errors.New("boom"), StatusCode: http.StatusInternalServerError}}, |
| 427 | {name: "raw max bytes error keeps old behavior", readErr: maxBytes}, |
| 428 | } { |
| 429 | t.Run(tc.name, func(t *testing.T) { |
| 430 | req := httptest.NewRequest(http.MethodPost, "/", nil) |
| 431 | req.Body = &failingBodyReader{data: []byte("abc"), err: tc.readErr} |
| 432 | |
| 433 | body, err := readRequestBodyForPlaceholder(req) |
| 434 | |
| 435 | if !tc.wantMarker { |
| 436 | if err != nil { |
| 437 | t.Fatalf("unrelated errors must be ignored like before but got %v", err) |
| 438 | } |
| 439 | if string(body) != "abc" { |
| 440 | t.Errorf("expected the truncated prefix %q but got %q", "abc", body) |
| 441 | } |
| 442 | return |
| 443 | } |
| 444 | if err == nil { |
| 445 | t.Fatal("expected the body limit marker but got none") |
| 446 | } |
| 447 | var marker RequestBodyLimitError |
| 448 | if !errors.As(err, &marker) { |
| 449 | t.Fatalf("expected RequestBodyLimitError but got %T", err) |
| 450 | } |
| 451 | var handlerErr HandlerError |
| 452 | if errors.As(err, &handlerErr) { |
| 453 | t.Error("the marker must not be detectable as a HandlerError") |
| 454 | } |
| 455 | if marker.StatusCode() != http.StatusRequestEntityTooLarge { |
| 456 | t.Errorf("expected status 413 but got %d", marker.StatusCode()) |
| 457 | } |
| 458 | var maxErr *http.MaxBytesError |
| 459 | if !errors.As(err, &maxErr) { |
| 460 | t.Error("the marker must unwrap to the MaxBytesError") |
| 461 | } |
| 462 | }) |
| 463 | } |
| 464 | } |