Skip to content

Commit 63d8d4a

Browse files
authored
Merge branch 'master' into fix-websocket-header-normalization
2 parents 9a90785 + 16235cc commit 63d8d4a

36 files changed

Lines changed: 6445 additions & 95 deletions

.github/SECURITY.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ We'll need enough information to verify the bug and make a patch. To speed thing
4949

5050
Please DO NOT use containers, VMs, cloud instances or services, or any other complex infrastructure in your steps. Always prefer `curl -v` instead of web browsers.
5151

52-
We consider publicly-registered domain names to be public information. This necessary in order to maintain the integrity of certificate transparency, public DNS, and other public trust systems. Do not redact domain names from your reports. The actual content of your domain name affects Caddy's behavior, so we need the exact domain name(s) to reproduce with, or your report will be ignored.
52+
We consider publicly-registered domain names to be public information. This is necessary in order to maintain the integrity of certificate transparency, public DNS, and other public trust systems. Do not redact domain names from your reports. The actual content of your domain name affects Caddy's behavior, so we need the exact domain name(s) to reproduce with, or your report will be ignored.
5353

5454
It will speed things up if you suggest a working patch, such as a code diff, and explain why and how it works. Reports that are not actionable, do not contain enough information, are too pushy/demanding, or are not able to convince us that it is a viable and practical attack on the web server itself may be deferred to a later time or possibly ignored, depending on available resources. Priority will be given to credible, responsible reports that are constructive, specific, and actionable. (We get a lot of invalid reports.) Thank you for understanding.
5555

caddyconfig/httpcaddyfile/httptype.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -523,7 +523,11 @@ func (ServerType) extractNamedRoutes(
523523
route.HandlersRaw = []json.RawMessage{caddyconfig.JSONModuleObject(handler, "handler", subroute.CaddyModule().ID.Name(), h.warnings)}
524524
}
525525

526-
namedRoutes[sb.block.GetKeysText()[0]] = &route
526+
key := sb.block.GetKeysText()[0]
527+
if _, exists := namedRoutes[key]; exists {
528+
return nil, fmt.Errorf("cannot have duplicate named_routes: %s", key)
529+
}
530+
namedRoutes[key] = &route
527531
}
528532
options["named_routes"] = namedRoutes
529533

caddyconfig/httpcaddyfile/serveroptions.go

