From 0e11d1bb232a3372e1407e5edfaea06237e20327 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Thu, 30 Jul 2026 17:02:10 +0200 Subject: [PATCH 1/2] fix(http): limit inbound request body size to 1MB Nothing capped inbound request bodies: the only body limits in the repository are outbound (http/client), so any caller could POST an arbitrarily large body to any endpoint on either interface. The deployment documentation also claimed the node limits request body sizes, which was not the case until now. Install echo's BodyLimit middleware on all interfaces, fixed at 1MB, returning 413 when exceeded. A deliberately heavy Verifiable Presentation (5 JWT credentials, each with an RSA-4096 x5c chain of 4 certificates) is around 133 kB, so 1MB leaves roughly 7x headroom, and matches the client_max_body_size the documentation recommends for reverse proxies. Assisted-by: AI --- .../deployment/security-considerations.rst | 2 +- http/engine.go | 9 +++++ http/engine_test.go | 40 +++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/docs/pages/deployment/security-considerations.rst b/docs/pages/deployment/security-considerations.rst index de201d0709..e09a3017af 100644 --- a/docs/pages/deployment/security-considerations.rst +++ b/docs/pages/deployment/security-considerations.rst @@ -39,7 +39,7 @@ The following public APIs accept POST requests: - ``/oauth2/{subjectID}/response`` To prevent malicious uploads, you MUST limit the size of the requests. -As a safeguard, the Nuts node will also limit the size of request bodies. +As a safeguard, the Nuts node also limits the size of request bodies to 1MB, on both the public and internal API endpoints, responding with HTTP 413 (Request Entity Too Large) when a request exceeds it. For example, Nginx has a configuration directive to limit the size of the request body: diff --git a/http/engine.go b/http/engine.go index 9ea89bb67a..5bb70d9152 100644 --- a/http/engine.go +++ b/http/engine.go @@ -30,6 +30,7 @@ import ( "time" "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" "github.com/nuts-foundation/nuts-node/core" cryptoEngine "github.com/nuts-foundation/nuts-node/crypto" "github.com/nuts-foundation/nuts-node/http/client" @@ -42,6 +43,13 @@ import ( const moduleName = "HTTP" +// requestBodySizeLimit caps the size of inbound HTTP request bodies on all interfaces, returning +// 413 Request Entity Too Large when exceeded. The largest legitimate requests are OAuth POSTs +// carrying Verifiable Presentations; a deliberately heavy presentation (5 JWT credentials, each +// with an RSA-4096 x5c chain of 4 certificates) is around 133 kB, so 1 MB leaves ample headroom. +// Matches the client_max_body_size the deployment documentation recommends for reverse proxies. +const requestBodySizeLimit = "1M" + // New returns a new HTTP engine. The callback is called when an HTTP interface shuts down unexpectedly. func New(serverShutdownCb func(), signingKeyResolver cryptoEngine.KeyResolver) *Engine { return &Engine{ @@ -97,6 +105,7 @@ func (h *Engine) Configure(serverConfig core.ServerConfig) error { h.applyTracingMiddleware(h.server) h.applyRateLimiterMiddleware(h.server, serverConfig) h.applyLoggerMiddleware(h.server, []string{MetricsPath, StatusPath, HealthPath}, h.config.Log) + h.server.Use(middleware.BodyLimit(requestBodySizeLimit)) return h.applyAuthMiddleware(h.server, InternalPath, h.config.Internal.Auth) } diff --git a/http/engine_test.go b/http/engine_test.go index b1e8101a75..a021e3b1e6 100644 --- a/http/engine_test.go +++ b/http/engine_test.go @@ -387,6 +387,46 @@ func TestEngine_LoggingMiddleware(t *testing.T) { }) } +func TestEngine_RequestBodyLimit(t *testing.T) { + // Configure sets the package-global client strict mode flag; restore it afterwards. + oldStrictMode := client.StrictMode + t.Cleanup(func() { client.StrictMode = oldStrictMode }) + noop := func() {} + engine := New(noop, nil) + engine.config = createTestConfig() + err := engine.Configure(*core.NewServerConfig()) + require.NoError(t, err) + echoBody := func(c echo.Context) error { + body, err := io.ReadAll(c.Request().Body) + if err != nil { + return err + } + return c.String(http.StatusOK, fmt.Sprintf("%d", len(body))) + } + engine.Router().POST("/", echoBody) + engine.Router().POST("/internal/test", echoBody) + require.NoError(t, engine.Start()) + defer engine.Shutdown() + assertServerStarted(t, engine.config.Public.Address) + assertServerStarted(t, engine.config.Internal.Address) + + t.Run("accepts a body under the limit", func(t *testing.T) { + response, err := http.Post("http://"+engine.config.Public.Address, "application/json", bytes.NewReader(make([]byte, 512*1024))) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, response.StatusCode) + }) + t.Run("rejects a body over the limit with 413", func(t *testing.T) { + response, err := http.Post("http://"+engine.config.Public.Address, "application/json", bytes.NewReader(make([]byte, 2*1024*1024))) + require.NoError(t, err) + assert.Equal(t, http.StatusRequestEntityTooLarge, response.StatusCode) + }) + t.Run("applies to the internal interface as well", func(t *testing.T) { + response, err := http.Post("http://"+engine.config.Internal.Address+"/internal/test", "application/json", bytes.NewReader(make([]byte, 2*1024*1024))) + require.NoError(t, err) + assert.Equal(t, http.StatusRequestEntityTooLarge, response.StatusCode) + }) +} + func assertServerStarted(t *testing.T, address string) { t.Helper() var err error From 10138c1ff0994c7f3e8ac40012fdfec9dbcc0ad8 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Thu, 30 Jul 2026 17:03:38 +0200 Subject: [PATCH 2/2] docs: add release note for inbound request body limit Assisted-by: AI --- docs/pages/release_notes.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/pages/release_notes.rst b/docs/pages/release_notes.rst index 563d39aa8c..ce9fd92f1b 100644 --- a/docs/pages/release_notes.rst +++ b/docs/pages/release_notes.rst @@ -14,6 +14,7 @@ Unreleased * #4233: ``request-credential`` API gains an optional ``credential_request_params`` JSON object overlaid on top of the OpenID4VCI Credential Request body sent to the issuer. Lets the wallet talk to issuers that accept additional fields, or to override the credential request entirely. ## Security +* #4441: Inbound HTTP request bodies are now limited to 1MB on both the public and internal interfaces; larger requests are rejected with HTTP 413 (Request Entity Too Large). Previously no limit was enforced, contrary to what the deployment documentation stated. The heaviest legitimate requests (OAuth POSTs carrying Verifiable Presentations) stay well below this limit, and it matches the ``client_max_body_size 1M`` reverse proxy configuration the documentation recommends. By @stevenvegt in https://github.com/nuts-foundation/nuts-node/pull/4441 * #4421: Stop reflecting fetched HTTP response bodies in API responses. The OAuth2 and OpenID4VCI callback handlers no longer place a remote endpoint's response body or error text into the returned ``error_description``, the did:web resolver no longer returns the fetched document body in its parse error, and the Discovery Service client no longer includes the remote server's error response in errors returned through the discovery APIs. Such content is now logged (truncated) for diagnostics instead. Static context such as the endpoint that failed is retained. By @stevenvegt in https://github.com/nuts-foundation/nuts-node/pull/4421 * #4420: Harden the strict-mode HTTP client against SSRF. In strict mode the client now refuses at connect time to reach non-public addresses (loopback, private/RFC1918, unique local, link-local and unspecified), checked against the resolved IP so DNS-rebinding cannot bypass it, and refuses to follow a redirect that downgrades from HTTPS to HTTP. Cloud provider metadata endpoints are always blocked, following the OWASP SSRF prevention cheat sheet. Deployments that legitimately reach a private address for an internal flow (such as an internal credential offering or OAuth user flow) can permit specific ranges with ``http.client.allowedinternalcidrs``; publicly routable ranges that are internal-only can additionally be blocked with ``http.client.deniedcidrs``, which takes precedence. Reported by @raysabee, fixed by @stevenvegt in https://github.com/nuts-foundation/nuts-node/pull/4420