feat(policies): pass request lengths to the proxy policies

The proxy policies middleware now passes the Content-Length of the
request body and the TUS Upload-Length header to the policies engine
as input.request.content_length and input.request.upload_length. This
allows rejecting uploads that are too big before any data is
transferred, instead of only in postprocessing after the upload has
completed.

We add them to the request instead of setting resource.size, because
the resource describes the target of the request (the middleware may
even stat it) and in postprocessing resource.size is the actual file
size.

This can only be an early check. The TUS concatenation extension
allows merging several smaller uploads into one file, so the file size
still has to be checked in postprocessing.

protojson encodes uint64 values as strings with camelCase keys, so
NewEnvironmentFromPB copies the lengths explicitly.

Assisted-by: Claude Code:claude-opus-5-5
Signed-off-by: Jörn Friedrich Dreyer <jfd@butonic.de>
This commit is contained in:
Jörn Friedrich Dreyer committed 2026-11-05 15:28:49 +01:00
1 parent 331855fe99
commit bd4325b75b
9 files changed
+195 -28

No files matched your search

@@ -223,6 +223,10 @@ type Request struct {
Method string `protobuf:"bytes,1,opt,name=method,proto3" json:"method,omitempty"`
Path string `protobuf:"bytes,2,opt,name=path,proto3" json:"path,omitempty"`
// Content-Length of the request body, 0 if unknown
ContentLength uint64 `protobuf:"varint,3,opt,name=content_length,json=contentLength,proto3" json:"content_length,omitempty"`
// TUS Upload-Length header, the declared total size of an upload, 0 if absent
UploadLength uint64 `protobuf:"varint,4,opt,name=upload_length,json=uploadLength,proto3" json:"upload_length,omitempty"`
}
func (x *Request) Reset() {
@@ -271,6 +275,20 @@ func (x *Request) GetPath() string {
return ""
}
func (x *Request) GetContentLength() uint64 {
if x != nil {
return x.ContentLength
}
return 0
}
func (x *Request) GetUploadLength() uint64 {
if x != nil {
return x.UploadLength
}
return 0
}
type Environment struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
@@ -487,35 +505,40 @@ var file_opencloud_messages_policies_v0_policies_proto_rawDesc = []byte{
0x75, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6f, 0x70, 0x61,
0x71, 0x75, 0x65, 0x49, 0x64, 0x12, 0x19, 0x0a, 0x08, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x69,
0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x73, 0x70, 0x61, 0x63, 0x65, 0x49, 0x64,
0x22, 0x35, 0x0a, 0x07, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x6d,
0x65, 0x74, 0x68, 0x6f, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6d, 0x65, 0x74,
0x68, 0x6f, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28,
0x09, 0x52, 0x04, 0x70, 0x61, 0x74, 0x68, 0x22, 0x8d, 0x02, 0x0a, 0x0b, 0x45, 0x6e, 0x76, 0x69,
0x72, 0x6f, 0x6e, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x3b, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x67, 0x65,
0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x25, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f,
0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x70, 0x6f, 0x6c, 0x69,
0x63, 0x69, 0x65, 0x73, 0x2e, 0x76, 0x30, 0x2e, 0x53, 0x74, 0x61, 0x67, 0x65, 0x52, 0x05, 0x73,
0x74, 0x61, 0x67, 0x65, 0x12, 0x38, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01,
0x28, 0x0b, 0x32, 0x24, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d,
0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73,
0x2e, 0x76, 0x30, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x12, 0x41,
0x0a, 0x07, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32,
0x27, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73,
0x61, 0x67, 0x65, 0x73, 0x2e, 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x2e, 0x76, 0x30,
0x2e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x52, 0x07, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73,
0x74, 0x12, 0x44, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x04, 0x20,
0x01, 0x28, 0x0b, 0x32, 0x28, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e,
0x22, 0x81, 0x01, 0x0a, 0x07, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06,
0x6d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6d, 0x65,
0x74, 0x68, 0x6f, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x02, 0x20, 0x01,
0x28, 0x09, 0x52, 0x04, 0x70, 0x61, 0x74, 0x68, 0x12, 0x25, 0x0a, 0x0e, 0x63, 0x6f, 0x6e, 0x74,
0x65, 0x6e, 0x74, 0x5f, 0x6c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x18, 0x03, 0x20, 0x01, 0x28, 0x04,
0x52, 0x0d, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x4c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x12,
0x23, 0x0a, 0x0d, 0x75, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x5f, 0x6c, 0x65, 0x6e, 0x67, 0x74, 0x68,
0x18, 0x04, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0c, 0x75, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x4c, 0x65,
0x6e, 0x67, 0x74, 0x68, 0x22, 0x8d, 0x02, 0x0a, 0x0b, 0x45, 0x6e, 0x76, 0x69, 0x72, 0x6f, 0x6e,
0x6d, 0x65, 0x6e, 0x74, 0x12, 0x3b, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20,
0x01, 0x28, 0x0e, 0x32, 0x25, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e,
0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65,
0x73, 0x2e, 0x76, 0x30, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x08, 0x72,
0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2a, 0x25, 0x0a, 0x05, 0x53, 0x74, 0x61, 0x67, 0x65,
0x12, 0x0c, 0x0a, 0x08, 0x53, 0x54, 0x41, 0x47, 0x45, 0x5f, 0x50, 0x50, 0x10, 0x00, 0x12, 0x0e,
0x0a, 0x0a, 0x53, 0x54, 0x41, 0x47, 0x45, 0x5f, 0x48, 0x54, 0x54, 0x50, 0x10, 0x01, 0x42, 0x4f,
0x5a, 0x4d, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6f, 0x70, 0x65,
0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2d, 0x65, 0x75, 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c,
0x6f, 0x75, 0x64, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x67, 0x65, 0x6e, 0x2f, 0x67, 0x65, 0x6e,
0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, 0x6d, 0x65, 0x73, 0x73, 0x61,
0x67, 0x65, 0x73, 0x2f, 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x2f, 0x76, 0x30, 0x62,
0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
0x73, 0x2e, 0x76, 0x30, 0x2e, 0x53, 0x74, 0x61, 0x67, 0x65, 0x52, 0x05, 0x73, 0x74, 0x61, 0x67,
0x65, 0x12, 0x38, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32,
0x24, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73,
0x61, 0x67, 0x65, 0x73, 0x2e, 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x2e, 0x76, 0x30,
0x2e, 0x55, 0x73, 0x65, 0x72, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x12, 0x41, 0x0a, 0x07, 0x72,
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x6f,
0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65,
0x73, 0x2e, 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x2e, 0x76, 0x30, 0x2e, 0x52, 0x65,
0x71, 0x75, 0x65, 0x73, 0x74, 0x52, 0x07, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x44,
0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b,
0x32, 0x28, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73,
0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x2e, 0x76,
0x30, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f,
0x75, 0x72, 0x63, 0x65, 0x2a, 0x25, 0x0a, 0x05, 0x53, 0x74, 0x61, 0x67, 0x65, 0x12, 0x0c, 0x0a,
0x08, 0x53, 0x54, 0x41, 0x47, 0x45, 0x5f, 0x50, 0x50, 0x10, 0x00, 0x12, 0x0e, 0x0a, 0x0a, 0x53,
0x54, 0x41, 0x47, 0x45, 0x5f, 0x48, 0x54, 0x54, 0x50, 0x10, 0x01, 0x42, 0x4f, 0x5a, 0x4d, 0x67,
0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c,
0x6f, 0x75, 0x64, 0x2d, 0x65, 0x75, 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64,
0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x67, 0x65, 0x6e, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x6f, 0x70,
0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73,
0x2f, 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x2f, 0x76, 0x30, 0x62, 0x06, 0x70, 0x72,
0x6f, 0x74, 0x6f, 0x33,
}
var (
@@ -134,6 +134,16 @@
},
"path": {
"type": "string"
},
"contentLength": {
"type": "string",
"format": "uint64",
"title": "Content-Length of the request body, 0 if unknown"
},
"uploadLength": {
"type": "string",
"format": "uint64",
"title": "TUS Upload-Length header, the declared total size of an upload, 0 if absent"
}
}
},
@@ -32,6 +32,10 @@ message Resource {
message Request {
string method = 1;
string path = 2;
// Content-Length of the request body, 0 if unknown
uint64 content_length = 3;
// TUS Upload-Length header, the declared total size of an upload, 0 if absent
uint64 upload_length = 4;
}
enum Stage {
+25
View File
@@ -185,6 +185,31 @@ To identify available keys for OPA, you need to look at [`engine.go`](https://gi
Note that which keys are available depends on the module it is used in.
### Request Lengths
In the proxy module, `input.request.content_length` contains the `Content-Length` of the request body and `input.request.upload_length` the total size of an upload as declared by the TUS `Upload-Length` header. Both are `0` if unknown. They allow rejecting uploads that are too big before any data is transferred:
```rego
package proxy
import future.keywords.if
default granted := true
# TUS creation declares the total size of the upload
granted = false if {
input.request.upload_length > 10737418240
}
# a plain PUT carries the whole file, a PATCH a chunk
granted = false if {
input.request.method == ["PUT", "PATCH"][_]
input.request.content_length > 10737418240
}
```
This is only an early check. The TUS concatenation extension can merge several smaller uploads into one file, so the actual file size has to be checked in postprocessing using `input.resource.size`.
## Rego Extensions
Besides the standard rego built-in functions, the following functions are added on top:
+8
View File
@@ -40,6 +40,10 @@ type Resource struct {
type Request struct {
Method string `json:"method"`
Path string `json:"path"`
// ContentLength is the length of the request body, 0 if unknown.
ContentLength uint64 `json:"content_length"`
// UploadLength is the total size of an upload as declared by the TUS Upload-Length header, 0 if absent.
UploadLength uint64 `json:"upload_length"`
}
// Environment contains every data that is needed to decide if the request should pass or not
@@ -63,6 +67,10 @@ func NewEnvironmentFromPB(pEnv *v0.Environment) (Environment, error) {
return env, err
}
// protojson encodes uint64 as strings with camelCase keys, so copy the lengths explicitly
env.Request.ContentLength = pEnv.GetRequest().GetContentLength()
env.Request.UploadLength = pEnv.GetRequest().GetUploadLength()
switch pEnv.Stage {
case v0.Stage_STAGE_HTTP:
env.Stage = StageHTTP
@@ -22,4 +22,23 @@ var _ = Describe("Engine", func() {
Entry("http stage", pMessage.Stage_STAGE_HTTP, engine.StageHTTP),
Entry("pp stage", pMessage.Stage_STAGE_PP, engine.StagePP),
)
It("NewEnvironmentFromPB keeps the request lengths", func() {
pEnv := &pMessage.Environment{
Stage: pMessage.Stage_STAGE_HTTP,
Request: &pMessage.Request{
Method: "POST",
Path: "/remote.php/dav/spaces/some-space-id",
ContentLength: 1024,
UploadLength: 20000000000,
},
}
env, err := engine.NewEnvironmentFromPB(pEnv)
Expect(err).ToNot(HaveOccurred())
Expect(env.Request.Method).To(Equal("POST"))
Expect(env.Request.ContentLength).To(Equal(uint64(1024)))
Expect(env.Request.UploadLength).To(Equal(uint64(20000000000)))
})
})
@@ -150,5 +150,28 @@ var _ = Describe("engine", func() {
Expect(granted).To(Equal(tc.want), "for %s", tc.name)
}
})
It("compares the request lengths as numbers", func() {
e, _ := newEngine("package size\n\nimport future.keywords.if\n\ndefault granted := true\n\ngranted = false if {\n input.request.upload_length > 10737418240\n}\n\ngranted = false if {\n input.request.method == [\"PUT\", \"PATCH\"][_]\n input.request.content_length > 10737418240\n}\n")
for _, tc := range []struct {
name string
request engine.Request
want bool
}{
{"no lengths", engine.Request{Method: "POST"}, true},
{"tus creation below limit", engine.Request{Method: "POST", UploadLength: 10737418240}, true},
{"tus creation above limit", engine.Request{Method: "POST", UploadLength: 20000000000}, false},
{"put above limit", engine.Request{Method: "PUT", ContentLength: 20000000000}, false},
{"patch above limit", engine.Request{Method: "PATCH", ContentLength: 20000000000}, false},
{"patch below limit", engine.Request{Method: "PATCH", ContentLength: 104857600}, true},
} {
granted, err := e.Evaluate(context.Background(), "data.size.granted", engine.Environment{
Request: tc.request,
})
Expect(err).ToNot(HaveOccurred())
Expect(granted).To(Equal(tc.want), "for %s", tc.name)
}
})
})
})
@@ -5,6 +5,7 @@ import (
"net/http"
"path"
"path/filepath"
"strconv"
"strings"
"time"
@@ -72,6 +73,14 @@ func Policies(qs string, opts ...Option) func(next http.Handler) http.Handler {
},
}
// the declared lengths allow policies to reject uploads before the data is transferred
if r.ContentLength > 0 {
req.Environment.Request.ContentLength = uint64(r.ContentLength)
}
if uploadLength, err := strconv.ParseUint(r.Header.Get(net.HeaderUploadLength), 10, 64); err == nil {
req.Environment.Request.UploadLength = uploadLength
}
resource := &pMessage.Resource{}
// tus
@@ -8,6 +8,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
@@ -109,6 +110,51 @@ func TestPolicies_EvaluationEnvironment_Request(t *testing.T) {
policiesMiddleware.ServeHTTP(responseRecorder, httptest.NewRequest(http.MethodDelete, "/whatever", nil))
}
func TestPolicies_EvaluationEnvironment_RequestLengths(t *testing.T) {
tests := []struct {
name string
method string
body string
uploadLength string
expectedContentLength uint64
expectedUploadLength uint64
}{
{name: "tus creation", method: http.MethodPost, uploadLength: "20000000000", expectedUploadLength: 20000000000},
{name: "put", method: http.MethodPut, body: "some content", expectedContentLength: 12},
{name: "patch", method: http.MethodPatch, body: "a chunk", expectedContentLength: 7},
{name: "invalid upload length", method: http.MethodPost, uploadLength: "-1"},
{name: "no lengths", method: http.MethodGet},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var g = NewWithT(t)
policiesMiddleware, policiesProviderService, _ := prepare("any")
policiesProviderService.On("Evaluate", mock.Anything, mock.Anything, mock.Anything).Return(
func(_ context.Context, in *policiesPG.EvaluateRequest, _ ...client.CallOption) (*policiesPG.EvaluateResponse, error) {
g.Expect(in.Environment.Request.ContentLength).To(Equal(tt.expectedContentLength))
g.Expect(in.Environment.Request.UploadLength).To(Equal(tt.expectedUploadLength))
return &policiesPG.EvaluateResponse{Result: false}, nil
},
).Once()
var body io.Reader
if tt.body != "" {
body = strings.NewReader(tt.body)
}
request := httptest.NewRequest(tt.method, "/remote.php/dav/spaces/some-space-id/file.txt", body)
if tt.uploadLength != "" {
request.Header.Set(net.HeaderUploadLength, tt.uploadLength)
}
policiesMiddleware.ServeHTTP(httptest.NewRecorder(), request)
policiesProviderService.AssertExpectations(t)
})
}
}
func TestPolicies_EvaluationEnvironment_Resource(t *testing.T) {
var g = NewWithT(t)