Conversation
…inks - Route registered methods with Path before Methods to ensure Gorilla Mux correctly sets ErrMethodMismatch - Configure NotFoundHandler and MethodNotAllowedHandler on the HTTP router - Add AllowedMethods on Router to populate RFC 9110 Allow header on 405 Method Not Allowed responses - Add ErrorMethodNotAllowed error type and methodNotAllowedHandler - Use filepath.EvalSymlinks in AddStaticFiles and validateFile to ensure static file serving does not follow symlinks outside the root served directory - Add comprehensive tests for 405/404 routing and symlink containment Fixes gofr-dev#3853 Fixes gofr-dev#3855 Signed-off-by: Abhinav Prakash <abhinavprakash616@gmail.com>
aryanmehrotra
left a comment
There was a problem hiding this comment.
Thanks @AbhiPra24 — I ran this against a real server rather than reading the diff, and both bugs are real and both of your fixes work. There is one regression that blocks it, and it is not visible from the diff or from the unit tests.
Both issues reproduce on development
#3853 POST /only-get -> 404, no Allow header (should be 405)
#3855 GET /static/evil.txt -> 200, body "TOPSECRET" (file outside the served root)
Your fixes do what they claim
| probe | development |
this PR |
|---|---|---|
POST /only-get |
404 | 405, Allow: GET |
DELETE /ping |
404 | 405, Allow: GET, POST |
GET /static/evil.txt |
200 TOPSECRET |
404 |
GET /static/evildir/passwords.txt |
200 | 404 |
dotdot-inside.txt (relative .. landing back inside) |
200 | 200 — correctly still served |
Same results under GOFR_ROUTER=trie, so the two matchers stay consistent. The Allow values are right, and the symlink containment does not over-reject.
Blocking: CORS preflight now returns 405
OPTIONS /only-get Origin: http://x.test Access-Control-Request-Method: GET
development: HTTP/1.1 200 OK
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Origin: *
this PR: HTTP/1.1 405 Method Not Allowed
Allow: GET
(no CORS headers at all)
Every cross-origin browser call to a GoFr app breaks — the preflight fails, so the browser never sends the real request.
Why, from the pinned gorilla/mux v1.8.1 (mux.go:138-168): the middleware chain is applied only in the matched-route branch, guarded by match.MatchErr == nil:
for _, route := range r.routes {
if route.Match(req, match) {
if match.MatchErr == nil { // <- middleware only here
for i := len(r.middlewares) - 1; i >= 0; i-- {
match.Handler = r.middlewares[i].Middleware(match.Handler)
}
}
return true
}
}
if match.MatchErr == ErrMethodMismatch {
if r.MethodNotAllowedHandler != nil {
match.Handler = r.MethodNotAllowedHandler // <- no middleware
return true
}
...
}
if r.NotFoundHandler != nil {
match.Handler = r.NotFoundHandler // <- no middleware
...
}middleware.CORS is registered through router.Use(...) (pkg/gofr/http_server.go:67-70, alongside Tracer, Logging and Metrics), so anything served by those two handler fields bypasses all four.
Blocking: 404 and 405 disappear from logs and metrics
Same root cause, and it also hits 404 — which previously went through PathPrefix("/"), a real route, and therefore had the full chain. Measured on the same app:
development |
this PR | |
|---|---|---|
app_http_response_count{path="/nope",status="404"} |
1 | absent |
app_http_response_count{path="/only-get"} |
1 | absent |
access-log lines for /nope |
3 | 0 |
access-log lines for /only-get |
2 | 0 |
Only the /ping 200 is still counted. A scanner walking unknown paths, or a client using the wrong verb, becomes completely invisible to logs, metrics and traces.
A shape that keeps the middleware
Rather than moving to mux's handler fields, keep the catch-all as a real route and decide inside it:
a.httpServer.router.PathPrefix("/").Handler(handler{
function: catchAllHandler, // 405 + Allow when AllowedMethods(r) is non-empty, else 404
container: a.container,
})AllowedMethods already skips routes with no methods, so the catch-all does not report itself. Because the catch-all matches, match.MatchErr stays nil, the whole chain runs, and CORS still intercepts OPTIONS before the handler is reached. Your AllowedMethods, ErrorMethodNotAllowed and the Allow formatting all carry over unchanged — this only changes where the decision is made.
If you go that way, worth re-checking whether the Path(...).Methods(...) reorder in Router.Add is still needed, since ErrMethodMismatch would no longer be what drives the 405.
Smaller things
An unrelated line was deleted. gofr.go drops:
if a.container.Logger != nil {
a.container.Logger.Infof("Registered HTTP server on port: %d", a.httpServer.port)
}Confirmed by running both: 1 such line on development, 0 here. It sits right where the catch-all was replaced, so it looks accidental.
Two new lint failures, both in the new test:
pkg/gofr/http/router_test.go:1007:21 G306: Expect WriteFile permissions to be 0600 or less (gosec)
pkg/gofr/http/router_test.go:1011:21 G306: (same)
Allow omits HEAD and OPTIONS. Allow: GET while OPTIONS /only-get is answered 405. RFC 9110 §10.2.1 has Allow list what the resource supports; fixing the CORS path will change this anyway, so it is worth settling both together.
On #3855, this departs from what the issue specified. The issue rules out this approach by name:
Adding
filepath.EvalSymlinksbefore the comparison would close the common case but reintroduces the TOCTOU windowos.Rootexists to eliminate — the link can be repointed between the resolve and the open.
Being fair to your version: the residual window needs write access inside the served tree, which is a far higher bar than the original bug, and EvalSymlinks avoids the breaking change os.Root brings (an absolute symlink pointing inside the root keeps working, where os.Root would reject it). That may well be the better trade — but it is a deliberate divergence from the design the issue landed on, and it should be argued in the PR description rather than left implicit.
Two unrelated issues in one PR. #3853 is self-contained and, once the middleware regression is fixed, ready. #3855 has an open design question. Splitting them would let the 405 work merge without waiting on that discussion.
What I ran
| check | result |
|---|---|
go build ./... |
clean |
go test ./pkg/gofr/http/... |
pass |
go test ./pkg/gofr |
fails identically on development (metrics-port FATAL) — not this PR |
gofmt -l |
2 dirty files, both pre-existing |
golangci-lint --new-from-rev |
2 new, listed above |
The unit tests all pass, which is worth saying plainly: nothing in them exercises CORS, logging or metrics on a 404/405 path, which is why an end-to-end run was the only thing that surfaced it. A test asserting that a 404 still carries Access-Control-Allow-Origin would have caught it and would be worth adding alongside the fix.
Good work on both root causes — the symlink reproduction in particular is a real vulnerability and the containment logic you wrote handles the ..-back-inside case correctly, which is the part that is easy to get wrong.
Pull Request Template
Description:
405 Method Not Allowedwith theAllowheader populated with accepted methods (e.g.Allow: GET, POST) and GoFr error JSON envelope{"error":{"message":"method not allowed"}}, rather than404 Not Found.NotFoundHandlerandMethodNotAllowedHandleron the router and registered routes withPathbeforeMethodsso Gorilla Mux accurately tracks method mismatches.AllowedMethods(r *http.Request) []stringto inspect allowed HTTP methods for the requested path.filepath.EvalSymlinksremains within the root directory before opening/serving files. Symlinks pointing outside the served root return404 Not Found.Allowheader formatting, and symlink containment scenarios.Fixes #3853
Fixes #3855
Breaking Changes (if applicable):
405 Method Not Allowedinstead of404 Not Found.Additional Information:
gofmt.Checklist:
goimportandgolangci-lint.