mirror of
https://github.com/caddyserver/caddy.git
synced 2026-09-16 07:42:04 -04:00
* Implement HTTPoxy mitigation in FastCGI Added HTTPoxy mitigation to prevent trusting client-supplied Proxy header for HTTP_PROXY environment variable. * Implement test for HTTPoxy vulnerability protection Add test to ensure HTTPoxy vulnerability is mitigated by dropping client-supplied Proxy headers. * gofmt: format code * revert unrelated gofmt change to replacer_test.go
488 lines
15 KiB
Go
488 lines
15 KiB
Go
package fastcgi
|
||
|
||
import (
|
||
"context"
|
||
"net"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"testing"
|
||
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
|
||
"github.com/caddyserver/caddy/v2"
|
||
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
|
||
)
|
||
|
||
func TestProvisionSplitPath(t *testing.T) {
|
||
tests := []struct {
|
||
name string
|
||
splitPath []string
|
||
wantErr error
|
||
wantSplitPath []string
|
||
}{
|
||
{
|
||
name: "valid lowercase split path",
|
||
splitPath: []string{".php"},
|
||
wantErr: nil,
|
||
wantSplitPath: []string{".php"},
|
||
},
|
||
{
|
||
name: "valid uppercase split path normalized",
|
||
splitPath: []string{".PHP"},
|
||
wantErr: nil,
|
||
wantSplitPath: []string{".php"},
|
||
},
|
||
{
|
||
name: "valid mixed case split path normalized",
|
||
splitPath: []string{".PhP", ".PHTML"},
|
||
wantErr: nil,
|
||
wantSplitPath: []string{".php", ".phtml"},
|
||
},
|
||
{
|
||
name: "empty split path",
|
||
splitPath: []string{},
|
||
wantErr: nil,
|
||
wantSplitPath: []string{},
|
||
},
|
||
{
|
||
name: "non-ASCII character in split path rejected",
|
||
splitPath: []string{".php", ".Ⱥphp"},
|
||
wantErr: ErrInvalidSplitPath,
|
||
},
|
||
{
|
||
name: "unicode character in split path rejected",
|
||
splitPath: []string{".phpⱥ"},
|
||
wantErr: ErrInvalidSplitPath,
|
||
},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
tr := Transport{SplitPath: tt.splitPath}
|
||
err := tr.Provision(caddy.Context{})
|
||
|
||
if tt.wantErr != nil {
|
||
require.ErrorIs(t, err, tt.wantErr)
|
||
|
||
return
|
||
}
|
||
|
||
require.NoError(t, err)
|
||
assert.Equal(t, tt.wantSplitPath, tr.SplitPath)
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestSplitPos(t *testing.T) {
|
||
tests := []struct {
|
||
name string
|
||
path string
|
||
splitPath []string
|
||
wantPos int
|
||
}{
|
||
{
|
||
name: "simple php extension",
|
||
path: "/path/to/script.php",
|
||
splitPath: []string{".php"},
|
||
wantPos: 19,
|
||
},
|
||
{
|
||
name: "php extension with path info",
|
||
path: "/path/to/script.php/some/path",
|
||
splitPath: []string{".php"},
|
||
wantPos: 19,
|
||
},
|
||
{
|
||
name: "case insensitive match",
|
||
path: "/path/to/script.PHP",
|
||
splitPath: []string{".php"},
|
||
wantPos: 19,
|
||
},
|
||
{
|
||
name: "mixed case match",
|
||
path: "/path/to/script.PhP/info",
|
||
splitPath: []string{".php"},
|
||
wantPos: 19,
|
||
},
|
||
{
|
||
name: "no match",
|
||
path: "/path/to/script.txt",
|
||
splitPath: []string{".php"},
|
||
wantPos: -1,
|
||
},
|
||
{
|
||
name: "empty split path",
|
||
path: "/path/to/script.php",
|
||
splitPath: []string{},
|
||
wantPos: 0,
|
||
},
|
||
{
|
||
name: "multiple split paths first match",
|
||
path: "/path/to/script.php",
|
||
splitPath: []string{".php", ".phtml"},
|
||
wantPos: 19,
|
||
},
|
||
{
|
||
name: "multiple split paths second match",
|
||
path: "/path/to/script.phtml",
|
||
splitPath: []string{".php", ".phtml"},
|
||
wantPos: 21,
|
||
},
|
||
// Unicode case-folding tests (security fix for GHSA-g966-83w7-6w38)
|
||
// U+023A (Ⱥ) lowercases to U+2C65 (ⱥ), which has different UTF-8 byte length
|
||
// Ⱥ: 2 bytes (C8 BA), ⱥ: 3 bytes (E2 B1 A5)
|
||
{
|
||
name: "unicode path with case-folding length expansion",
|
||
path: "/ȺȺȺȺshell.php",
|
||
splitPath: []string{".php"},
|
||
wantPos: 18, // correct position in original string
|
||
},
|
||
{
|
||
name: "unicode path with extension after expansion chars",
|
||
path: "/ȺȺȺȺshell.php/path/info",
|
||
splitPath: []string{".php"},
|
||
wantPos: 18,
|
||
},
|
||
{
|
||
name: "unicode in filename with multiple php occurrences",
|
||
path: "/ȺȺȺȺshell.php.txt.php",
|
||
splitPath: []string{".php"},
|
||
wantPos: 18, // should match first .php, not be confused by byte offset shift
|
||
},
|
||
{
|
||
name: "unicode case insensitive extension",
|
||
path: "/ȺȺȺȺshell.PHP",
|
||
splitPath: []string{".php"},
|
||
wantPos: 18,
|
||
},
|
||
{
|
||
name: "unicode in middle of path",
|
||
path: "/path/Ⱥtest/script.php",
|
||
splitPath: []string{".php"},
|
||
wantPos: 23, // Ⱥ is 2 bytes, so path is 23 bytes total, .php ends at byte 23
|
||
},
|
||
{
|
||
name: "unicode only in directory not filename",
|
||
path: "/Ⱥ/script.php",
|
||
splitPath: []string{".php"},
|
||
wantPos: 14,
|
||
},
|
||
// Additional Unicode characters that expand when lowercased
|
||
// U+0130 (İ - Turkish capital I with dot) lowercases to U+0069 + U+0307
|
||
{
|
||
name: "turkish capital I with dot",
|
||
path: "/İtest.php",
|
||
splitPath: []string{".php"},
|
||
wantPos: 11,
|
||
},
|
||
// Ensure standard ASCII still works correctly
|
||
{
|
||
name: "ascii only path with case variation",
|
||
path: "/PATH/TO/SCRIPT.PHP/INFO",
|
||
splitPath: []string{".php"},
|
||
wantPos: 19,
|
||
},
|
||
{
|
||
name: "path at root",
|
||
path: "/index.php",
|
||
splitPath: []string{".php"},
|
||
wantPos: 10,
|
||
},
|
||
{
|
||
name: "extension in middle of filename",
|
||
path: "/test.php.bak",
|
||
splitPath: []string{".php"},
|
||
wantPos: 9,
|
||
},
|
||
// Regression tests adapted from FrankenPHP advisories
|
||
// GHSA-3g8v-8r37-cgjm and GHSA-v4h7-cj44-8fc8: search.IgnoreCase
|
||
// matched Unicode equivalents of ASCII letters as ".php", and an
|
||
// inner non-ASCII byte path could leave the match flag stale.
|
||
{
|
||
name: "non-ascii byte after dot must not match",
|
||
path: "/PoC-match-unset.¡.txt",
|
||
splitPath: []string{".php"},
|
||
wantPos: -1,
|
||
},
|
||
{
|
||
name: "non-ascii byte mid-extension must not match",
|
||
path: "/script.p\xc2\xa1p",
|
||
splitPath: []string{".php"},
|
||
wantPos: -1,
|
||
},
|
||
{
|
||
name: "small full stop ﹒ in extension must not match",
|
||
path: "/shell﹒php",
|
||
splitPath: []string{".php"},
|
||
wantPos: -1,
|
||
},
|
||
{
|
||
name: "fullwidth full stop . in extension must not match",
|
||
path: "/shell.php",
|
||
splitPath: []string{".php"},
|
||
wantPos: -1,
|
||
},
|
||
{
|
||
name: "fullwidth p in extension must not match",
|
||
path: "/shell.php",
|
||
splitPath: []string{".php"},
|
||
wantPos: -1,
|
||
},
|
||
{
|
||
name: "circled php must not match",
|
||
path: "/shell.ⓟⓗⓟ",
|
||
splitPath: []string{".php"},
|
||
wantPos: -1,
|
||
},
|
||
{
|
||
name: "mathematical sans-serif bold php must not match",
|
||
path: "/shell.\U0001D5FD\U0001D5F5\U0001D5FD",
|
||
splitPath: []string{".php"},
|
||
wantPos: -1,
|
||
},
|
||
{
|
||
name: "mathematical script php must not match",
|
||
path: "/shell.\U0001D4C5\U0001D4BD\U0001D4C5",
|
||
splitPath: []string{".php"},
|
||
wantPos: -1,
|
||
},
|
||
{
|
||
name: "circled php with later real php still picks the real one",
|
||
path: "/shell.ⓟⓗⓟ.anything-after-payload.php",
|
||
splitPath: []string{".php"},
|
||
// "/shell." (7) + "ⓟⓗⓟ" (3*3 bytes) + ".anything-after-payload.php" (27) = 43
|
||
wantPos: 43,
|
||
},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
gotPos := Transport{SplitPath: tt.splitPath}.splitPos(tt.path)
|
||
assert.Equal(t, tt.wantPos, gotPos, "splitPos(%q, %v)", tt.path, tt.splitPath)
|
||
|
||
// Verify that the split produces valid substrings
|
||
if gotPos > 0 && gotPos <= len(tt.path) {
|
||
scriptName := tt.path[:gotPos]
|
||
pathInfo := tt.path[gotPos:]
|
||
|
||
// The script name should end with one of the split extensions (case-insensitive)
|
||
hasValidEnding := false
|
||
for _, split := range tt.splitPath {
|
||
if strings.HasSuffix(strings.ToLower(scriptName), split) {
|
||
hasValidEnding = true
|
||
break
|
||
}
|
||
}
|
||
assert.True(t, hasValidEnding, "script name %q should end with one of %v", scriptName, tt.splitPath)
|
||
|
||
// Original path should be reconstructable
|
||
assert.Equal(t, tt.path, scriptName+pathInfo, "path should be reconstructable from split parts")
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestSplitPosUnicodeSecurityRegression specifically tests the vulnerability
|
||
// described in GHSA-g966-83w7-6w38 where Unicode case-folding caused
|
||
// incorrect SCRIPT_NAME/PATH_INFO splitting
|
||
func TestSplitPosUnicodeSecurityRegression(t *testing.T) {
|
||
// U+023A: Ⱥ (UTF-8: C8 BA). Lowercase is ⱥ (UTF-8: E2 B1 A5), longer in bytes.
|
||
path := "/ȺȺȺȺshell.php.txt.php"
|
||
split := []string{".php"}
|
||
|
||
pos := Transport{SplitPath: split}.splitPos(path)
|
||
|
||
// The vulnerable code would return 22 (computed on lowercased string)
|
||
// The correct code should return 18 (position in original string)
|
||
expectedPos := strings.Index(path, ".php") + len(".php")
|
||
assert.Equal(t, expectedPos, pos, "split position should match first .php in original string")
|
||
assert.Equal(t, 18, pos, "split position should be 18, not 22")
|
||
|
||
if pos > 0 && pos <= len(path) {
|
||
scriptName := path[:pos]
|
||
pathInfo := path[pos:]
|
||
|
||
assert.Equal(t, "/ȺȺȺȺshell.php", scriptName, "script name should be the path up to first .php")
|
||
assert.Equal(t, ".txt.php", pathInfo, "path info should be the remainder after first .php")
|
||
}
|
||
}
|
||
|
||
// TestHeaderNameReplacer asserts the CGI header-to-env normalization rule:
|
||
// hyphens are mapped to underscores while every other character (including
|
||
// spaces and dots) is passed through. Spaces are not RFC 7230 tokens, so they
|
||
// cannot reach this function from the wire; the only header names that
|
||
// survive untouched at the server layer are sanitized by the underscore/dot
|
||
// filter in caddyhttp.Server.serveHTTP (see GHSA-f59h-q822-g45g,
|
||
// GHSA-49wc-4hcv-v58q).
|
||
func TestHeaderNameReplacer(t *testing.T) {
|
||
tests := []struct {
|
||
in, want string
|
||
}{
|
||
{"X-Forwarded-For", "X_Forwarded_For"},
|
||
{"Remote-User", "Remote_User"},
|
||
// Underscores are preserved (the server has already dropped any
|
||
// underscore-named headers when the filter is on).
|
||
{"Remote_User", "Remote_User"},
|
||
// Dots are preserved by this replacer too; the server-layer filter is
|
||
// what prevents a dotted alias (e.g. Remote.User) from reaching here.
|
||
{"Remote.User", "Remote.User"},
|
||
// Spaces are not rewritten because Go's HTTP parser rejects whitespace in
|
||
// header field names.
|
||
{"Foo Bar", "Foo Bar"},
|
||
}
|
||
for _, tt := range tests {
|
||
assert.Equal(t, tt.want, headerNameReplacer.Replace(tt.in), "input %q", tt.in)
|
||
}
|
||
}
|
||
|
||
// TestSplitPosSecurityRegressionUnicodeBypass guards against the FrankenPHP
|
||
// advisories GHSA-3g8v-8r37-cgjm (uninitialized match flag on inner non-ASCII
|
||
// byte) and GHSA-v4h7-cj44-8fc8 (Unicode equivalence via search.IgnoreCase
|
||
// folding fullwidth/mathematical/circled letters onto ASCII). Every payload
|
||
// below produced a false positive in the vulnerable implementation; none
|
||
// must match here.
|
||
func TestSplitPosSecurityRegressionUnicodeBypass(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
tr := Transport{SplitPath: []string{".php"}}
|
||
payloads := []string{
|
||
"/PoC-match-unset.¡.txt", // GHSA-3g8v: stale match=true on IndexString fallback
|
||
"/shell﹒php", // U+FE52 small full stop
|
||
"/shell.php", // U+FF0E fullwidth full stop
|
||
"/shell.php", // U+FF50 fullwidth p
|
||
"/shell.php", // U+FF48 fullwidth h
|
||
"/shell.php", // U+FF50 fullwidth p (trailing)
|
||
"/shell.\U0001D5C1\U0001D5B5\U0001D5C1", // mathematical sans-serif p/h
|
||
"/shell.\U0001D5FD\U0001D5F5\U0001D5FD", // mathematical sans-serif bold p/h
|
||
"/shell.\U0001D4C5\U0001D4BD\U0001D4C5", // mathematical script p/h
|
||
"/shell.ⓟⓗⓟ", // circled latin small
|
||
}
|
||
|
||
for _, p := range payloads {
|
||
assert.Equalf(t, -1, tr.splitPos(p), "payload %q must not be detected as .php", p)
|
||
}
|
||
}
|
||
|
||
func TestBuildEnvServerAddr(t *testing.T) {
|
||
testCases := []struct {
|
||
name string
|
||
localAddr net.Addr
|
||
envVars map[string]string
|
||
expectedAddr string
|
||
expectExists bool
|
||
}{
|
||
{
|
||
name: "IPv4 TCP listener",
|
||
localAddr: &net.TCPAddr{IP: net.ParseIP("10.0.0.12"), Port: 80},
|
||
expectedAddr: "10.0.0.12",
|
||
expectExists: true,
|
||
},
|
||
{
|
||
name: "IPv6 TCP listener",
|
||
localAddr: &net.TCPAddr{IP: net.ParseIP("2001:db8::1"), Port: 443},
|
||
expectedAddr: "2001:db8::1",
|
||
expectExists: true,
|
||
},
|
||
{
|
||
name: "Missing local address context",
|
||
localAddr: nil,
|
||
expectExists: false,
|
||
},
|
||
{
|
||
name: "Unix listener",
|
||
localAddr: &net.UnixAddr{Name: "/var/run/caddy.sock", Net: "unix"},
|
||
expectExists: false,
|
||
},
|
||
{
|
||
name: "Explicitly configured SERVER_ADDR override",
|
||
localAddr: &net.TCPAddr{IP: net.ParseIP("10.0.0.12"), Port: 80},
|
||
envVars: map[string]string{"SERVER_ADDR": "192.168.1.100"},
|
||
expectedAddr: "192.168.1.100",
|
||
expectExists: true,
|
||
},
|
||
}
|
||
|
||
for _, tc := range testCases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
tr := Transport{
|
||
Root: "/var/www",
|
||
EnvVars: tc.envVars,
|
||
}
|
||
err := tr.Provision(caddy.Context{})
|
||
require.NoError(t, err)
|
||
|
||
req, err := http.NewRequest(http.MethodGet, "http://localhost/index.php", nil)
|
||
require.NoError(t, err)
|
||
|
||
req = req.WithContext(context.WithValue(req.Context(), caddyhttp.OriginalRequestCtxKey, *req))
|
||
repl := caddy.NewReplacer()
|
||
req = req.WithContext(context.WithValue(req.Context(), caddy.ReplacerCtxKey, repl))
|
||
|
||
if tc.localAddr != nil {
|
||
req = req.WithContext(context.WithValue(req.Context(), http.LocalAddrContextKey, tc.localAddr))
|
||
}
|
||
|
||
env, err := tr.buildEnv(req)
|
||
require.NoError(t, err)
|
||
|
||
val, exists := env["SERVER_ADDR"]
|
||
assert.Equal(t, tc.expectExists, exists)
|
||
if tc.expectExists {
|
||
assert.Equal(t, tc.expectedAddr, val)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestBuildEnvDropsProxyHeader guards against the HTTPoxy vulnerability
|
||
// (CVE-2016-5385): a client-supplied `Proxy` request header must never be
|
||
// translated into the HTTP_PROXY environment variable, since many HTTP client
|
||
// libraries used by FastCGI backends honor HTTP_PROXY for outbound requests,
|
||
// which would let an attacker hijack the backend's traffic.
|
||
// See https://httpoxy.org.
|
||
func TestBuildEnvDropsProxyHeader(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
newRequest := func(headers http.Header) *http.Request {
|
||
r := httptest.NewRequest(http.MethodGet, "http://example.com/index.php", nil)
|
||
// Set the header map directly so non-canonical casing is preserved,
|
||
// mimicking a header that reaches buildEnv unmodified.
|
||
r.Header = headers
|
||
ctx := context.WithValue(r.Context(), caddy.ReplacerCtxKey, caddy.NewReplacer())
|
||
ctx = context.WithValue(ctx, caddyhttp.VarsCtxKey, map[string]any{})
|
||
ctx = context.WithValue(ctx, caddyhttp.OriginalRequestCtxKey, *r)
|
||
return r.WithContext(ctx)
|
||
}
|
||
|
||
for _, casing := range []string{"Proxy", "proxy", "pRoXy", "PROXY"} {
|
||
t.Run("casing "+casing, func(t *testing.T) {
|
||
r := newRequest(http.Header{
|
||
casing: []string{"http://attacker.example:8080"},
|
||
"X-Safe-Hdr": []string{"kept"},
|
||
})
|
||
|
||
env, err := Transport{}.buildEnv(r)
|
||
require.NoError(t, err)
|
||
|
||
assert.NotContains(t, env, "HTTP_PROXY", "HTTP_PROXY must never be set from a client header")
|
||
// Sanity check that unrelated headers are still forwarded, i.e. the
|
||
// filter is narrow and doesn't break normal header passthrough.
|
||
assert.Equal(t, "kept", env["HTTP_X_SAFE_HDR"])
|
||
})
|
||
}
|
||
|
||
t.Run("configured HTTP_PROXY is preserved", func(t *testing.T) {
|
||
// The filter must only drop the *client* header; an explicitly
|
||
// configured env var is trusted and must survive.
|
||
r := newRequest(http.Header{"Proxy": []string{"http://attacker.example:8080"}})
|
||
|
||
tr := Transport{EnvVars: map[string]string{"HTTP_PROXY": "http://trusted.example:3128"}}
|
||
env, err := tr.buildEnv(r)
|
||
require.NoError(t, err)
|
||
|
||
assert.Equal(t, "http://trusted.example:3128", env["HTTP_PROXY"])
|
||
})
|
||
}
|