Skip to content

Commit d32648b

Browse files
authored
Refactor route path normalization (#31381)
Refactor route path normalization and decouple it from the chi router. Fix the TODO, fix the legacy strange path behavior.
1 parent 5a7376c commit d32648b

File tree

8 files changed

+153
-157
lines changed

8 files changed

+153
-157
lines changed

modules/web/route.go

+58-1
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ package web
55

66
import (
77
"net/http"
8+
"net/url"
89
"strings"
910

11+
"code.gitea.io/gitea/modules/setting"
1012
"code.gitea.io/gitea/modules/web/middleware"
1113

1214
"gitea.com/go-chi/binding"
@@ -160,14 +162,69 @@ func (r *Route) Patch(pattern string, h ...any) {
160162

161163
// ServeHTTP implements http.Handler
162164
func (r *Route) ServeHTTP(w http.ResponseWriter, req *http.Request) {
163-
r.R.ServeHTTP(w, req)
165+
r.normalizeRequestPath(w, req, r.R)
164166
}
165167

166168
// NotFound defines a handler to respond whenever a route could not be found.
167169
func (r *Route) NotFound(h http.HandlerFunc) {
168170
r.R.NotFound(h)
169171
}
170172

173+
func (r *Route) normalizeRequestPath(resp http.ResponseWriter, req *http.Request, next http.Handler) {
174+
normalized := false
175+
normalizedPath := req.URL.EscapedPath()
176+
if normalizedPath == "" {
177+
normalizedPath, normalized = "/", true
178+
} else if normalizedPath != "/" {
179+
normalized = strings.HasSuffix(normalizedPath, "/")
180+
normalizedPath = strings.TrimRight(normalizedPath, "/")
181+
}
182+
removeRepeatedSlashes := strings.Contains(normalizedPath, "//")
183+
normalized = normalized || removeRepeatedSlashes
184+
185+
// the following code block is a slow-path for replacing all repeated slashes "//" to one single "/"
186+
// if the path doesn't have repeated slashes, then no need to execute it
187+
if removeRepeatedSlashes {
188+
buf := &strings.Builder{}
189+
for i := 0; i < len(normalizedPath); i++ {
190+
if i == 0 || normalizedPath[i-1] != '/' || normalizedPath[i] != '/' {
191+
buf.WriteByte(normalizedPath[i])
192+
}
193+
}
194+
normalizedPath = buf.String()
195+
}
196+
197+
// If the config tells Gitea to use a sub-url path directly without reverse proxy,
198+
// then we need to remove the sub-url path from the request URL path.
199+
// But "/v2" is special for OCI container registry, it should always be in the root of the site.
200+
if setting.UseSubURLPath {
201+
remainingPath, ok := strings.CutPrefix(normalizedPath, setting.AppSubURL+"/")
202+
if ok {
203+
normalizedPath = "/" + remainingPath
204+
} else if normalizedPath == setting.AppSubURL {
205+
normalizedPath = "/"
206+
} else if !strings.HasPrefix(normalizedPath+"/", "/v2/") {
207+
// do not respond to other requests, to simulate a real sub-path environment
208+
http.Error(resp, "404 page not found, sub-path is: "+setting.AppSubURL, http.StatusNotFound)
209+
return
210+
}
211+
normalized = true
212+
}
213+
214+
// if the path is normalized, then fill it back to the request
215+
if normalized {
216+
decodedPath, err := url.PathUnescape(normalizedPath)
217+
if err != nil {
218+
http.Error(resp, "400 Bad Request: unable to unescape path "+normalizedPath, http.StatusBadRequest)
219+
return
220+
}
221+
req.URL.RawPath = normalizedPath
222+
req.URL.Path = decodedPath
223+
}
224+
225+
next.ServeHTTP(resp, req)
226+
}
227+
171228
// Combo delegates requests to Combo
172229
func (r *Route) Combo(pattern string, h ...any) *Combo {
173230
return &Combo{r, pattern, h}

modules/web/route_test.go

+44
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ import (
1010
"strconv"
1111
"testing"
1212

13+
"code.gitea.io/gitea/modules/setting"
14+
"code.gitea.io/gitea/modules/test"
15+
1316
chi "github.com/go-chi/chi/v5"
1417
"github.com/stretchr/testify/assert"
1518
)
@@ -176,3 +179,44 @@ func TestRoute3(t *testing.T) {
176179
assert.EqualValues(t, http.StatusOK, recorder.Code)
177180
assert.EqualValues(t, 4, hit)
178181
}
182+
183+
func TestRouteNormalizePath(t *testing.T) {
184+
type paths struct {
185+
EscapedPath, RawPath, Path string
186+
}
187+
testPath := func(reqPath string, expectedPaths paths) {
188+
recorder := httptest.NewRecorder()
189+
recorder.Body = bytes.NewBuffer(nil)
190+
191+
actualPaths := paths{EscapedPath: "(none)", RawPath: "(none)", Path: "(none)"}
192+
r := NewRoute()
193+
r.Get("/*", func(resp http.ResponseWriter, req *http.Request) {
194+
actualPaths.EscapedPath = req.URL.EscapedPath()
195+
actualPaths.RawPath = req.URL.RawPath
196+
actualPaths.Path = req.URL.Path
197+
})
198+
199+
req, err := http.NewRequest("GET", reqPath, nil)
200+
assert.NoError(t, err)
201+
r.ServeHTTP(recorder, req)
202+
assert.Equal(t, expectedPaths, actualPaths, "req path = %q", reqPath)
203+
}
204+
205+
// RawPath could be empty if the EscapedPath is the same as escape(Path) and it is already normalized
206+
testPath("/", paths{EscapedPath: "/", RawPath: "", Path: "/"})
207+
testPath("//", paths{EscapedPath: "/", RawPath: "/", Path: "/"})
208+
testPath("/%2f", paths{EscapedPath: "/%2f", RawPath: "/%2f", Path: "//"})
209+
testPath("///a//b/", paths{EscapedPath: "/a/b", RawPath: "/a/b", Path: "/a/b"})
210+
211+
defer test.MockVariableValue(&setting.UseSubURLPath, true)()
212+
defer test.MockVariableValue(&setting.AppSubURL, "/sub-path")()
213+
testPath("/", paths{EscapedPath: "(none)", RawPath: "(none)", Path: "(none)"}) // 404
214+
testPath("/sub-path", paths{EscapedPath: "/", RawPath: "/", Path: "/"})
215+
testPath("/sub-path/", paths{EscapedPath: "/", RawPath: "/", Path: "/"})
216+
testPath("/sub-path//a/b///", paths{EscapedPath: "/a/b", RawPath: "/a/b", Path: "/a/b"})
217+
testPath("/sub-path/%2f/", paths{EscapedPath: "/%2f", RawPath: "/%2f", Path: "//"})
218+
// "/v2" is special for OCI container registry, it should always be in the root of the site
219+
testPath("/v2", paths{EscapedPath: "/v2", RawPath: "/v2", Path: "/v2"})
220+
testPath("/v2/", paths{EscapedPath: "/v2", RawPath: "/v2", Path: "/v2"})
221+
testPath("/v2/%2f", paths{EscapedPath: "/v2/%2f", RawPath: "/v2/%2f", Path: "/v2//"})
222+
}

routers/common/middleware.go

+13-55
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,23 @@ import (
1919

2020
"gitea.com/go-chi/session"
2121
"github.com/chi-middleware/proxy"
22-
chi "github.com/go-chi/chi/v5"
22+
"github.com/go-chi/chi/v5"
2323
)
2424

2525
// ProtocolMiddlewares returns HTTP protocol related middlewares, and it provides a global panic recovery
2626
func ProtocolMiddlewares() (handlers []any) {
27-
// first, normalize the URL path
28-
handlers = append(handlers, normalizeRequestPathMiddleware)
27+
// make sure chi uses EscapedPath(RawPath) as RoutePath, then "%2f" could be handled correctly
28+
handlers = append(handlers, func(next http.Handler) http.Handler {
29+
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
30+
ctx := chi.RouteContext(req.Context())
31+
if req.URL.RawPath == "" {
32+
ctx.RoutePath = req.URL.EscapedPath()
33+
} else {
34+
ctx.RoutePath = req.URL.RawPath
35+
}
36+
next.ServeHTTP(resp, req)
37+
})
38+
})
2939

3040
// prepare the ContextData and panic recovery
3141
handlers = append(handlers, func(next http.Handler) http.Handler {
@@ -75,58 +85,6 @@ func ProtocolMiddlewares() (handlers []any) {
7585
return handlers
7686
}
7787

78-
func normalizeRequestPathMiddleware(next http.Handler) http.Handler {
79-
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
80-
// escape the URL RawPath to ensure that all routing is done using a correctly escaped URL
81-
req.URL.RawPath = req.URL.EscapedPath()
82-
83-
urlPath := req.URL.RawPath
84-
rctx := chi.RouteContext(req.Context())
85-
if rctx != nil && rctx.RoutePath != "" {
86-
urlPath = rctx.RoutePath
87-
}
88-
89-
normalizedPath := strings.TrimRight(urlPath, "/")
90-
// the following code block is a slow-path for replacing all repeated slashes "//" to one single "/"
91-
// if the path doesn't have repeated slashes, then no need to execute it
92-
if strings.Contains(normalizedPath, "//") {
93-
buf := &strings.Builder{}
94-
prevWasSlash := false
95-
for _, chr := range normalizedPath {
96-
if chr != '/' || !prevWasSlash {
97-
buf.WriteRune(chr)
98-
}
99-
prevWasSlash = chr == '/'
100-
}
101-
normalizedPath = buf.String()
102-
}
103-
104-
if setting.UseSubURLPath {
105-
remainingPath, ok := strings.CutPrefix(normalizedPath, setting.AppSubURL+"/")
106-
if ok {
107-
normalizedPath = "/" + remainingPath
108-
} else if normalizedPath == setting.AppSubURL {
109-
normalizedPath = "/"
110-
} else if !strings.HasPrefix(normalizedPath+"/", "/v2/") {
111-
// do not respond to other requests, to simulate a real sub-path environment
112-
http.Error(resp, "404 page not found, sub-path is: "+setting.AppSubURL, http.StatusNotFound)
113-
return
114-
}
115-
// TODO: it's not quite clear about how req.URL and rctx.RoutePath work together.
116-
// Fortunately, it is only used for debug purpose, we have enough time to figure it out in the future.
117-
req.URL.RawPath = normalizedPath
118-
req.URL.Path = normalizedPath
119-
}
120-
121-
if rctx == nil {
122-
req.URL.Path = normalizedPath
123-
} else {
124-
rctx.RoutePath = normalizedPath
125-
}
126-
next.ServeHTTP(resp, req)
127-
})
128-
}
129-
13088
func Sessioner() func(next http.Handler) http.Handler {
13189
return session.Sessioner(session.Options{
13290
Provider: setting.SessionConfig.Provider,

routers/common/middleware_test.go

-70
This file was deleted.

routers/init.go

+2-1
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,8 @@ func NormalRoutes() *web.Route {
191191
if setting.Packages.Enabled {
192192
// This implements package support for most package managers
193193
r.Mount("/api/packages", packages_router.CommonRoutes())
194-
// This implements the OCI API (Note this is not preceded by /api but is instead /v2)
194+
// This implements the OCI API, this container registry "/v2" endpoint must be in the root of the site.
195+
// If site admin deploys Gitea in a sub-path, they must configure their reverse proxy to map the "https://host/v2" endpoint to Gitea.
195196
r.Mount("/v2", packages_router.ContainerRoutes())
196197
}
197198

services/context/base.go

+12-7
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"code.gitea.io/gitea/modules/json"
1919
"code.gitea.io/gitea/modules/log"
2020
"code.gitea.io/gitea/modules/optional"
21+
"code.gitea.io/gitea/modules/setting"
2122
"code.gitea.io/gitea/modules/translation"
2223
"code.gitea.io/gitea/modules/web/middleware"
2324

@@ -142,23 +143,27 @@ func (b *Base) RemoteAddr() string {
142143
return b.Req.RemoteAddr
143144
}
144145

145-
// Params returns the param on route
146-
func (b *Base) Params(p string) string {
147-
s, _ := url.PathUnescape(chi.URLParam(b.Req, strings.TrimPrefix(p, ":")))
146+
// Params returns the param in request path, eg: "/{var}" => "/a%2fb", then `var == "a/b"`
147+
func (b *Base) Params(name string) string {
148+
s, err := url.PathUnescape(b.PathParamRaw(name))
149+
if err != nil && !setting.IsProd {
150+
panic("Failed to unescape path param: " + err.Error() + ", there seems to be a double-unescaping bug")
151+
}
148152
return s
149153
}
150154

151-
func (b *Base) PathParamRaw(p string) string {
152-
return chi.URLParam(b.Req, strings.TrimPrefix(p, ":"))
155+
// PathParamRaw returns the raw param in request path, eg: "/{var}" => "/a%2fb", then `var == "a%2fb"`
156+
func (b *Base) PathParamRaw(name string) string {
157+
return chi.URLParam(b.Req, strings.TrimPrefix(name, ":"))
153158
}
154159

155-
// ParamsInt64 returns the param on route as int64
160+
// ParamsInt64 returns the param in request path as int64
156161
func (b *Base) ParamsInt64(p string) int64 {
157162
v, _ := strconv.ParseInt(b.Params(p), 10, 64)
158163
return v
159164
}
160165

161-
// SetParams set params into routes
166+
// SetParams set request path params into routes
162167
func (b *Base) SetParams(k, v string) {
163168
chiCtx := chi.RouteContext(b)
164169
chiCtx.URLParams.Add(strings.TrimPrefix(k, ":"), url.PathEscape(v))

services/context/repo.go

+3-3
Original file line numberDiff line numberDiff line change
@@ -1006,12 +1006,12 @@ func RepoRefByType(refType RepoRefType, ignoreNotExistErr ...bool) func(*Context
10061006
if refType == RepoRefLegacy {
10071007
// redirect from old URL scheme to new URL scheme
10081008
prefix := strings.TrimPrefix(setting.AppSubURL+strings.ToLower(strings.TrimSuffix(ctx.Req.URL.Path, ctx.Params("*"))), strings.ToLower(ctx.Repo.RepoLink))
1009-
1010-
ctx.Redirect(path.Join(
1009+
redirect := path.Join(
10111010
ctx.Repo.RepoLink,
10121011
util.PathEscapeSegments(prefix),
10131012
ctx.Repo.BranchNameSubURL(),
1014-
util.PathEscapeSegments(ctx.Repo.TreePath)))
1013+
util.PathEscapeSegments(ctx.Repo.TreePath))
1014+
ctx.Redirect(redirect)
10151015
return cancel
10161016
}
10171017
}

0 commit comments

Comments
 (0)