mirror of
https://github.com/versity/versitygw.git
synced 2026-10-22 14:22:40 +00:00
fiber `v3.6.0` (gofiber/fiber#4695) normalizes every request path before routing as RFC 3986 Section 6.2.2 describes: it decodes the escapes of unreserved characters and removes `.` and `..` segments, and `ctx.Path()`, `ctx.Params()` and every `ctx.Path(override)` return the normalized path with no way to opt out. `DecodeURL` sets the decoded path back through `ctx.Path()`, so keys were decoded twice (`100%2541` was stored as `100A`, `q%7euser` as `q~user`) and dot segments rerouted requests: `PUT /bucket/.` became `CreateBucket` and `/bucket/../../etc/passwd` named the bucket `etc`. The new `httpctx.Ctx`, created with `fiber.NewWithCustomCtx`, keeps a path set with `Path()` as it is and has fiber route a copy in which every `%` and the dots of a `.` or `..` segment are `_`. The copy keeps the length of the path, since fiber matches routes on it but takes the route parameters from `Path()` at the matched offsets, so `Params("bucket")` holds the real bucket. The S3, admin and website servers and the RDMA ops tracker use it. S3 stores keys holding `.` and `..` segments as they are (`a/../b`, `x/.` and `..` are objects of their own) and only rejects, before authentication, a request path whose decoded `..` segments climb above the root, separating the segments at `\` as well as at `/` and skipping the empty ones. `DecodeURL` now rejects such a path with `InvalidURI` and keeps the dot segments in the key otherwise. `Backend.StoresDotSegmentKeys()` reports whether a backend stores such keys as their own objects: `azure` and `s3proxy` do, while `posix` and the `BackendUnsupported` default don't, since a filesystem resolves the segments to another object or to a path outside the bucket. `IsObjectNameValid` takes the backend and rejects keys holding dot segments for those backends in the request path, the `DeleteObjects` keys, the `PostObject` key and the copy sources, so `posix` now also rejects `a/../b` and `a/./b`, which it stored as `b` and `a/b`. The unused `path.go` is removed.
133 lines
3.5 KiB
Go
133 lines
3.5 KiB
Go
// Copyright 2026 Versity Software
|
|
// This file is licensed under the Apache License, Version 2.0
|
|
// (the "License"); you may not use this file except in compliance
|
|
// with the License. You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing,
|
|
// software distributed under the License is distributed on an
|
|
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
// KIND, either express or implied. See the License for the
|
|
// specific language governing permissions and limitations
|
|
// under the License.
|
|
|
|
package middlewares
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/gofiber/fiber/v3"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/valyala/fasthttp"
|
|
"github.com/versity/versitygw/internal/httpctx"
|
|
"github.com/versity/versitygw/s3err"
|
|
)
|
|
|
|
func TestDecodeURL(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
uri string
|
|
wantPath string
|
|
wantErr error
|
|
}{
|
|
{
|
|
name: "plain path",
|
|
uri: "/bucket/object",
|
|
wantPath: "/bucket/object",
|
|
},
|
|
{
|
|
name: "escaped characters",
|
|
uri: "/bucket/my%20object%2Bkey",
|
|
wantPath: "/bucket/my object+key",
|
|
},
|
|
{
|
|
name: "escaped percent is decoded once",
|
|
uri: "/bucket/100%2541",
|
|
wantPath: "/bucket/100%41",
|
|
},
|
|
{
|
|
name: "dot segments stay in the key",
|
|
uri: "/bucket/./a/../b/.",
|
|
wantPath: "/bucket/./a/../b/.",
|
|
},
|
|
{
|
|
name: "escaped dot segments are decoded",
|
|
uri: "/bucket/q/%2e%2E/r",
|
|
wantPath: "/bucket/q/../r",
|
|
},
|
|
{
|
|
name: "dot dot climbing to the root",
|
|
uri: "/bucket/../key",
|
|
wantPath: "/bucket/../key",
|
|
},
|
|
{
|
|
name: "empty segments climbed over",
|
|
uri: "/bucket/x//../../key",
|
|
wantPath: "/bucket/x//../../key",
|
|
},
|
|
{
|
|
name: "dot dot above the root",
|
|
uri: "/bucket/../../key",
|
|
wantErr: s3err.GetAPIError(s3err.ErrInvalidURI),
|
|
},
|
|
{
|
|
name: "dot dot above the root without a bucket",
|
|
uri: "/..",
|
|
wantErr: s3err.GetAPIError(s3err.ErrInvalidURI),
|
|
},
|
|
{
|
|
name: "empty segments don't count",
|
|
uri: "/bucket//../../key",
|
|
wantErr: s3err.GetAPIError(s3err.ErrInvalidURI),
|
|
},
|
|
{
|
|
name: "backslash dot dot climbing to the root",
|
|
uri: "/bucket/..%5Ckey",
|
|
wantPath: "/bucket/..\\key",
|
|
},
|
|
{
|
|
name: "backslash separated dot dot above the root",
|
|
uri: "/bucket/x%5C..%5C..%5C..%5Ckey",
|
|
wantErr: s3err.GetAPIError(s3err.ErrInvalidURI),
|
|
},
|
|
{
|
|
name: "escaped dot dot above the root",
|
|
uri: "/bucket/.%2e/%2E%2E/key",
|
|
wantErr: s3err.GetAPIError(s3err.ErrInvalidURI),
|
|
},
|
|
{
|
|
name: "invalid escape",
|
|
uri: "/bucket/(*&^%&())",
|
|
wantErr: s3err.GetAPIError(s3err.ErrInvalidURI),
|
|
},
|
|
{
|
|
name: "truncated escape",
|
|
uri: "/bucket/object%2",
|
|
wantErr: s3err.GetAPIError(s3err.ErrInvalidURI),
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// net/http refuses to build a request with a malformed escape,
|
|
// so the raw uri is set on the fasthttp request directly
|
|
fctx := &fasthttp.RequestCtx{}
|
|
fctx.Request.SetRequestURI(tt.uri)
|
|
app := fiber.NewWithCustomCtx(httpctx.NewCtx)
|
|
ctx := app.AcquireCtx(fctx)
|
|
defer app.ReleaseCtx(ctx)
|
|
|
|
err := DecodeURL(ctx)
|
|
if tt.wantErr != nil {
|
|
assert.Equal(t, tt.wantErr, err)
|
|
return
|
|
}
|
|
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, tt.wantPath, ctx.Path())
|
|
assert.Equal(t, tt.wantPath, string(ctx.Request().URI().PathOriginal()))
|
|
})
|
|
}
|
|
}
|