@marcel91200 / caddy Mirror · read-only
Fork 0

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, 02:15 UTC

master
go 597 lines 18.9 KB
Raw
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 fastcgi
16
17 import (
18 "crypto/tls"
19 "errors"
20 "fmt"
21 "net"
22 "net/http"
23 "net/url"
24 "path/filepath"
25 "runtime"
26 "strconv"
27 "strings"
28 "time"
29 "unicode/utf8"
30
31 "go.uber.org/zap"
32 "go.uber.org/zap/zapcore"
33
34 "github.com/caddyserver/caddy/v2"
35 "github.com/caddyserver/caddy/v2/modules/caddyhttp"
36 "github.com/caddyserver/caddy/v2/modules/caddyhttp/reverseproxy"
37 "github.com/caddyserver/caddy/v2/modules/caddytls"
38 )
39
40 var (
41 ErrInvalidSplitPath = errors.New("split path contains non-ASCII characters")
42
43 noopLogger = zap.NewNop()
44 )
45
46 func init() {
47 caddy.RegisterModule(Transport{})
48 }
49
50 // Transport facilitates FastCGI communication.
51 type Transport struct {
52 // Use this directory as the fastcgi root directory. Defaults to the root
53 // directory of the parent virtual host.
54 Root string `json:"root,omitempty"`
55
56 // The path in the URL will be split into two, with the first piece ending
57 // with the value of SplitPath. The first piece will be assumed as the
58 // actual resource (CGI script) name, and the second piece will be set to
59 // PATH_INFO for the CGI script to use.
60 //
61 // Split paths can only contain ASCII characters.
62 // Comparison is case-insensitive.
63 //
64 // Future enhancements should be careful to avoid CVE-2019-11043,
65 // which can be mitigated with use of a try_files-like behavior
66 // that 404s if the fastcgi path info is not found.
67 SplitPath []string `json:"split_path,omitempty"`
68
69 // Path declared as root directory will be resolved to its absolute value
70 // after the evaluation of any symbolic links.
71 // Due to the nature of PHP opcache, root directory path is cached: when
72 // using a symlinked directory as root this could generate errors when
73 // symlink is changed without php-fpm being restarted; enabling this
74 // directive will set $_SERVER['DOCUMENT_ROOT'] to the real directory path.
75 ResolveRootSymlink bool `json:"resolve_root_symlink,omitempty"`
76
77 // Extra environment variables.
78 EnvVars map[string]string `json:"env,omitempty"`
79
80 // The duration used to set a deadline when connecting to an upstream. Default: `3s`.
81 DialTimeout caddy.Duration `json:"dial_timeout,omitempty"`
82
83 // The duration used to set a deadline when reading from the FastCGI server.
84 ReadTimeout caddy.Duration `json:"read_timeout,omitempty"`
85
86 // The duration used to set a deadline when sending to the FastCGI server.
87 WriteTimeout caddy.Duration `json:"write_timeout,omitempty"`
88
89 // Capture and log any messages sent by the upstream on stderr. Logs at WARN
90 // level by default. If the response has a 4xx or 5xx status ERROR level will
91 // be used instead.
92 CaptureStderr bool `json:"capture_stderr,omitempty"`
93
94 serverSoftware string
95 logger *zap.Logger
96 }
97
98 // CaddyModule returns the Caddy module information.
99 func (Transport) CaddyModule() caddy.ModuleInfo {
100 return caddy.ModuleInfo{
101 ID: "http.reverse_proxy.transport.fastcgi",
102 New: func() caddy.Module { return new(Transport) },
103 }
104 }
105
106 // Provision sets up t.
107 func (t *Transport) Provision(ctx caddy.Context) error {
108 t.logger = ctx.Logger()
109
110 if t.Root == "" {
111 t.Root = "{http.vars.root}"
112 }
113
114 version, _ := caddy.Version()
115 t.serverSoftware = "Caddy/" + version
116
117 // Set a relatively short default dial timeout.
118 // This is helpful to make load-balancer retries more speedy.
119 if t.DialTimeout == 0 {
120 t.DialTimeout = caddy.Duration(3 * time.Second)
121 }
122
123 var b strings.Builder
124
125 for i, split := range t.SplitPath {
126 b.Grow(len(split))
127
128 for j := 0; j < len(split); j++ {
129 c := split[j]
130 if c >= utf8.RuneSelf {
131 return ErrInvalidSplitPath
132 }
133
134 if 'A' <= c && c <= 'Z' {
135 b.WriteByte(c + 'a' - 'A')
136 } else {
137 b.WriteByte(c)
138 }
139 }
140
141 t.SplitPath[i] = b.String()
142 b.Reset()
143 }
144
145 return nil
146 }
147
148 // DefaultBufferSizes enables request buffering for fastcgi if not configured.
149 // This is because most fastcgi servers are php-fpm that require the content length to be set to read the body, golang
150 // std has fastcgi implementation that doesn't need this value to process the body, but we can safely assume that's
151 // not used.
152 // http3 requests have a negative content length for GET and HEAD requests, if that header is not sent.
153 // see: https://github.com/caddyserver/caddy/issues/6678#issuecomment-2472224182
154 // Though it appears even if CONTENT_LENGTH is invalid, php-fpm can handle just fine if the body is empty (no Stdin records sent).
155 // php-fpm will hang if there is any data in the body though, https://github.com/caddyserver/caddy/issues/5420#issuecomment-2415943516
156
157 // TODO: better default buffering for fastcgi requests without content length, in theory a value of 1 should be enough, make it bigger anyway
158 func (t Transport) DefaultBufferSizes() (int64, int64) {
159 return 4096, 0
160 }
161
162 // RequiresContentLength reports that fastcgi cannot forward a body of unknown
163 // length: without a valid CONTENT_LENGTH the client rejects the request rather
164 // than let the upstream hang, so the request buffer above is the only way to
165 // learn the length of a body that does not announce one.
166 func (t Transport) RequiresContentLength() bool {
167 return true
168 }
169
170 // RoundTrip implements http.RoundTripper.
171 func (t Transport) RoundTrip(r *http.Request) (*http.Response, error) {
172 server := r.Context().Value(caddyhttp.ServerCtxKey).(*caddyhttp.Server)
173
174 // Disallow null bytes in the request path, because
175 // PHP upstreams may do bad things, like execute a
176 // non-PHP file as PHP code. See #4574
177 if strings.Contains(r.URL.Path, "\x00") {
178 return nil, caddyhttp.Error(http.StatusBadRequest, fmt.Errorf("invalid request path"))
179 }
180
181 env, err := t.buildEnv(r)
182 if err != nil {
183 return nil, fmt.Errorf("building environment: %w", err)
184 }
185
186 ctx := r.Context()
187
188 // extract dial information from request (should have been embedded by the reverse proxy)
189 network, address := "tcp", r.URL.Host
190 if dialInfo, ok := reverseproxy.GetDialInfo(ctx); ok {
191 network = dialInfo.Network
192 address = dialInfo.Address
193 }
194
195 logCreds := server.Logs != nil && server.Logs.ShouldLogCredentials
196 loggableReq := caddyhttp.LoggableHTTPRequest{
197 Request: r,
198 ShouldLogCredentials: logCreds,
199 }
200 loggableEnv := loggableEnv{vars: env, logCredentials: logCreds}
201
202 logger := t.logger.With(
203 zap.Object("request", loggableReq),
204 zap.Object("env", loggableEnv),
205 )
206 if c := t.logger.Check(zapcore.DebugLevel, "roundtrip"); c != nil {
207 c.Write(
208 zap.String("dial", address),
209 zap.Object("env", loggableEnv),
210 zap.Object("request", loggableReq),
211 )
212 }
213
214 // connect to the backend
215 dialer := net.Dialer{Timeout: time.Duration(t.DialTimeout)}
216 conn, err := dialer.DialContext(ctx, network, address)
217 if err != nil {
218 return nil, reverseproxy.NewDialError(fmt.Errorf("dialing backend: %w", err))
219 }
220 defer func() {
221 // conn will be closed with the response body unless there's an error
222 if err != nil {
223 conn.Close()
224 }
225 }()
226
227 // create the client that will facilitate the protocol
228 client := client{
229 rwc: conn,
230 reqID: 1,
231 logger: logger,
232 stderr: t.CaptureStderr,
233 }
234
235 // read/write timeouts
236 if err = client.SetReadTimeout(time.Duration(t.ReadTimeout)); err != nil {
237 return nil, fmt.Errorf("setting read timeout: %v", err)
238 }
239 if err = client.SetWriteTimeout(time.Duration(t.WriteTimeout)); err != nil {
240 return nil, fmt.Errorf("setting write timeout: %v", err)
241 }
242
243 contentLength := r.ContentLength
244 if contentLength == 0 {
245 contentLength, _ = strconv.ParseInt(r.Header.Get("Content-Length"), 10, 64)
246 }
247
248 var resp *http.Response
249 switch r.Method {
250 case http.MethodHead:
251 resp, err = client.Head(env)
252 case http.MethodGet:
253 resp, err = client.Get(env, r.Body, contentLength)
254 case http.MethodOptions:
255 resp, err = client.Options(env)
256 default:
257 resp, err = client.Post(env, r.Method, r.Header.Get("Content-Type"), r.Body, contentLength)
258 }
259 if err != nil {
260 return nil, err
261 }
262
263 return resp, nil
264 }
265
266 // buildEnv returns a set of CGI environment variables for the request.
267 func (t Transport) buildEnv(r *http.Request) (envVars, error) {
268 repl := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer)
269
270 var env envVars
271
272 // Separate remote IP and port; more lenient than net.SplitHostPort
273 var ip, port string
274 if idx := strings.LastIndex(r.RemoteAddr, ":"); idx > -1 {
275 ip = r.RemoteAddr[:idx]
276 port = r.RemoteAddr[idx+1:]
277 } else {
278 ip = r.RemoteAddr
279 }
280
281 // Remove [] from IPv6 addresses
282 ip = strings.Replace(ip, "[", "", 1)
283 ip = strings.Replace(ip, "]", "", 1)
284
285 // make sure file root is absolute
286 root, err := caddy.FastAbs(repl.ReplaceAll(t.Root, "."))
287 if err != nil {
288 return nil, err
289 }
290
291 if t.ResolveRootSymlink {
292 root, err = filepath.EvalSymlinks(root)
293 if err != nil {
294 return nil, err
295 }
296 }
297
298 // Derive the script from the same path model the matchers judged: Windows
299 // separators and per-component trailing dots/spaces normalized, then cleaned
300 // (mirroring MatchPath and MatchURLPattern). Caddy does not rewrite
301 // r.URL.Path itself, and the rewrite route php_fastcgi relies on to normalize
302 // it can fail to match, so the transport must establish this invariant rather
303 // than inherit it. Without this, /admin.php/../../other.php (or, on Windows,
304 // /admin.php\..\other.php) is judged by every path matcher as /other.php
305 // while SCRIPT_FILENAME resolves to /admin.php.
306 fpath := caddyhttp.NormalizedPath(r.URL.Path, true)
307 scriptName := fpath
308
309 docURI := fpath
310 // split "actual path" from "path info" if configured
311 var pathInfo string
312 if splitPos := t.splitPos(fpath); splitPos > -1 {
313 docURI = fpath[:splitPos]
314 pathInfo = fpath[splitPos:]
315
316 // Strip PATH_INFO from SCRIPT_NAME
317 scriptName = strings.TrimSuffix(scriptName, pathInfo)
318 }
319
320 // On Windows, an 8.3 alias or an alternate data stream can name a non-PHP file
321 // with a script-looking path, and path matchers cannot see through either.
322 if runtime.GOOS == "windows" && (strings.Contains(scriptName, ":") || caddyhttp.HasWindowsShortName(scriptName)) {
323 return nil, caddyhttp.Error(http.StatusBadRequest, errors.New("illegal script path"))
324 }
325
326 // Path matchers whose pattern contains "%" clean the escaped path, where "%2f"
327 // and "%2e%2e" are neither separators nor dot segments, so both forms must
328 // select the same script. On Windows, they read a backslash, escaped even
329 // without RawPath, as "%2f".
330 backslash := runtime.GOOS == "windows" && strings.Contains(r.URL.Path, `\`)
331 if r.URL.RawPath != "" || backslash {
332 escaped, err := url.PathUnescape(caddyhttp.NormalizedPath(r.URL.EscapedPath(), true))
333 if backslash {
334 escaped = strings.ReplaceAll(escaped, `\`, "/")
335 }
336 if err != nil || t.scriptPart(escaped) != docURI {
337 return nil, caddyhttp.Error(http.StatusBadRequest, errors.New("escaped and decoded paths select different scripts"))
338 }
339 }
340
341 // Try to grab the path remainder from a file matcher
342 // if we didn't get a split result here.
343 // See https://github.com/caddyserver/caddy/issues/3718
344 if pathInfo == "" {
345 pathInfo, _ = repl.GetString("http.matchers.file.remainder")
346 }
347
348 // SCRIPT_FILENAME is the absolute path of SCRIPT_NAME
349 scriptFilename := caddyhttp.SanitizedPathJoin(root, scriptName)
350
351 // Ensure the SCRIPT_NAME has a leading slash for compliance with RFC3875
352 // Info: https://tools.ietf.org/html/rfc3875#section-4.1.13
353 if scriptName != "" && !strings.HasPrefix(scriptName, "/") {
354 scriptName = "/" + scriptName
355 }
356
357 // Get the request URL from context. The context stores the original URL in case
358 // it was changed by a middleware such as rewrite. By default, we pass the
359 // original URI in as the value of REQUEST_URI (the user can overwrite this
360 // if desired). Most PHP apps seem to want the original URI. Besides, this is
361 // how nginx defaults: http://stackoverflow.com/a/12485156/1048862
362 origReq := r.Context().Value(caddyhttp.OriginalRequestCtxKey).(http.Request)
363
364 requestScheme := "http"
365 if r.TLS != nil {
366 requestScheme = "https"
367 }
368
369 reqHost, reqPort, err := net.SplitHostPort(r.Host)
370 if err != nil {
371 // whatever, just assume there was no port
372 reqHost = r.Host
373 }
374
375 authUser, _ := repl.GetString("http.auth.user.id")
376
377 // Some variables are unused but cleared explicitly to prevent
378 // the parent environment from interfering.
379 env = envVars{
380 // Variables defined in CGI 1.1 spec
381 "AUTH_TYPE": "", // Not used
382 "CONTENT_LENGTH": r.Header.Get("Content-Length"),
383 "CONTENT_TYPE": r.Header.Get("Content-Type"),
384 "GATEWAY_INTERFACE": "CGI/1.1",
385 "PATH_INFO": pathInfo,
386 "QUERY_STRING": r.URL.RawQuery,
387 "REMOTE_ADDR": ip,
388 "REMOTE_HOST": ip, // For speed, remote host lookups disabled
389 "REMOTE_PORT": port,
390 "REMOTE_IDENT": "", // Not used
391 "REMOTE_USER": authUser,
392 "REQUEST_METHOD": r.Method,
393 "REQUEST_SCHEME": requestScheme,
394 "SERVER_NAME": reqHost,
395 "SERVER_PROTOCOL": r.Proto,
396 "SERVER_SOFTWARE": t.serverSoftware,
397
398 // Other variables
399 "DOCUMENT_ROOT": root,
400 "DOCUMENT_URI": docURI,
401 "HTTP_HOST": r.Host, // added here, since not always part of headers
402 "REQUEST_URI": origReq.URL.RequestURI(),
403 "SCRIPT_FILENAME": scriptFilename,
404 "SCRIPT_NAME": scriptName,
405 }
406
407 if localAddr, ok := r.Context().Value(http.LocalAddrContextKey).(net.Addr); ok {
408 var ipStr string
409 if host, _, err := net.SplitHostPort(localAddr.String()); err == nil {
410 ipStr = host
411 } else {
412 ipStr = localAddr.String()
413 }
414 if ip := net.ParseIP(ipStr); ip != nil {
415 env["SERVER_ADDR"] = ipStr
416 }
417 }
418
419 // compliance with the CGI specification requires that
420 // PATH_TRANSLATED should only exist if PATH_INFO is defined.
421 // Info: https://www.ietf.org/rfc/rfc3875 Page 14
422 if env["PATH_INFO"] != "" {
423 env["PATH_TRANSLATED"] = caddyhttp.SanitizedPathJoin(root, pathInfo) // Info: http://www.oreilly.com/openbook/cgi/ch02_04.html
424 }
425
426 // compliance with the CGI specification requires that
427 // the SERVER_PORT variable MUST be set to the TCP/IP port number on which this request is received from the client
428 // even if the port is the default port for the scheme and could otherwise be omitted from a URI.
429 // https://tools.ietf.org/html/rfc3875#section-4.1.15
430 if reqPort != "" {
431 env["SERVER_PORT"] = reqPort
432 } else if requestScheme == "http" {
433 env["SERVER_PORT"] = "80"
434 } else if requestScheme == "https" {
435 env["SERVER_PORT"] = "443"
436 }
437
438 // Some web apps rely on knowing HTTPS or not
439 if r.TLS != nil {
440 env["HTTPS"] = "on"
441 // and pass the protocol details in a manner compatible with apache's mod_ssl
442 // (which is why these have a SSL_ prefix and not TLS_).
443 v, ok := tlsProtocolStrings[r.TLS.Version]
444 if ok {
445 env["SSL_PROTOCOL"] = v
446 }
447 // and pass the cipher suite in a manner compatible with apache's mod_ssl
448 for _, cs := range caddytls.SupportedCipherSuites() {
449 if cs.ID == r.TLS.CipherSuite {
450 env["SSL_CIPHER"] = cs.Name
451 break
452 }
453 }
454 }
455
456 // Add all HTTP headers to env variables
457 for field, val := range r.Header {
458 switch http.CanonicalHeaderKey(field) {
459 // HTTP_HOST comes from r.Host, the host the request was routed on:
460 // over HTTP/2, net/http keeps a "host" field distinct from :authority.
461 case "Host":
462 continue
463 // HTTPoxy mitigation: client-supplied Proxy header must not
464 // be trusted for setting the HTTP_PROXY environment variable.
465 // See https://httpoxy.org for details.
466 case "Proxy":
467 continue
468 }
469
470 header := strings.ToUpper(field)
471 header = headerNameReplacer.Replace(header)
472 env["HTTP_"+header] = strings.Join(val, ", ")
473 }
474
475 // Add env variables from config (with support for placeholders in values)
476 // last, so a client header cannot override an HTTP_* variable set by the operator
477 for key, value := range t.EnvVars {
478 env[key] = repl.ReplaceAll(value, "")
479 }
480
481 return env, nil
482 }
483
484 // splitPos returns the index where path should
485 // be split based on t.SplitPath.
486 //
487 // example: if splitPath is [".php"]
488 // "/path/to/script.php/some/path": ("/path/to/script.php", "/some/path")
489 //
490 // A match must end the path or be followed by "/", as in the file matcher, so
491 // "/admin.php.php" or "/admin.phpx/y.php" can't run "/admin.php" past route
492 // matchers that judged another file.
493 //
494 // Matching is strictly ASCII case-insensitive. Bytes >= utf8.RuneSelf in path
495 // never match any split entry: split strings are validated ASCII-only and
496 // lower-cased in Provision(), so any Unicode equivalence (e.g. fullwidth or
497 // mathematical letters folding to ASCII) would let an attacker upload a file
498 // whose name contains such code points and have it served as PHP. See
499 // FrankenPHP advisories GHSA-3g8v-8r37-cgjm and GHSA-v4h7-cj44-8fc8.
500 //
501 // Adapted from FrankenPHP's code (copyright 2026 Kévin Dunglas, MIT license)
502 func (t Transport) splitPos(path string) int {
503 // TODO: from v1...
504 // if httpserver.CaseSensitivePath {
505 // return strings.Index(path, r.SplitPath)
506 // }
507 if len(t.SplitPath) == 0 {
508 return 0
509 }
510
511 pathLen := len(path)
512
513 for _, split := range t.SplitPath {
514 splitLen := len(split)
515 if splitLen == 0 || splitLen > pathLen {
516 continue
517 }
518
519 for i := 0; i <= pathLen-splitLen; i++ {
520 match := true
521 for j := range splitLen {
522 c := path[i+j]
523 if c >= utf8.RuneSelf {
524 match = false
525
526 break
527 }
528
529 if 'A' <= c && c <= 'Z' {
530 c += 'a' - 'A'
531 }
532
533 if c != split[j] {
534 match = false
535
536 break
537 }
538 }
539
540 if end := i + splitLen; match && (end == pathLen || path[end] == '/') {
541 return end
542 }
543 }
544 }
545
546 return -1
547 }
548
549 // scriptPart returns the part of path naming the script.
550 func (t Transport) scriptPart(path string) string {
551 if pos := t.splitPos(path); pos > -1 {
552 return path[:pos]
553 }
554 return path
555 }
556
557 type envVars map[string]string
558
559 // loggableEnv is a simple type to allow for speeding up zap log encoding.
560 type loggableEnv struct {
561 vars envVars
562 logCredentials bool
563 }
564
565 func (env loggableEnv) MarshalLogObject(enc zapcore.ObjectEncoder) error {
566 for k, v := range env.vars {
567 if !env.logCredentials {
568 switch strings.ToLower(k) {
569 case "http_cookie", "http_set_cookie", "http_authorization", "http_proxy_authorization":
570 v = ""
571 }
572 }
573 enc.AddString(k, v)
574 }
575 return nil
576 }
577
578 // Map of supported protocols to Apache ssl_mod format
579 // Note that these are slightly different from SupportedProtocols in caddytls/config.go
580 var tlsProtocolStrings = map[uint16]string{
581 tls.VersionTLS10: "TLSv1",
582 tls.VersionTLS11: "TLSv1.1",
583 tls.VersionTLS12: "TLSv1.2",
584 tls.VersionTLS13: "TLSv1.3",
585 }
586
587 var headerNameReplacer = strings.NewReplacer("-", "_")
588
589 // Interface guards
590 var (
591 _ zapcore.ObjectMarshaler = (*loggableEnv)(nil)
592
593 _ caddy.Provisioner = (*Transport)(nil)
594 _ http.RoundTripper = (*Transport)(nil)
595 _ reverseproxy.BufferedTransport = (*Transport)(nil)
596 _ reverseproxy.ContentLengthRequiredTransport = (*Transport)(nil)
597 )