Lines changed: 30 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -36,27 +36,28 @@ type serverOptions struct {
3636
ListenerAddress string
3737

3838
// These will all map 1:1 to the caddyhttp.Server struct
39-
Name string
40-
ListenerWrappersRaw []json.RawMessage
41-
PacketConnWrappersRaw []json.RawMessage
42-
ReadTimeout caddy.Duration
43-
ReadHeaderTimeout caddy.Duration
44-
WriteTimeout caddy.Duration
45-
IdleTimeout caddy.Duration
46-
KeepAliveInterval caddy.Duration
47-
KeepAliveIdle caddy.Duration
48-
KeepAliveCount int
49-
MaxHeaderBytes int
50-
EnableFullDuplex bool
51-
Protocols []string
52-
StrictSNIHost *bool
53-
TrustedProxiesRaw json.RawMessage
54-
TrustedProxiesStrict int
55-
TrustedProxiesUnix bool
56-
ClientIPHeaders []string
57-
ShouldLogCredentials bool
58-
Metrics *caddyhttp.Metrics
59-
Trace bool // TODO: EXPERIMENTAL
39+
Name string
40+
ListenerWrappersRaw []json.RawMessage
41+
PacketConnWrappersRaw []json.RawMessage
42+
ReadTimeout caddy.Duration
43+
ReadHeaderTimeout caddy.Duration
44+
WriteTimeout caddy.Duration
45+
IdleTimeout caddy.Duration
46+
KeepAliveInterval caddy.Duration
47+
KeepAliveIdle caddy.Duration
48+
KeepAliveCount int
49+
MaxHeaderBytes int
50+
EnableFullDuplex bool
51+
ExpectedUnderscoreHeaders []string
52+
Protocols []string
53+
StrictSNIHost *bool
54+
TrustedProxiesRaw json.RawMessage
55+
TrustedProxiesStrict int
56+
TrustedProxiesUnix bool
57+
ClientIPHeaders []string
58+
ShouldLogCredentials bool
59+
Metrics *caddyhttp.Metrics
60+
Trace bool // TODO: EXPERIMENTAL
6061
// If set, overrides whether QUIC listeners allow 0-RTT (early data).
6162
// If nil, the default behavior is used (currently allowed).
6263
Allow0RTT *bool
@@ -218,6 +219,13 @@ func unmarshalCaddyfileServerOptions(d *caddyfile.Dispenser) (any, error) {
218219
}
219220
serverOpts.EnableFullDuplex = true
220221

222+
case "expected_underscore_headers":
223+
args := d.RemainingArgs()
224+
if len(args) == 0 {
225+
return nil, d.ArgErr()
226+
}
227+
serverOpts.ExpectedUnderscoreHeaders = args
228+
221229
case "log_credentials":
222230
if d.NextArg() {
223231
return nil, d.ArgErr()
@@ -380,6 +388,7 @@ func applyServerOptions(
380388
server.KeepAliveCount = opts.KeepAliveCount
381389
server.MaxHeaderBytes = opts.MaxHeaderBytes
382390
server.EnableFullDuplex = opts.EnableFullDuplex
391+
server.ExpectedUnderscoreHeaders = opts.ExpectedUnderscoreHeaders
383392
server.Protocols = opts.Protocols
384393
server.StrictSNIHost = opts.StrictSNIHost
385394
server.TrustedProxiesRaw = opts.TrustedProxiesRaw
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
&(api) {
2+
header X-Version v1
3+
respond "API v1"
4+
}
5+
6+
&(api) {
7+
header X-Version v2
8+
respond "API v2"
9+
}
10+
11+
localhost {
12+
invoke api
13+
}
14+
15+
----------
16+
cannot have duplicate named_routes: api
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
localhost:9080 {
2+
forward_auth :9091 {
3+
uri /first
4+
uri /second
5+
copy_headers Remote-User
6+
}
7+
8+
respond "ok" 200
9+
}
10+
11+
----------
12+
parsing caddyfile tokens for 'forward_auth': cannot re-declare uri: /second

caddytest/integration/intercept_test.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,72 @@ func TestIntercept(t *testing.T) {
3838

3939
tester.AssertGetResponse("http://localhost:9080/no-intercept", 200, "I'm not a teapot")
4040
}
41+
42+
func TestInterceptReplaceStatusWithMatcher(t *testing.T) {
43+
tester := caddytest.NewTester(t)
44+
tester.InitServer(`{
45+
skip_install_trust
46+
admin localhost:2999
47+
http_port 9080
48+
https_port 9443
49+
grace_period 1ns
50+
}
51+
52+
localhost:9080 {
53+
respond /error "boom" 500
54+
55+
intercept {
56+
@err status 5xx
57+
replace_status @err 200
58+
}
59+
}
60+
`, "caddyfile")
61+
62+
tester.AssertGetResponse("http://localhost:9080/error", 200, "boom")
63+
}
64+
65+
func TestInterceptReplaceStatusWithoutMatcher(t *testing.T) {
66+
tester := caddytest.NewTester(t)
67+
tester.InitServer(`{
68+
skip_install_trust
69+
admin localhost:2999
70+
http_port 9080
71+
https_port 9443
72+
grace_period 1ns
73+
}
74+
75+
localhost:9080 {
76+
respond /forbidden "denied" 403
77+
78+
intercept {
79+
replace_status 200
80+
}
81+
}
82+
`, "caddyfile")
83+
84+
tester.AssertGetResponse("http://localhost:9080/forbidden", 200, "denied")
85+
}
86+
87+
func TestInterceptReplaceStatusNotMatched(t *testing.T) {
88+
tester := caddytest.NewTester(t)
89+
tester.InitServer(`{
90+
skip_install_trust
91+
admin localhost:2999
92+
http_port 9080
93+
https_port 9443
94+
grace_period 1ns
95+
}
96+
97+
localhost:9080 {
98+
respond /ok "all good" 200
99+
100+
intercept {
101+
@err status 5xx
102+
replace_status @err 503
103+
}
104+
}
105+
`, "caddyfile")
106+
107+
// 200 does not match @err (5xx), so status should pass through unchanged
108+
tester.AssertGetResponse("http://localhost:9080/ok", 200, "all good")
109+
}

caddytest/integration/reverseproxy_test.go

Lines changed: 105 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ func TestReverseProxyWithPlaceholderDialAddress(t *testing.T) {
199199
],
200200
"handle": [
201201
{
202-
202+
203203
"handler": "reverse_proxy",
204204
"upstreams": [
205205
{
@@ -293,7 +293,7 @@ func TestReverseProxyWithPlaceholderTCPDialAddress(t *testing.T) {
293293
],
294294
"handle": [
295295
{
296-
296+
297297
"handler": "reverse_proxy",
298298
"upstreams": [
299299
{
@@ -374,7 +374,7 @@ func TestReverseProxyHealthCheck(t *testing.T) {
374374
http://localhost:9080 {
375375
reverse_proxy {
376376
to localhost:2020
377-
377+
378378
health_uri /health
379379
health_port 2021
380380
health_interval 10ms
@@ -495,7 +495,7 @@ func TestReverseProxyHealthCheckUnixSocket(t *testing.T) {
495495
http://localhost:9080 {
496496
reverse_proxy {
497497
to unix/%s
498-
498+
499499
health_uri /health
500500
health_port 2021
501501
health_interval 2s
@@ -553,7 +553,7 @@ func TestReverseProxyHealthCheckUnixSocketWithoutPort(t *testing.T) {
553553
http://localhost:9080 {
554554
reverse_proxy {
555555
to unix/%s
556-
556+
557557
health_uri /health
558558
health_interval 2s
559559
health_timeout 5s
@@ -793,3 +793,103 @@ func TestReverseProxyRetryMatchIsTransportError(t *testing.T) {
793793
// Transport error on broken upstream should be retried to good upstream
794794
tester.AssertGetResponse("http://localhost:9080/", 200, "ok")
795795
}
796+
797+
func TestReverseProxySNIPlaceHolder(t *testing.T) {
798+
configTemplate := `
799+
{
800+
skip_install_trust
801+
local_certs
802+
admin localhost:2999
803+
http_port 9080
804+
https_port 9443
805+
grace_period 1ns
806+
}
807+
localhost example.com {
808+
@proxied header X-Transport caddy
809+
respond @proxied {http.request.tls.server_name}
810+
reverse_proxy 127.0.0.1:9443 {
811+
header_up X-Transport caddy
812+
header_up Host {host}
813+
transport http {
814+
versions %s
815+
tls_server_name {header.X-SNI}
816+
tls_insecure_skip_verify
817+
}
818+
}
819+
}
820+
`
821+
for _, versions := range []string{"1.1 2", "3"} {
822+
tester := caddytest.NewTester(t)
823+
tester.InitServer(fmt.Sprintf(configTemplate, versions), "caddyfile")
824+
req, err := http.NewRequest("GET", "https://localhost:9443", nil)
825+
if err != nil {
826+
t.Errorf("failed to create request %s", err)
827+
return
828+
}
829+
830+
req.Header.Set("X-SNI", "example.com")
831+
tester.AssertResponse(req, 200, "example.com")
832+
}
833+
}
834+
835+
func TestWeightedRoundRobinSelectionValidation(t *testing.T) {
836+
configTemplate := `
837+
{
838+
"apps": {
839+
"http": {
840+
"servers": {
841+
"srv0": {
842+
"listen": [":18080"],
843+
"routes": [
844+
{
845+
"handle": [
846+
{
847+
"handler": "reverse_proxy",
848+
"load_balancing": {
849+
"selection_policy": {
850+
"policy": "weighted_round_robin",
851+
"weights": %s
852+
}
853+
},
854+
"upstreams": [
855+
{"dial": "localhost:18081"},
856+
{"dial": "localhost:18082"}
857+
]
858+
}
859+
]
860+
}
861+
]
862+
}
863+
}
864+
}
865+
}
866+
}`
867+
868+
tests := []struct {
869+
name string
870+
weights string
871+
errMsg string
872+
}{
873+
{
874+
name: "negative weight",
875+
weights: "[-1, 2]",
876+
errMsg: "weight of an upstream cannot be negative",
877+
},
878+
{
879+
name: "zero total weight",
880+
weights: "[0, 0]",
881+
errMsg: "requires at least one upstream with a positive weight",
882+
},
883+
}
884+
885+
for _, tc := range tests {
886+
t.Run(tc.name, func(t *testing.T) {
887+
caddytest.AssertLoadError(
888+
t,
889+
fmt.Sprintf(configTemplate, tc.weights),
890+
"json",
891+
tc.errMsg,
892+
)
893+
})
894+
}
895+
}

cmd/cobra.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,8 +149,14 @@ func caddyCmdToCobra(caddyCmd Command) *cobra.Command {
149149
func WrapCommandFuncForCobra(f CommandFunc) func(cmd *cobra.Command, _ []string) error {
150150
return func(cmd *cobra.Command, _ []string) error {
151151
status, err := f(Flags{cmd.Flags()})
152-
if status > 1 {
152+
if err != nil {
153+
// Route the error through Caddy's logger so it receives the same
154+
// colored, structured formatting as INFO/WARN output, rather than
155+
// cobra's plain "Error: ..." line which lacks any highlighting.
156+
caddy.Log().Error(err.Error())
153157
cmd.SilenceErrors = true
158+
}
159+
if status > 1 {
154160
return &exitError{ExitCode: status, Err: err}
155161
}
156162
return err

modules/caddyhttp/app.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,8 @@ func init() {
7070
// `{http.request.orig_uri.query}` | The request's original query string (without `?`)
7171
// `{http.request.orig_uri.prefixed_query}` | The request's original query string with a `?` prefix, if non-empty
7272
// `{http.request.port}` | The port part of the request's Host header
73-
// `{http.request.proto}` | The protocol of the request
73+
// `{http.request.proto}` | The raw protocol of the request as returned by Go (e.g., HTTP/2.0 or HTTP/3.0)
74+
// `{http.request.proto_name}` | The spec-defined protocol of the request (e.g., HTTP/2 or HTTP/3)
7475
// `{http.request.local.host}` | The host (IP) part of the local address the connection arrived on
7576
// `{http.request.local.port}` | The port part of the local address the connection arrived on
7677
// `{http.request.local}` | The local address the connection arrived on
@@ -256,6 +257,12 @@ func (app *App) Provision(ctx caddy.Context) error {
256257
}
257258
}
258259

260+
// limit max header bytes to a more reasonable default than 1MB from Go std lib
261+
// (see https://github.com/php/frankenphp/issues/2459#issuecomment-4655612909)
262+
if srv.MaxHeaderBytes <= 0 {
263+
srv.MaxHeaderBytes = 16 * 1024
264+
}
265+
259266
// if not explicitly configured by the user, disallow TLS
260267
// client auth bypass (domain fronting) which could
261268
// otherwise be exploited by sending an unprotected SNI
@@ -286,6 +293,11 @@ func (app *App) Provision(ctx caddy.Context) error {
286293
srv.ClientIPHeaders = []string{"X-Forwarded-For"}
287294
}
288295

296+
// precompute underscore header allowlist rules
297+
if err := srv.provisionUnderscoreHeaders(); err != nil {
298+
return fmt.Errorf("server %s: %v", srvName, err)
299+
}
300+
289301
// process each listener address
290302
for i := range srv.Listen {
291303
lnOut, err := repl.ReplaceOrErr(srv.Listen[i], true, true)

0 commit comments

Comments
 (0)