mirror of
https://github.com/caddyserver/caddy.git
synced 2026-10-31 16:32:45 -04:00
* fastcgi: derive SCRIPT_FILENAME from the normalized request path
Path matchers evaluate a normalized request path — MatchPath applies
normalizeWindowsPath and then CleanPath before comparing — but the FastCGI
transport split r.URL.Path verbatim. Caddy does not rewrite r.URL.Path itself,
so the two disagreed whenever the path contained dot segments after the split
token: /admin.php/../../other.php was matched as /other.php while
SCRIPT_FILENAME resolved to <root>/admin.php, and on Windows the same held for
/admin.php\..\other.php. A route gated by a path matcher could therefore execute
a script that matcher never evaluated.
php_fastcgi generates a rewrite route that normally normalizes the path first,
but that route can fail to match, so the transport establishes the invariant
itself rather than inheriting it. The reason it can fail to match is addressed
in the following commit; this change does not depend on it.
caddyhttp.NormalizedPath is added so a consumer acting on a path a matcher has
already judged derives it exactly as the matcher did, rather than reassembling
the two steps and risking drift.
Scope — two divergences of the same class are NOT addressed here:
* SCRIPT_NAME remains a prefix of the request path, so a matcher naming a
script exactly (path /admin.php) is still reachable via PATH_INFO at
/admin.php/anything. Matchers protecting a script need a trailing wildcard
regardless; that is inherent to CGI, not to this bug.
* On Windows, NTFS 8.3 short names are not rejected here. fileserver's
ServeHTTP rejects them (and ':' for ADS) precisely because matchers cannot
see through them, so GET /ADMIN~1.PHP is judged as /admin~1.php by a
path matcher while NTFS resolves SCRIPT_FILENAME to admin.php. Closing that
means sharing fileserver's hasWindowsShortName across packages, which is a
larger change than this fix; it is reported alongside this one and a patch
is available on request.
Side effects: PATH_INFO and DOCUMENT_URI are now derived from the normalized
path, so repeated slashes are merged and "." segments removed (/index.php//a//b
yields PATH_INFO=/a/b rather than //a//b), and on Windows per-component trailing
dots and spaces are folded. An application routing on a raw PATH_INFO will see
this. An empty URL.Path stays empty rather than becoming ".". REQUEST_URI is
unchanged and still carries the original target.
Tests: a buildEnv regression over literal and percent-encoded dot segments,
covering that legitimate PATH_INFO is preserved and an empty path stays empty; a
Windows-only counterpart whose cases each fail without the Windows
normalization; and an end-to-end test of a basic_auth-protected php_fastcgi
route against a FastCGI responder reporting the SCRIPT_FILENAME it received. The
end-to-end test uses `index off` so no rewrite route exists, ensuring it
exercises the transport rather than the rewrite.
* fileserver: make first_exist_fallback fall back when the file is absent
The try_policy first_exist_fallback is documented to match unconditionally,
falling back to the last try_files entry when no earlier candidate exists. It
did not: candidates are built with fs.Glob, which returns only paths that exist,
so an absent final entry left the list empty and the fallback branch — nested
inside the loop over that list — was unreachable.
php_fastcgi hard-codes this policy so its generated rewrite route always runs;
when the fallback failed, that rewrite was silently skipped.
Keep the pattern itself as a candidate when nothing globbed. This also aligns
Linux with Windows, where globResults is populated without an existence check
and the fallback already fired.
Behaviour change worth calling out: on a php_fastcgi site whose root has no
index.php (or no file matching the configured index), the rewrite now always
runs, so a request for a path that exists neither on the filesystem nor as a PHP
script is proxied to the backend as <root>/index.php instead of falling through
to file_server and returning 404. That is what the documented fallback promises,
but for a site of standalone .php scripts plus static assets it is a visible
change: unmatched requests now reach PHP-FPM.
The other try policies still test existence explicitly, so a candidate that does
not exist is skipped — but note this applies to ANY pattern that expands to
nothing, not only a malformed glob: a file whose literal name contains glob
metacharacters can now match under first_exist, largest_size and smallest_size
where previously globbing found it only via expansion.
* fileserver: synthesize the literal candidate only for the fallback entry
Address review: the previous commit appended the pattern itself as a
candidate inside makeCandidates whenever globbing found nothing. Since
makeCandidates is shared by every try_policy, first_exist, largest_size,
smallest_size and most_recently_modified could then stat the literal
pattern, so a file whose name contains glob metacharacters (for example
"lit[1].txt") matched where it did not before.
Restore makeCandidates to its original behaviour and build the literal
candidate only for the final entry of first_exist_fallback, the one
entry that is documented to match unconditionally. Placeholders in that
entry are expanded without glob escaping, since nothing is globbed, so
a request for /a[b].txt falls back to /a[b].txt rather than /a\[b].txt.
The path is still cleaned and joined with SanitizedPathJoin, so it
cannot escape the root.
This supersedes the last paragraph of the previous commit message: the
other policies now behave exactly as they did before that commit. The
php_fastcgi behaviour change described there still applies.
Add a regression test that creates a file literally named "lit[1].txt"
and checks that only the fallback entry of first_exist_fallback matches
it by its literal name, including the split_path remainder.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fastcgi: add license headers to new test files
Address review: phpfastcgi_test.go and fastcgi_windows_test.go were
added without the Apache license header used across the codebase. On
the Windows test, the header goes above the build constraint, matching
replacer_fuzz.go.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fastcgi: avoid hardcoded Unix paths in dot-segment tests
Address review: the new tests compared SCRIPT_FILENAME against strings
with forward slashes ("/srv/other.php", a "/admin.php" suffix), but the
transport makes the root absolute and joins it with filepath.Join, so
on Windows the value is C:\srv\other.php and the assertions would fail.
Use a temporary directory as the root and build the expected paths with
filepath instead.
Also lower the bcrypt cost of the integration test's basic_auth hash to
the minimum. At cost 14 the authenticated request exceeded the 5s test
client timeout under -race.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
556 lines
17 KiB
Go
556 lines
17 KiB
Go
// Copyright 2015 Matthew Holt and The Caddy Authors
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package fastcgi
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"go.uber.org/zap"
|
|
"go.uber.org/zap/zapcore"
|
|
|
|
"github.com/caddyserver/caddy/v2"
|
|
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
|
|
"github.com/caddyserver/caddy/v2/modules/caddyhttp/reverseproxy"
|
|
"github.com/caddyserver/caddy/v2/modules/caddytls"
|
|
)
|
|
|
|
var (
|
|
ErrInvalidSplitPath = errors.New("split path contains non-ASCII characters")
|
|
|
|
noopLogger = zap.NewNop()
|
|
)
|
|
|
|
func init() {
|
|
caddy.RegisterModule(Transport{})
|
|
}
|
|
|
|
// Transport facilitates FastCGI communication.
|
|
type Transport struct {
|
|
// Use this directory as the fastcgi root directory. Defaults to the root
|
|
// directory of the parent virtual host.
|
|
Root string `json:"root,omitempty"`
|
|
|
|
// The path in the URL will be split into two, with the first piece ending
|
|
// with the value of SplitPath. The first piece will be assumed as the
|
|
// actual resource (CGI script) name, and the second piece will be set to
|
|
// PATH_INFO for the CGI script to use.
|
|
//
|
|
// Split paths can only contain ASCII characters.
|
|
// Comparison is case-insensitive.
|
|
//
|
|
// Future enhancements should be careful to avoid CVE-2019-11043,
|
|
// which can be mitigated with use of a try_files-like behavior
|
|
// that 404s if the fastcgi path info is not found.
|
|
SplitPath []string `json:"split_path,omitempty"`
|
|
|
|
// Path declared as root directory will be resolved to its absolute value
|
|
// after the evaluation of any symbolic links.
|
|
// Due to the nature of PHP opcache, root directory path is cached: when
|
|
// using a symlinked directory as root this could generate errors when
|
|
// symlink is changed without php-fpm being restarted; enabling this
|
|
// directive will set $_SERVER['DOCUMENT_ROOT'] to the real directory path.
|
|
ResolveRootSymlink bool `json:"resolve_root_symlink,omitempty"`
|
|
|
|
// Extra environment variables.
|
|
EnvVars map[string]string `json:"env,omitempty"`
|
|
|
|
// The duration used to set a deadline when connecting to an upstream. Default: `3s`.
|
|
DialTimeout caddy.Duration `json:"dial_timeout,omitempty"`
|
|
|
|
// The duration used to set a deadline when reading from the FastCGI server.
|
|
ReadTimeout caddy.Duration `json:"read_timeout,omitempty"`
|
|
|
|
// The duration used to set a deadline when sending to the FastCGI server.
|
|
WriteTimeout caddy.Duration `json:"write_timeout,omitempty"`
|
|
|
|
// Capture and log any messages sent by the upstream on stderr. Logs at WARN
|
|
// level by default. If the response has a 4xx or 5xx status ERROR level will
|
|
// be used instead.
|
|
CaptureStderr bool `json:"capture_stderr,omitempty"`
|
|
|
|
serverSoftware string
|
|
logger *zap.Logger
|
|
}
|
|
|
|
// CaddyModule returns the Caddy module information.
|
|
func (Transport) CaddyModule() caddy.ModuleInfo {
|
|
return caddy.ModuleInfo{
|
|
ID: "http.reverse_proxy.transport.fastcgi",
|
|
New: func() caddy.Module { return new(Transport) },
|
|
}
|
|
}
|
|
|
|
// Provision sets up t.
|
|
func (t *Transport) Provision(ctx caddy.Context) error {
|
|
t.logger = ctx.Logger()
|
|
|
|
if t.Root == "" {
|
|
t.Root = "{http.vars.root}"
|
|
}
|
|
|
|
version, _ := caddy.Version()
|
|
t.serverSoftware = "Caddy/" + version
|
|
|
|
// Set a relatively short default dial timeout.
|
|
// This is helpful to make load-balancer retries more speedy.
|
|
if t.DialTimeout == 0 {
|
|
t.DialTimeout = caddy.Duration(3 * time.Second)
|
|
}
|
|
|
|
var b strings.Builder
|
|
|
|
for i, split := range t.SplitPath {
|
|
b.Grow(len(split))
|
|
|
|
for j := 0; j < len(split); j++ {
|
|
c := split[j]
|
|
if c >= utf8.RuneSelf {
|
|
return ErrInvalidSplitPath
|
|
}
|
|
|
|
if 'A' <= c && c <= 'Z' {
|
|
b.WriteByte(c + 'a' - 'A')
|
|
} else {
|
|
b.WriteByte(c)
|
|
}
|
|
}
|
|
|
|
t.SplitPath[i] = b.String()
|
|
b.Reset()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// DefaultBufferSizes enables request buffering for fastcgi if not configured.
|
|
// This is because most fastcgi servers are php-fpm that require the content length to be set to read the body, golang
|
|
// std has fastcgi implementation that doesn't need this value to process the body, but we can safely assume that's
|
|
// not used.
|
|
// http3 requests have a negative content length for GET and HEAD requests, if that header is not sent.
|
|
// see: https://github.com/caddyserver/caddy/issues/6678#issuecomment-2472224182
|
|
// Though it appears even if CONTENT_LENGTH is invalid, php-fpm can handle just fine if the body is empty (no Stdin records sent).
|
|
// php-fpm will hang if there is any data in the body though, https://github.com/caddyserver/caddy/issues/5420#issuecomment-2415943516
|
|
|
|
// TODO: better default buffering for fastcgi requests without content length, in theory a value of 1 should be enough, make it bigger anyway
|
|
func (t Transport) DefaultBufferSizes() (int64, int64) {
|
|
return 4096, 0
|
|
}
|
|
|
|
// RequiresContentLength reports that fastcgi cannot forward a body of unknown
|
|
// length: without a valid CONTENT_LENGTH the client rejects the request rather
|
|
// than let the upstream hang, so the request buffer above is the only way to
|
|
// learn the length of a body that does not announce one.
|
|
func (t Transport) RequiresContentLength() bool {
|
|
return true
|
|
}
|
|
|
|
// RoundTrip implements http.RoundTripper.
|
|
func (t Transport) RoundTrip(r *http.Request) (*http.Response, error) {
|
|
server := r.Context().Value(caddyhttp.ServerCtxKey).(*caddyhttp.Server)
|
|
|
|
// Disallow null bytes in the request path, because
|
|
// PHP upstreams may do bad things, like execute a
|
|
// non-PHP file as PHP code. See #4574
|
|
if strings.Contains(r.URL.Path, "\x00") {
|
|
return nil, caddyhttp.Error(http.StatusBadRequest, fmt.Errorf("invalid request path"))
|
|
}
|
|
|
|
env, err := t.buildEnv(r)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("building environment: %v", err)
|
|
}
|
|
|
|
ctx := r.Context()
|
|
|
|
// extract dial information from request (should have been embedded by the reverse proxy)
|
|
network, address := "tcp", r.URL.Host
|
|
if dialInfo, ok := reverseproxy.GetDialInfo(ctx); ok {
|
|
network = dialInfo.Network
|
|
address = dialInfo.Address
|
|
}
|
|
|
|
logCreds := server.Logs != nil && server.Logs.ShouldLogCredentials
|
|
loggableReq := caddyhttp.LoggableHTTPRequest{
|
|
Request: r,
|
|
ShouldLogCredentials: logCreds,
|
|
}
|
|
loggableEnv := loggableEnv{vars: env, logCredentials: logCreds}
|
|
|
|
logger := t.logger.With(
|
|
zap.Object("request", loggableReq),
|
|
zap.Object("env", loggableEnv),
|
|
)
|
|
if c := t.logger.Check(zapcore.DebugLevel, "roundtrip"); c != nil {
|
|
c.Write(
|
|
zap.String("dial", address),
|
|
zap.Object("env", loggableEnv),
|
|
zap.Object("request", loggableReq),
|
|
)
|
|
}
|
|
|
|
// connect to the backend
|
|
dialer := net.Dialer{Timeout: time.Duration(t.DialTimeout)}
|
|
conn, err := dialer.DialContext(ctx, network, address)
|
|
if err != nil {
|
|
return nil, reverseproxy.NewDialError(fmt.Errorf("dialing backend: %w", err))
|
|
}
|
|
defer func() {
|
|
// conn will be closed with the response body unless there's an error
|
|
if err != nil {
|
|
conn.Close()
|
|
}
|
|
}()
|
|
|
|
// create the client that will facilitate the protocol
|
|
client := client{
|
|
rwc: conn,
|
|
reqID: 1,
|
|
logger: logger,
|
|
stderr: t.CaptureStderr,
|
|
}
|
|
|
|
// read/write timeouts
|
|
if err = client.SetReadTimeout(time.Duration(t.ReadTimeout)); err != nil {
|
|
return nil, fmt.Errorf("setting read timeout: %v", err)
|
|
}
|
|
if err = client.SetWriteTimeout(time.Duration(t.WriteTimeout)); err != nil {
|
|
return nil, fmt.Errorf("setting write timeout: %v", err)
|
|
}
|
|
|
|
contentLength := r.ContentLength
|
|
if contentLength == 0 {
|
|
contentLength, _ = strconv.ParseInt(r.Header.Get("Content-Length"), 10, 64)
|
|
}
|
|
|
|
var resp *http.Response
|
|
switch r.Method {
|
|
case http.MethodHead:
|
|
resp, err = client.Head(env)
|
|
case http.MethodGet:
|
|
resp, err = client.Get(env, r.Body, contentLength)
|
|
case http.MethodOptions:
|
|
resp, err = client.Options(env)
|
|
default:
|
|
resp, err = client.Post(env, r.Method, r.Header.Get("Content-Type"), r.Body, contentLength)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
// buildEnv returns a set of CGI environment variables for the request.
|
|
func (t Transport) buildEnv(r *http.Request) (envVars, error) {
|
|
repl := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer)
|
|
|
|
var env envVars
|
|
|
|
// Separate remote IP and port; more lenient than net.SplitHostPort
|
|
var ip, port string
|
|
if idx := strings.LastIndex(r.RemoteAddr, ":"); idx > -1 {
|
|
ip = r.RemoteAddr[:idx]
|
|
port = r.RemoteAddr[idx+1:]
|
|
} else {
|
|
ip = r.RemoteAddr
|
|
}
|
|
|
|
// Remove [] from IPv6 addresses
|
|
ip = strings.Replace(ip, "[", "", 1)
|
|
ip = strings.Replace(ip, "]", "", 1)
|
|
|
|
// make sure file root is absolute
|
|
root, err := caddy.FastAbs(repl.ReplaceAll(t.Root, "."))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if t.ResolveRootSymlink {
|
|
root, err = filepath.EvalSymlinks(root)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// Derive the script from the same path model the matchers judged: Windows
|
|
// separators and per-component trailing dots/spaces normalized, then cleaned
|
|
// (mirroring MatchPath and MatchURLPattern). Caddy does not rewrite
|
|
// r.URL.Path itself, and the rewrite route php_fastcgi relies on to normalize
|
|
// it can fail to match, so the transport must establish this invariant rather
|
|
// than inherit it. Without this, /admin.php/../../other.php (or, on Windows,
|
|
// /admin.php\..\other.php) is judged by every path matcher as /other.php
|
|
// while SCRIPT_FILENAME resolves to /admin.php.
|
|
fpath := caddyhttp.NormalizedPath(r.URL.Path, true)
|
|
scriptName := fpath
|
|
|
|
docURI := fpath
|
|
// split "actual path" from "path info" if configured
|
|
var pathInfo string
|
|
if splitPos := t.splitPos(fpath); splitPos > -1 {
|
|
docURI = fpath[:splitPos]
|
|
pathInfo = fpath[splitPos:]
|
|
|
|
// Strip PATH_INFO from SCRIPT_NAME
|
|
scriptName = strings.TrimSuffix(scriptName, pathInfo)
|
|
}
|
|
|
|
// Try to grab the path remainder from a file matcher
|
|
// if we didn't get a split result here.
|
|
// See https://github.com/caddyserver/caddy/issues/3718
|
|
if pathInfo == "" {
|
|
pathInfo, _ = repl.GetString("http.matchers.file.remainder")
|
|
}
|
|
|
|
// SCRIPT_FILENAME is the absolute path of SCRIPT_NAME
|
|
scriptFilename := caddyhttp.SanitizedPathJoin(root, scriptName)
|
|
|
|
// Ensure the SCRIPT_NAME has a leading slash for compliance with RFC3875
|
|
// Info: https://tools.ietf.org/html/rfc3875#section-4.1.13
|
|
if scriptName != "" && !strings.HasPrefix(scriptName, "/") {
|
|
scriptName = "/" + scriptName
|
|
}
|
|
|
|
// Get the request URL from context. The context stores the original URL in case
|
|
// it was changed by a middleware such as rewrite. By default, we pass the
|
|
// original URI in as the value of REQUEST_URI (the user can overwrite this
|
|
// if desired). Most PHP apps seem to want the original URI. Besides, this is
|
|
// how nginx defaults: http://stackoverflow.com/a/12485156/1048862
|
|
origReq := r.Context().Value(caddyhttp.OriginalRequestCtxKey).(http.Request)
|
|
|
|
requestScheme := "http"
|
|
if r.TLS != nil {
|
|
requestScheme = "https"
|
|
}
|
|
|
|
reqHost, reqPort, err := net.SplitHostPort(r.Host)
|
|
if err != nil {
|
|
// whatever, just assume there was no port
|
|
reqHost = r.Host
|
|
}
|
|
|
|
authUser, _ := repl.GetString("http.auth.user.id")
|
|
|
|
// Some variables are unused but cleared explicitly to prevent
|
|
// the parent environment from interfering.
|
|
env = envVars{
|
|
// Variables defined in CGI 1.1 spec
|
|
"AUTH_TYPE": "", // Not used
|
|
"CONTENT_LENGTH": r.Header.Get("Content-Length"),
|
|
"CONTENT_TYPE": r.Header.Get("Content-Type"),
|
|
"GATEWAY_INTERFACE": "CGI/1.1",
|
|
"PATH_INFO": pathInfo,
|
|
"QUERY_STRING": r.URL.RawQuery,
|
|
"REMOTE_ADDR": ip,
|
|
"REMOTE_HOST": ip, // For speed, remote host lookups disabled
|
|
"REMOTE_PORT": port,
|
|
"REMOTE_IDENT": "", // Not used
|
|
"REMOTE_USER": authUser,
|
|
"REQUEST_METHOD": r.Method,
|
|
"REQUEST_SCHEME": requestScheme,
|
|
"SERVER_NAME": reqHost,
|
|
"SERVER_PROTOCOL": r.Proto,
|
|
"SERVER_SOFTWARE": t.serverSoftware,
|
|
|
|
// Other variables
|
|
"DOCUMENT_ROOT": root,
|
|
"DOCUMENT_URI": docURI,
|
|
"HTTP_HOST": r.Host, // added here, since not always part of headers
|
|
"REQUEST_URI": origReq.URL.RequestURI(),
|
|
"SCRIPT_FILENAME": scriptFilename,
|
|
"SCRIPT_NAME": scriptName,
|
|
}
|
|
|
|
if localAddr, ok := r.Context().Value(http.LocalAddrContextKey).(net.Addr); ok {
|
|
var ipStr string
|
|
if host, _, err := net.SplitHostPort(localAddr.String()); err == nil {
|
|
ipStr = host
|
|
} else {
|
|
ipStr = localAddr.String()
|
|
}
|
|
if ip := net.ParseIP(ipStr); ip != nil {
|
|
env["SERVER_ADDR"] = ipStr
|
|
}
|
|
}
|
|
|
|
// compliance with the CGI specification requires that
|
|
// PATH_TRANSLATED should only exist if PATH_INFO is defined.
|
|
// Info: https://www.ietf.org/rfc/rfc3875 Page 14
|
|
if env["PATH_INFO"] != "" {
|
|
env["PATH_TRANSLATED"] = caddyhttp.SanitizedPathJoin(root, pathInfo) // Info: http://www.oreilly.com/openbook/cgi/ch02_04.html
|
|
}
|
|
|
|
// compliance with the CGI specification requires that
|
|
// the SERVER_PORT variable MUST be set to the TCP/IP port number on which this request is received from the client
|
|
// even if the port is the default port for the scheme and could otherwise be omitted from a URI.
|
|
// https://tools.ietf.org/html/rfc3875#section-4.1.15
|
|
if reqPort != "" {
|
|
env["SERVER_PORT"] = reqPort
|
|
} else if requestScheme == "http" {
|
|
env["SERVER_PORT"] = "80"
|
|
} else if requestScheme == "https" {
|
|
env["SERVER_PORT"] = "443"
|
|
}
|
|
|
|
// Some web apps rely on knowing HTTPS or not
|
|
if r.TLS != nil {
|
|
env["HTTPS"] = "on"
|
|
// and pass the protocol details in a manner compatible with apache's mod_ssl
|
|
// (which is why these have a SSL_ prefix and not TLS_).
|
|
v, ok := tlsProtocolStrings[r.TLS.Version]
|
|
if ok {
|
|
env["SSL_PROTOCOL"] = v
|
|
}
|
|
// and pass the cipher suite in a manner compatible with apache's mod_ssl
|
|
for _, cs := range caddytls.SupportedCipherSuites() {
|
|
if cs.ID == r.TLS.CipherSuite {
|
|
env["SSL_CIPHER"] = cs.Name
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add env variables from config (with support for placeholders in values)
|
|
for key, value := range t.EnvVars {
|
|
env[key] = repl.ReplaceAll(value, "")
|
|
}
|
|
|
|
// Add all HTTP headers to env variables
|
|
for field, val := range r.Header {
|
|
// HTTPoxy mitigation: client-supplied Proxy header must not
|
|
// be trusted for setting the HTTP_PROXY environment variable.
|
|
// See https://httpoxy.org for details.
|
|
if http.CanonicalHeaderKey(field) == "Proxy" {
|
|
continue
|
|
}
|
|
|
|
header := strings.ToUpper(field)
|
|
header = headerNameReplacer.Replace(header)
|
|
env["HTTP_"+header] = strings.Join(val, ", ")
|
|
}
|
|
return env, nil
|
|
}
|
|
|
|
// splitPos returns the index where path should
|
|
// be split based on t.SplitPath.
|
|
//
|
|
// example: if splitPath is [".php"]
|
|
// "/path/to/script.php/some/path": ("/path/to/script.php", "/some/path")
|
|
//
|
|
// Matching is strictly ASCII case-insensitive. Bytes >= utf8.RuneSelf in path
|
|
// never match any split entry: split strings are validated ASCII-only and
|
|
// lower-cased in Provision(), so any Unicode equivalence (e.g. fullwidth or
|
|
// mathematical letters folding to ASCII) would let an attacker upload a file
|
|
// whose name contains such code points and have it served as PHP. See
|
|
// FrankenPHP advisories GHSA-3g8v-8r37-cgjm and GHSA-v4h7-cj44-8fc8.
|
|
//
|
|
// Adapted from FrankenPHP's code (copyright 2026 Kévin Dunglas, MIT license)
|
|
func (t Transport) splitPos(path string) int {
|
|
// TODO: from v1...
|
|
// if httpserver.CaseSensitivePath {
|
|
// return strings.Index(path, r.SplitPath)
|
|
// }
|
|
if len(t.SplitPath) == 0 {
|
|
return 0
|
|
}
|
|
|
|
pathLen := len(path)
|
|
|
|
for _, split := range t.SplitPath {
|
|
splitLen := len(split)
|
|
if splitLen == 0 || splitLen > pathLen {
|
|
continue
|
|
}
|
|
|
|
for i := 0; i <= pathLen-splitLen; i++ {
|
|
match := true
|
|
for j := range splitLen {
|
|
c := path[i+j]
|
|
if c >= utf8.RuneSelf {
|
|
match = false
|
|
|
|
break
|
|
}
|
|
|
|
if 'A' <= c && c <= 'Z' {
|
|
c += 'a' - 'A'
|
|
}
|
|
|
|
if c != split[j] {
|
|
match = false
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
if match {
|
|
return i + splitLen
|
|
}
|
|
}
|
|
}
|
|
|
|
return -1
|
|
}
|
|
|
|
type envVars map[string]string
|
|
|
|
// loggableEnv is a simple type to allow for speeding up zap log encoding.
|
|
type loggableEnv struct {
|
|
vars envVars
|
|
logCredentials bool
|
|
}
|
|
|
|
func (env loggableEnv) MarshalLogObject(enc zapcore.ObjectEncoder) error {
|
|
for k, v := range env.vars {
|
|
if !env.logCredentials {
|
|
switch strings.ToLower(k) {
|
|
case "http_cookie", "http_set_cookie", "http_authorization", "http_proxy_authorization":
|
|
v = ""
|
|
}
|
|
}
|
|
enc.AddString(k, v)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Map of supported protocols to Apache ssl_mod format
|
|
// Note that these are slightly different from SupportedProtocols in caddytls/config.go
|
|
var tlsProtocolStrings = map[uint16]string{
|
|
tls.VersionTLS10: "TLSv1",
|
|
tls.VersionTLS11: "TLSv1.1",
|
|
tls.VersionTLS12: "TLSv1.2",
|
|
tls.VersionTLS13: "TLSv1.3",
|
|
}
|
|
|
|
var headerNameReplacer = strings.NewReplacer("-", "_")
|
|
|
|
// Interface guards
|
|
var (
|
|
_ zapcore.ObjectMarshaler = (*loggableEnv)(nil)
|
|
|
|
_ caddy.Provisioner = (*Transport)(nil)
|
|
_ http.RoundTripper = (*Transport)(nil)
|
|
_ reverseproxy.BufferedTransport = (*Transport)(nil)
|
|
_ reverseproxy.ContentLengthRequiredTransport = (*Transport)(nil)
|
|
)
|