diff --git a/internal/entrypoint/config.go b/internal/entrypoint/config.go index 1933ed4c..ac81368e 100644 --- a/internal/entrypoint/config.go +++ b/internal/entrypoint/config.go @@ -23,6 +23,9 @@ type Config struct { } func (cfg *Config) Validate() error { + if err := cfg.Rules.NotFound.Validate(); err != nil { + return err + } if cfg.ProxyProtocol == nil { return nil } diff --git a/internal/entrypoint/not_found_middleware_test.go b/internal/entrypoint/not_found_middleware_test.go new file mode 100644 index 00000000..07528cee --- /dev/null +++ b/internal/entrypoint/not_found_middleware_test.go @@ -0,0 +1,139 @@ +package entrypoint + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + "github.com/yusing/godoxy/internal/logging/accesslog" + "github.com/yusing/godoxy/internal/route/rules" +) + +type remoteAddrAccessLogger struct { + accesslog.AccessLogger + remoteAddr string +} + +func (logger *remoteAddrAccessLogger) LogRequest(request *http.Request, _ *http.Response) { + logger.remoteAddr = request.RemoteAddr +} + +func TestNotFoundRuleRequestMiddlewareUpdatesAccessLogAddress(t *testing.T) { + ep := NewTestEntrypoint(t, nil) + server := newTestHTTPServer(t, ep) + logger := &remoteAddrAccessLogger{} + ep.accessLogger = logger + + var notFoundRules rules.Rules + require.NoError(t, notFoundRules.Parse(` +default { + middleware CloudflareRealIP { + } +} +`)) + ep.SetNotFoundRules(notFoundRules) + + request := httptest.NewRequest(http.MethodGet, "http://unknown.example/garbage", nil) + request.RemoteAddr = "127.0.0.1:1234" + request.Header.Set("CF-Connecting-IP", "198.51.100.10") + response := httptest.NewRecorder() + + server.ServeHTTP(response, request) + + require.Equal(t, http.StatusNotFound, response.Code) + require.Equal(t, "198.51.100.10:1234", request.RemoteAddr) + require.Equal(t, "198.51.100.10:1234", logger.remoteAddr) + require.Equal(t, "198.51.100.10", request.Header.Get("X-Real-IP")) +} + +func TestNotFoundRuleRequestMiddlewareIsOptIn(t *testing.T) { + ep := NewTestEntrypoint(t, nil) + server := newTestHTTPServer(t, ep) + logger := &remoteAddrAccessLogger{} + ep.accessLogger = logger + + request := httptest.NewRequest(http.MethodGet, "http://unknown.example/garbage", nil) + request.RemoteAddr = "127.0.0.1:1234" + request.Header.Set("CF-Connecting-IP", "198.51.100.10") + response := httptest.NewRecorder() + + server.ServeHTTP(response, request) + + require.Equal(t, http.StatusNotFound, response.Code) + require.Equal(t, "127.0.0.1:1234", request.RemoteAddr) + require.Equal(t, "127.0.0.1:1234", logger.remoteAddr) +} + +func TestNotFoundRuleRequestMiddlewareDoesNotRunForMatchedRoute(t *testing.T) { + ep := NewTestEntrypoint(t, nil) + server := newTestHTTPServer(t, ep) + logger := &remoteAddrAccessLogger{} + ep.accessLogger = logger + + matchedRoute := newFakeHTTPRoute(t, "matched.example", "") + matchedRoute.handler = func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + } + server.AddRoute(matchedRoute) + + var notFoundRules rules.Rules + require.NoError(t, notFoundRules.Parse(` +default { + middleware CloudflareRealIP { + } +} +`)) + ep.SetNotFoundRules(notFoundRules) + + request := httptest.NewRequest(http.MethodGet, "http://matched.example/garbage", nil) + request.RemoteAddr = "127.0.0.1:1234" + request.Header.Set("CF-Connecting-IP", "198.51.100.10") + response := httptest.NewRecorder() + + server.ServeHTTP(response, request) + + require.Equal(t, http.StatusNoContent, response.Code) + require.Equal(t, "127.0.0.1:1234", request.RemoteAddr) + require.Equal(t, "127.0.0.1:1234", logger.remoteAddr) +} + +func TestConfigValidateRejectsNotFoundMiddlewareInResponsePhase(t *testing.T) { + tests := []struct { + name string + rules string + wantError bool + }{ + { + name: "request phase", + rules: `default { + middleware CloudflareRealIP { + } + }`, + }, + { + name: "response phase", + rules: `status 404 { + middleware CloudflareRealIP { + } + }`, + wantError: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var notFoundRules rules.Rules + require.NoError(t, notFoundRules.Parse(test.rules)) + + cfg := Config{} + cfg.Rules.NotFound = notFoundRules + err := cfg.Validate() + if test.wantError { + require.ErrorContains(t, err, "request middleware cannot be used in a response-phase rule or action block") + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/internal/net/gphttp/middleware/middlewares.go b/internal/net/gphttp/middleware/middlewares.go index d0c4dab4..a8f4fa93 100644 --- a/internal/net/gphttp/middleware/middlewares.go +++ b/internal/net/gphttp/middleware/middlewares.go @@ -2,11 +2,13 @@ package middleware import ( "errors" + "fmt" "io/fs" "path" "github.com/rs/zerolog/log" "github.com/yusing/godoxy/internal/common" + "github.com/yusing/godoxy/internal/route/rules" gperr "github.com/yusing/goutils/errs" fsutils "github.com/yusing/goutils/fs" strutils "github.com/yusing/goutils/strings" @@ -48,6 +50,44 @@ var ( ErrMiddlewareAlreadyExists = errors.New("middleware with the same name already exists") ) +func init() { + rules.InitRequestMiddlewareResolver(func(name string, options map[string]any) (rules.RequestMiddleware, error) { + definition, err := Get(name) + if err != nil { + return nil, err + } + + middleware, err := definition.New(OptionsRaw(options)) + if err != nil { + return nil, err + } + + if !hasRequestPhase(middleware.impl) { + return nil, fmt.Errorf("middleware %q has no request phase", name) + } + + return NewMiddlewareChain(name, []*Middleware{middleware}).TryModifyRequest, nil + }) +} + +func hasRequestPhase(impl any) bool { + switch impl := impl.(type) { + case *checkBypass: + return impl.modReq != nil && hasRequestPhase(impl.modReq) + case *middlewareChain: + for _, before := range impl.befores { + if hasRequestPhase(before) { + return true + } + } + return false + case RequestModifier: + return true + default: + return false + } +} + func Get(name string) (*Middleware, error) { middleware, ok := allMiddlewares[strutils.ToLowerNoSnake(name)] if !ok { diff --git a/internal/net/gphttp/middleware/rule_middleware_test.go b/internal/net/gphttp/middleware/rule_middleware_test.go new file mode 100644 index 00000000..7ee8fdc6 --- /dev/null +++ b/internal/net/gphttp/middleware/rule_middleware_test.go @@ -0,0 +1,156 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + "github.com/yusing/godoxy/internal/net/gphttp" + "github.com/yusing/godoxy/internal/route/rules" +) + +func TestRuleMiddlewareResolverSupportsRequestPhase(t *testing.T) { + var configured rules.Rules + require.NoError(t, configured.Parse(` +default { + middleware CloudflareRealIP +} +`)) +} + +func TestRuleMiddlewareResolverSupportsRequestCompose(t *testing.T) { + component, err := CloudflareRealIP.New(nil) + require.NoError(t, err) + + const name = "testrulerequestcompose" + previous, existed := allMiddlewares[name] + allMiddlewares[name] = NewMiddlewareChain(name, []*Middleware{component}) + t.Cleanup(func() { + if existed { + allMiddlewares[name] = previous + } else { + delete(allMiddlewares, name) + } + }) + + var configured rules.Rules + require.NoError(t, configured.Parse(` +default { + middleware testrulerequestcompose +} +`)) +} + +func TestRuleMiddlewareResolverRejectsUnsupportedMiddleware(t *testing.T) { + responseWithBypass, err := ModifyResponse.New(OptionsRaw{ + "bypass": []string{"path /health"}, + }) + require.NoError(t, err) + + const wrappedResponseName = "testrulewrappedresponse" + const responseComposeName = "testruleresponsecompose" + allMiddlewares[wrappedResponseName] = responseWithBypass + allMiddlewares[responseComposeName] = NewMiddlewareChain(responseComposeName, []*Middleware{responseWithBypass}) + t.Cleanup(func() { + delete(allMiddlewares, wrappedResponseName) + delete(allMiddlewares, responseComposeName) + }) + + const emptyComposeName = "testruleemptycompose" + previous, existed := allMiddlewares[emptyComposeName] + allMiddlewares[emptyComposeName] = NewMiddlewareChain(emptyComposeName, nil) + t.Cleanup(func() { + if existed { + allMiddlewares[emptyComposeName] = previous + } else { + delete(allMiddlewares, emptyComposeName) + } + }) + + tests := []struct { + name string + middleware string + errorText string + }{ + {name: "response only", middleware: "ModifyResponse", errorText: "has no request phase"}, + {name: "empty compose", middleware: emptyComposeName, errorText: "has no request phase"}, + {name: "wrapped response only", middleware: wrappedResponseName, errorText: "has no request phase"}, + {name: "response-only compose", middleware: responseComposeName, errorText: "has no request phase"}, + {name: "unknown", middleware: "does-not-exist", errorText: "unknown middleware"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var configured rules.Rules + err := configured.Parse("default {\n middleware " + test.middleware + "\n}") + require.ErrorContains(t, err, test.errorText) + }) + } +} + +func TestRuleMiddlewareResolverAppliesBlockProperties(t *testing.T) { + var configured rules.Rules + require.NoError(t, configured.Parse(` +default { + middleware RealIP { + header: X-Forwarded-For + from: + - 127.0.0.1/32 + } +} +`)) + + request := httptest.NewRequest(http.MethodGet, "http://unknown.example/", nil) + request.RemoteAddr = "127.0.0.1:1234" + request.Header.Set("X-Forwarded-For", "198.51.100.10") + response := httptest.NewRecorder() + configured.BuildHandler(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }).ServeHTTP(response, request) + + require.Equal(t, http.StatusNoContent, response.Code) + require.Equal(t, "198.51.100.10:1234", request.RemoteAddr) + require.Equal(t, "198.51.100.10", request.Header.Get("X-Real-IP")) +} + +func TestRuleMiddlewareResolverPreservesNonUserAuthBypass(t *testing.T) { + var configured rules.Rules + require.NoError(t, configured.Parse(` +default { + middleware CIDRWhitelist { + } +} +`)) + + t.Run("non-user request bypasses access control", func(t *testing.T) { + fallbackCalled := false + handler := configured.BuildHandler(func(w http.ResponseWriter, _ *http.Request) { + fallbackCalled = true + w.WriteHeader(http.StatusNoContent) + }) + request := httptest.NewRequest(http.MethodGet, "http://unknown.example/", nil) + request.RemoteAddr = "203.0.113.10:1234" + request = request.WithContext(gphttp.WithNonUserRequest(request.Context())) + response := httptest.NewRecorder() + + handler.ServeHTTP(response, request) + + require.True(t, fallbackCalled) + require.Equal(t, http.StatusNoContent, response.Code) + }) + + t.Run("user request still applies access control", func(t *testing.T) { + fallbackCalled := false + handler := configured.BuildHandler(func(http.ResponseWriter, *http.Request) { + fallbackCalled = true + }) + request := httptest.NewRequest(http.MethodGet, "http://unknown.example/", nil) + request.RemoteAddr = "203.0.113.10:1234" + response := httptest.NewRecorder() + + handler.ServeHTTP(response, request) + + require.False(t, fallbackCalled) + require.Equal(t, http.StatusForbidden, response.Code) + }) +} diff --git a/internal/route/rules/README.md b/internal/route/rules/README.md index 35dfd391..70e6f259 100644 --- a/internal/route/rules/README.md +++ b/internal/route/rules/README.md @@ -218,15 +218,26 @@ path regex("/api/v[0-9]+/.*") // regex pattern | `proxy ` | Proxy to upstream | | `require_basic_auth ` | Return 401 challenge | -**Non-Terminating Actions** (modify and continue): +**Request Actions**: + +| Command | Description | +| ------------------------------ | --------------------------------------------------- | +| `rewrite ` | Rewrite request path | +| `require_auth` | Require authentication | +| `middleware ` | Run request middleware; terminate if it handles it | +| `set ` | Set header/variable | +| `add ` | Add header/variable | +| `remove ` | Remove header/variable | + +`middleware ` runs built-in or composed middleware with default options. +A `middleware { ... }` block accepts the middleware's normal properties, +including nested values. The name is the action target; the block contains only +that target's options. Only middleware with a request phase is supported; +response-only middleware is rejected while parsing the rules. A +middleware that permits the request continues to the next action, while one +that handles the request terminates the rule chain. +Request middleware is rejected in response-phase rules and action blocks that run in the response phase. -| Command | Description | -| ------------------------------ | ---------------------- | -| `rewrite ` | Rewrite request path | -| `require_auth` | Require authentication | -| `set ` | Set header/variable | -| `add ` | Add header/variable | -| `remove ` | Remove header/variable | **Response Actions**: @@ -295,18 +306,19 @@ block := '{' do_body '}' ```text // Elif/Else chains can appear in do_body do_stmt := command_line | command_block | nested_block | elif_else_chain -command_block := command ws* '{' option_lines '}' -elif_else_chain := nested_block { elif_clause } [else_clause] +command_block := command { ws+ command_arg } ws* '{' option_lines '}' +elif_else_chain := nested_block { elif_clause } [else_clause] elif_clause := 'elif' ws* on_expr ws* '{' do_body '}' else_clause := 'else' ws* '{' do_body '}' ``` #### Command option blocks -Every `do` command can be written with an option block. Each line maps a named -argument to one scalar value, in the same positional order the command expects. -The parser converts the block back into positional args before running the -command's normal validator. +A `do` command block contains the command name, any header arguments owned by +that command, and a YAML-like property block. Commands whose block properties +map to positional arguments can omit header arguments. For example, middleware +uses its name as a header argument and accepts its normal properties, including +nested lists and mappings. ```bash path glob("/admin/*") { @@ -323,9 +335,11 @@ path glob("/admin/*") { } ``` -Option blocks do not accept inline positional arguments. Missing keys, unknown -keys, duplicate keys, and non-scalar values are rejected before command -validation. +Each command owns its header arguments and accepted property shapes. For scalar +option blocks, missing keys, unknown keys, and duplicate keys are rejected +before command validation. Property values must be scalars unless the command +accepts structured data; middleware configuration, for example, may contain +nested lists and mappings. #### Nested blocks (inline conditionals inside `do`) diff --git a/internal/route/rules/command.go b/internal/route/rules/command.go index 4d8cda6a..4e866dee 100644 --- a/internal/route/rules/command.go +++ b/internal/route/rules/command.go @@ -12,9 +12,10 @@ var errTerminateRule = errors.New("terminate rule") type ( HandlerFunc func(w *httputils.ResponseModifier, r *http.Request, upstream http.HandlerFunc) error Handler struct { - fn HandlerFunc - phase PhaseFlag - terminate bool + fn HandlerFunc + phase PhaseFlag + requestPhaseOnly bool + terminate bool } CommandHandler interface { diff --git a/internal/route/rules/do.go b/internal/route/rules/do.go index e897e834..13d4801e 100644 --- a/internal/route/rules/do.go +++ b/internal/route/rules/do.go @@ -39,6 +39,7 @@ const ( CommandUpstreamOld2 = "pass" CommandRequireAuth = "require_auth" + CommandMiddleware = "middleware" CommandRewrite = "rewrite" CommandHandle = "handle" CommandServe = "serve" @@ -55,9 +56,16 @@ const ( CommandNotify = "notify" ) -type AuthHandler func(w http.ResponseWriter, r *http.Request) (proceed bool) +type ( + AuthHandler func(w http.ResponseWriter, r *http.Request) (proceed bool) + RequestMiddleware func(w http.ResponseWriter, r *http.Request) (proceed bool) + RequestMiddlewareResolver func(name string, options map[string]any) (RequestMiddleware, error) +) -var authHandler AuthHandler +var ( + authHandler AuthHandler + requestMiddlewareResolver RequestMiddlewareResolver +) func InitAuthHandler(handler AuthHandler) { authHandler = handler @@ -67,16 +75,23 @@ func GetAuthHandler() AuthHandler { return authHandler } +// InitRequestMiddlewareResolver connects rule actions to request middleware without creating a package import cycle. +func InitRequestMiddlewareResolver(resolver RequestMiddlewareResolver) { + requestMiddlewareResolver = resolver +} + func init() { commands[CommandUpstreamOld] = commands[CommandUpstream] commands[CommandUpstreamOld2] = commands[CommandUpstream] } var commands = map[string]struct { - help Help - validate ValidateFunc - build func(args any) HandlerFunc - terminate bool + help Help + validate ValidateFunc + validateBlock func(args []string, body string) (PhaseFlag, any, error) + build func(args any) HandlerFunc + requestPhaseOnly bool + terminate bool }{ CommandUpstream: { help: Help{ @@ -123,6 +138,63 @@ var commands = map[string]struct { } }, }, + CommandMiddleware: { + requestPhaseOnly: true, + help: Help{ + command: CommandMiddleware, + description: makeLines("Run a request middleware and continue if it permits the request"), + args: helpArgs( + helpArg{"name", "the built-in or composed middleware name"}, + ), + }, + validate: func(args []string) (phase PhaseFlag, parsedArgs any, err error) { + phase = PhasePre + if len(args) != 1 { + return phase, nil, ErrExpectOneArg + } + if requestMiddlewareResolver == nil { + return phase, nil, errors.New("request middleware resolver is not initialized") + } + + middleware, err := requestMiddlewareResolver(args[0], nil) + if err != nil { + return phase, nil, err + } + return phase, middleware, nil + }, + validateBlock: func(args []string, body string) (phase PhaseFlag, parsedArgs any, err error) { + phase = PhasePre + if len(args) != 1 { + return phase, nil, ErrExpectOneArg + } + if requestMiddlewareResolver == nil { + return phase, nil, errors.New("request middleware resolver is not initialized") + } + + options, err := parseMiddlewareOptionsBlock(body) + if err != nil { + return phase, nil, err + } + + middleware, err := requestMiddlewareResolver(args[0], options) + if err != nil { + return phase, nil, err + } + + return phase, middleware, nil + }, + build: func(args any) HandlerFunc { + + middleware := args.(RequestMiddleware) + return func(w *httputils.ResponseModifier, r *http.Request, upstream http.HandlerFunc) error { + if proceed := middleware(w, r); !proceed { + return errTerminateRule + } + return nil + } + }, + }, + CommandRewrite: { help: Help{ command: CommandRewrite, diff --git a/internal/route/rules/do_blocks.go b/internal/route/rules/do_blocks.go index 54e0447e..4fd0dac5 100644 --- a/internal/route/rules/do_blocks.go +++ b/internal/route/rules/do_blocks.go @@ -6,6 +6,8 @@ import ( "strings" "unicode" + "github.com/goccy/go-yaml" + "github.com/yusing/godoxy/internal/serialization" gperr "github.com/yusing/goutils/errs" httputils "github.com/yusing/goutils/http" ) @@ -393,7 +395,9 @@ func parseDoWithBlocks(src string) (handlers []CommandHandler, err error) { } h := builder.build(validArgs) - handlers = append(handlers, Handler{fn: h, phase: phase, terminate: builder.terminate}) + handlers = append(handlers, Handler{ + fn: h, phase: phase, terminate: builder.terminate, requestPhaseOnly: builder.requestPhaseOnly, + }) return nil } @@ -414,24 +418,33 @@ func parseDoWithBlocks(src string) (handlers []CommandHandler, err error) { } return false, nil } - if !bodyLooksLikeOptionBlock(body) { - return false, nil + if builder.validateBlock == nil { + if !bodyLooksLikeOptionBlock(body) { + return false, nil + } + return true, ErrInvalidArguments.Withf("option block does not accept inline args") } - return true, ErrInvalidArguments.Withf("option block does not accept inline args") } - flatArgs, err := parseCommandBlockArgs(builder.help, body) - if err != nil { - return true, gperr.PrependSubject(err, directive).With(builder.help.Error()) + var phase PhaseFlag + var validArgs any + if builder.validateBlock != nil { + phase, validArgs, err = builder.validateBlock(args, body) + } else { + var flatArgs []string + flatArgs, err = parseCommandBlockArgs(builder.help, body) + if err == nil { + phase, validArgs, err = builder.validate(flatArgs) + } } - - phase, validArgs, err := builder.validate(flatArgs) if err != nil { return true, gperr.PrependSubject(err, directive).With(builder.help.Error()) } h := builder.build(validArgs) - handlers = append(handlers, Handler{fn: h, phase: phase, terminate: builder.terminate}) + handlers = append(handlers, Handler{ + fn: h, phase: phase, terminate: builder.terminate, requestPhaseOnly: builder.requestPhaseOnly, + }) return true, nil } @@ -612,3 +625,34 @@ func parseCommandBlockScalar(v string) (string, error) { } return args[0], nil } + +func parseMiddlewareOptionsBlock(body string) (map[string]any, error) { + // Rule blocks accept tab indentation, while YAML property blocks do not. + var normalized strings.Builder + normalized.Grow(len(body)) + indent := true + for i := range len(body) { + switch { + case body[i] == '\n': + normalized.WriteByte(body[i]) + indent = true + case indent && body[i] == '\t': + normalized.WriteString(" ") + default: + normalized.WriteByte(body[i]) + if body[i] != ' ' && body[i] != '\r' { + indent = false + } + } + } + body = normalized.String() + + options := make(map[string]any) + if strings.TrimSpace(body) == "" { + return options, nil + } + if err := serialization.UnmarshalValidate([]byte(body), &options, yaml.Unmarshal); err != nil { + return nil, ErrInvalidArguments.With(err) + } + return options, nil +} diff --git a/internal/route/rules/do_test.go b/internal/route/rules/do_test.go index 448b2ef3..b27293bb 100644 --- a/internal/route/rules/do_test.go +++ b/internal/route/rules/do_test.go @@ -1,9 +1,13 @@ package rules import ( + "errors" + "net/http" + "net/http/httptest" "os" "testing" + "github.com/stretchr/testify/require" expect "github.com/yusing/goutils/testing" ) @@ -193,3 +197,274 @@ func TestParseCommandServeFileRejectsDirectory(t *testing.T) { err := cmd.Parse("serve_file " + t.TempDir()) expect.ErrorIs(t, ErrInvalidArguments, err) } +func TestMiddlewareCommandExecutesInDeclarationOrder(t *testing.T) { + previousResolver := requestMiddlewareResolver + t.Cleanup(func() { + InitRequestMiddlewareResolver(previousResolver) + }) + + var order []string + InitRequestMiddlewareResolver(func(name string, _ map[string]any) (RequestMiddleware, error) { + return func(_ http.ResponseWriter, r *http.Request) bool { + order = append(order, name) + r.Header.Add("X-Middleware", name) + return true + }, nil + }) + + var configured Rules + require.NoError(t, configured.Parse(` +default { + middleware first + middleware second +} +`)) + + fallbackCalled := false + handler := configured.BuildHandler(func(w http.ResponseWriter, _ *http.Request) { + fallbackCalled = true + order = append(order, "fallback") + w.WriteHeader(http.StatusNoContent) + }) + request := httptest.NewRequest(http.MethodGet, "http://unknown.example/", nil) + response := httptest.NewRecorder() + + handler.ServeHTTP(response, request) + + require.Equal(t, http.StatusNoContent, response.Code) + require.True(t, fallbackCalled) + require.Equal(t, []string{"first", "second", "fallback"}, order) + require.Equal(t, []string{"first", "second"}, request.Header.Values("X-Middleware")) +} + +func TestMiddlewareCommandTerminatesWhenMiddlewareHandlesRequest(t *testing.T) { + previousResolver := requestMiddlewareResolver + t.Cleanup(func() { + InitRequestMiddlewareResolver(previousResolver) + }) + + InitRequestMiddlewareResolver(func(string, map[string]any) (RequestMiddleware, error) { + return func(w http.ResponseWriter, _ *http.Request) bool { + http.Error(w, "blocked", http.StatusForbidden) + return false + }, nil + }) + + var configured Rules + require.NoError(t, configured.Parse(` +default { + middleware blocker +} +`)) + + fallbackCalled := false + handler := configured.BuildHandler(func(http.ResponseWriter, *http.Request) { + fallbackCalled = true + }) + response := httptest.NewRecorder() + + handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "http://unknown.example/", nil)) + + require.Equal(t, http.StatusForbidden, response.Code) + require.False(t, fallbackCalled) +} +func TestMiddlewareCommandTerminationDoesNotFallThroughWithoutResponse(t *testing.T) { + previousResolver := requestMiddlewareResolver + t.Cleanup(func() { + InitRequestMiddlewareResolver(previousResolver) + }) + InitRequestMiddlewareResolver(func(string, map[string]any) (RequestMiddleware, error) { + return func(http.ResponseWriter, *http.Request) bool { + return false + }, nil + }) + + var configured Rules + require.NoError(t, configured.Parse(` +default { + middleware blocker +} +`)) + + fallbackCalled := false + handler := configured.BuildHandler(func(http.ResponseWriter, *http.Request) { + fallbackCalled = true + }) + + handler.ServeHTTP( + httptest.NewRecorder(), + httptest.NewRequest(http.MethodGet, "http://unknown.example/", nil), + ) + + require.False(t, fallbackCalled) +} + +func TestMatchedMiddlewareTerminationDoesNotFallThroughWithoutResponse(t *testing.T) { + previousResolver := requestMiddlewareResolver + t.Cleanup(func() { + InitRequestMiddlewareResolver(previousResolver) + }) + InitRequestMiddlewareResolver(func(string, map[string]any) (RequestMiddleware, error) { + return func(http.ResponseWriter, *http.Request) bool { + return false + }, nil + }) + + var configured Rules + require.NoError(t, configured.Parse(` +path / { + middleware blocker +} +`)) + + fallbackCalled := false + handler := configured.BuildHandler(func(http.ResponseWriter, *http.Request) { + fallbackCalled = true + }) + + handler.ServeHTTP( + httptest.NewRecorder(), + httptest.NewRequest(http.MethodGet, "http://unknown.example/", nil), + ) + + require.False(t, fallbackCalled) +} + +func TestMiddlewareCommandValidation(t *testing.T) { + previousResolver := requestMiddlewareResolver + t.Cleanup(func() { + InitRequestMiddlewareResolver(previousResolver) + }) + + resolveErr := errors.New("middleware unavailable") + InitRequestMiddlewareResolver(func(string, map[string]any) (RequestMiddleware, error) { + return nil, resolveErr + }) + + tests := []struct { + name string + command string + wantErr error + }{ + {name: "missing name", command: "middleware", wantErr: ErrInvalidArguments}, + {name: "too many names", command: "middleware first second", wantErr: ErrInvalidArguments}, + {name: "resolver error", command: "middleware unknown", wantErr: resolveErr}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var command Command + require.ErrorIs(t, command.Parse(test.command), test.wantErr) + }) + } +} + +func TestRulesValidateRejectsMiddlewareAfterResponseMatcher(t *testing.T) { + previousResolver := requestMiddlewareResolver + t.Cleanup(func() { + InitRequestMiddlewareResolver(previousResolver) + }) + InitRequestMiddlewareResolver(func(string, map[string]any) (RequestMiddleware, error) { + return func(http.ResponseWriter, *http.Request) bool { return true }, nil + }) + + tests := map[string]string{ + "direct": `status 404 { + middleware test + }`, + "block action": `status 404 { + middleware test { + } + }`, + "nested action block": `status 404 { + method GET { + middleware test + } + }`, + "nested response matcher": `default { + status 404 { + middleware test + } + }`, + "mixed-phase action block": `default { + method GET { + middleware test + set resp_header X-Test yes + } + }`, + } + for name, config := range tests { + t.Run(name, func(t *testing.T) { + var configured Rules + require.NoError(t, configured.Parse(config)) + require.ErrorContains(t, configured.Validate(), "request middleware cannot be used in a response-phase rule or action block") + }) + } +} + +func TestMiddlewareCommandBlockProperties(t *testing.T) { + previousResolver := requestMiddlewareResolver + t.Cleanup(func() { + InitRequestMiddlewareResolver(previousResolver) + }) + + var resolvedName string + var resolvedOptions map[string]any + InitRequestMiddlewareResolver(func(name string, options map[string]any) (RequestMiddleware, error) { + resolvedName = name + resolvedOptions = options + return func(http.ResponseWriter, *http.Request) bool { return true }, nil + }) + + var configured Rules + require.NoError(t, configured.Parse(` +default { + middleware RealIP { + header: X-Forwarded-For + from: + - 127.0.0.1/32 + recursive: true + } +} +`)) + + require.Equal(t, "RealIP", resolvedName) + require.Equal(t, "X-Forwarded-For", resolvedOptions["header"]) + require.Equal(t, []any{"127.0.0.1/32"}, resolvedOptions["from"]) + require.Equal(t, true, resolvedOptions["recursive"]) +} + +func TestMiddlewareCommandBlockDoesNotRewriteQuotedTabs(t *testing.T) { + previousResolver := requestMiddlewareResolver + t.Cleanup(func() { + InitRequestMiddlewareResolver(previousResolver) + }) + + var resolvedHeader any + InitRequestMiddlewareResolver(func(_ string, options map[string]any) (RequestMiddleware, error) { + resolvedHeader = options["header"] + return func(http.ResponseWriter, *http.Request) bool { return true }, nil + }) + + var configured Rules + err := configured.Parse(` +default { + middleware RealIP { + header: "X-Forwarded For" + } +} +`) + require.NoError(t, err) + require.Equal(t, "X-Forwarded For", resolvedHeader) +} + +func TestMiddlewareCommandBlockRequiresName(t *testing.T) { + var configured Rules + err := configured.Parse(` +default { + middleware { + header: X-Forwarded-For + } +} +`) + require.ErrorIs(t, err, ErrInvalidArguments) +} diff --git a/internal/route/rules/rules.go b/internal/route/rules/rules.go index 259abe40..79e231bf 100644 --- a/internal/route/rules/rules.go +++ b/internal/route/rules/rules.go @@ -71,6 +71,10 @@ func (rules Rules) Validate() gperr.Error { // set name to index if name is empty rules[i].Name = fmt.Sprintf("rule[%d]", i) } + if commandsContainRequestPhaseOnly(rule.Do.post) || + (rule.On.phase.IsPostRule() && commandsContainRequestPhaseOnly(rule.Do.pre)) { + return ErrInvalidArguments.Withf("rule[%d]: request middleware cannot be used in a response-phase rule or action block", i) + } } if len(defaultRulesFound) > 1 { return ErrMultipleDefaultRules.Withf("found %d", len(defaultRulesFound)) @@ -145,6 +149,31 @@ func commandTerminatesInPre(cmd CommandHandler) bool { } } +func commandsContainRequestPhaseOnly(cmds []CommandHandler) bool { + return slices.ContainsFunc(cmds, commandContainsRequestPhaseOnly) +} + +func commandContainsRequestPhaseOnly(cmd CommandHandler) bool { + switch c := cmd.(type) { + case Handler: + return c.requestPhaseOnly + case *Handler: + return c != nil && c.requestPhaseOnly + case IfBlockCommand: + return commandsContainRequestPhaseOnly(c.Do) + case *IfBlockCommand: + return c != nil && commandContainsRequestPhaseOnly(*c) + case IfElseBlockCommand: + return slices.ContainsFunc(c.Ifs, func(branch IfBlockCommand) bool { + return commandsContainRequestPhaseOnly(branch.Do) + }) || commandsContainRequestPhaseOnly(c.Else) + case *IfElseBlockCommand: + return c != nil && commandContainsRequestPhaseOnly(*c) + default: + return false + } +} + func ifElseBlockTerminatesInPre(cmd IfElseBlockCommand) bool { hasFallback := len(cmd.Else) > 0 for _, br := range cmd.Ifs { @@ -412,7 +441,7 @@ func (rules Rules) BuildHandler(up http.HandlerFunc) http.HandlerFunc { if !rm.HasStatus() { if hasError { http.Error(rm, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - } else { // call upstream if no WriteHeader or Write was called and no error occurred + } else if !preTerminated && !defaultTerminatedInPre { up(rm, r) } } diff --git a/webui b/webui index b3141c98..a80391d1 160000 --- a/webui +++ b/webui @@ -1 +1 @@ -Subproject commit b3141c98bf0a9973fb85c64a3d5bd17a064f1411 +Subproject commit a80391d1789f40c66bed130df9e0ed165694c022