Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions discovery/api/server/client/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,10 @@ import (
"time"
)

// New creates a new DefaultHTTPClient.
func New(timeout time.Duration) *DefaultHTTPClient {
// New creates a new DefaultHTTPClient. Options are passed to the underlying strict HTTP client.
func New(timeout time.Duration, options ...client.Option) *DefaultHTTPClient {
return &DefaultHTTPClient{
client: client.New(timeout),
client: client.New(timeout, options...),
}
}

Expand Down
20 changes: 19 additions & 1 deletion discovery/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (
"github.com/nuts-foundation/nuts-node/v6/core"
"github.com/nuts-foundation/nuts-node/v6/discovery/api/server/client"
"github.com/nuts-foundation/nuts-node/v6/discovery/log"
httpclient "github.com/nuts-foundation/nuts-node/v6/http/client"
"github.com/nuts-foundation/nuts-node/v6/storage"
"github.com/nuts-foundation/nuts-node/v6/vcr"
"github.com/nuts-foundation/nuts-node/v6/vcr/credential"
Expand Down Expand Up @@ -57,6 +58,20 @@ var (
errRetractionContainsCredentials = errors.New("retraction presentation must not contain credentials")
errInvalidRetractionJTIClaim = errors.New("invalid/missing 'retract_jti' claim for retraction presentation")
errCyclicForwardingDetected = errors.New("cyclic forwarding detected")
errPresentationTooLarge = fmt.Errorf("presentation exceeds maximum size of %d bytes", maxPresentationSize)
)

const (
// maxPresentationSize is the maximum size (in bytes) of a Verifiable Presentation that can be registered on a Discovery Service.
// Legitimate registrations are a few KB; a VP carrying an X509Credential with a full PKIoverheid certificate chain
// measures about 16 KB. The cap bounds what a single participant can add to the service, so that a few
// registrations cannot push the service's responses over what clients are willing to read (maxResponseSize).
maxPresentationSize = 64 * 1024
// maxResponseSize is the maximum size (in bytes) of a response from a remote Discovery Service that the client reads.
// It is larger than the default of the strict HTTP client, since the endpoint is operator-configured (through the
// service definition) and a service's full list of presentations can exceed 1 MB. It is bounded because the
// response is buffered and parsed in memory.
maxResponseSize = 10 * 1024 * 1024
)

var _ core.Injectable = &Module{}
Expand Down Expand Up @@ -106,7 +121,7 @@ func (m *Module) Configure(serverConfig core.ServerConfig) error {
return err
}

m.httpClient = client.New(serverConfig.HTTPClient.Timeout)
m.httpClient = client.New(serverConfig.HTTPClient.Timeout, httpclient.WithMaxResponseSize(maxResponseSize))

return m.loadDefinitions()

Expand Down Expand Up @@ -229,6 +244,9 @@ func (m *Module) Register(context context.Context, serviceID string, presentatio

func (m *Module) verifyRegistration(definition ServiceDefinition, presentation vc.VerifiablePresentation) error {
// First, simple sanity checks
if len(presentation.Raw()) > maxPresentationSize {
return errors.Join(ErrInvalidPresentation, errPresentationTooLarge)
}
if presentation.Format() != vc.JWTPresentationProofFormat {
return errors.Join(ErrInvalidPresentation, errUnsupportedPresentationFormat)
}
Expand Down
12 changes: 12 additions & 0 deletions discovery/module_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import (
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"gorm.io/gorm"
"strings"
"sync"
"sync/atomic"
"testing"
Expand Down Expand Up @@ -118,6 +119,17 @@ func Test_Module_Register(t *testing.T) {

assert.EqualError(t, err, "presentation is invalid for registration\npresentation is valid for too long (max 1s)")
})
t.Run("too large", func(t *testing.T) {
m, _ := setupModule(t, storageEngine)
// pad the VP with a claim to push it over the maximum size, a real one is a few KB
largeVP := createPresentationCustom(aliceDID, func(claims map[string]interface{}, _ *vc.VerifiablePresentation) {
claims["padding"] = strings.Repeat("a", maxPresentationSize)
}, vcAlice)

err := m.Register(ctx, testServiceID, largeVP)

assert.EqualError(t, err, "presentation is invalid for registration\npresentation exceeds maximum size of 65536 bytes")
})
t.Run("no expiration", func(t *testing.T) {
m, _ := setupModule(t, storageEngine)
err := m.Register(ctx, testServiceID, createPresentationCustom(aliceDID, func(claims map[string]interface{}, _ *vc.VerifiablePresentation) {
Expand Down
3 changes: 3 additions & 0 deletions docs/pages/deployment/discovery.rst
Original file line number Diff line number Diff line change
Expand Up @@ -184,4 +184,7 @@ A service definition consists of:
- ``presentation_max_validity``: the maximum validity of the Verifiable Presentation in seconds
- ``presentation_definition``: the presentation definition that specifies the required Verifiable Credentials (see `Presentation Definitions <https://identity.foundation/presentation-exchange/>`_)

Registered Verifiable Presentations may be at most 64 KiB. This is a fixed limit of the Nuts node, not configurable per service definition.
Legitimate presentations are a few KB (about 16 KB when they carry an ``X509Credential`` with a full certificate chain), the limit keeps a single participant from inflating the service's responses.

For details see `Nuts RFC022 <https://nuts-foundation.gitbook.io/drafts/rfc/rfc022-discovery-service>`_.
1 change: 1 addition & 0 deletions docs/pages/release_notes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Unreleased
* Network: connections on which no message was received for ``network.idletimeout`` (default ``2m``) are now closed and re-established. Peers send gossip and diagnostics messages every few seconds, so a silent connection is a dead one: typically a half-open TCP connection or a reverse proxy that kept the stream open after the other side went away. Previously such connections lingered until the proxy or node was restarted, and the peer holding the stale connection rejected new connections with ``already connected``. Set ``network.idletimeout`` to ``0`` to disable. By @stevenvegt in https://github.com/nuts-foundation/nuts-node/pull/4562

## Security
* #4596: Discovery Service: registered Verifiable Presentations are now limited to 64 KiB, and the Discovery Service client reads responses of up to 10 MiB from the (operator-configured) Discovery Server instead of the 1 MiB applied to other outbound HTTP calls. Previously a client could no longer synchronize a service whose response exceeded 1 MiB, which a few hundred registrations or a couple of deliberately padded ones could cause. By @reinkrul in https://github.com/nuts-foundation/nuts-node/pull/4597
* Docker image: base image upgraded to alpine 3.24.2. Upgrade github.com/go-chi/chi/v5 to v5.3.0 (`GO-2026-5777 <https://pkg.go.dev/vuln/GO-2026-5777>`_, `GO-2026-5775 <https://pkg.go.dev/vuln/GO-2026-5775>`_ and `GO-2026-5774 <https://pkg.go.dev/vuln/GO-2026-5774>`_: IP spoofing through the ``X-Forwarded-For`` header in the ``RealIP`` middleware) as reported by image scanners. This code path is not reachable from the node according to govulncheck.
* #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
* #4439: Helm chart (version 0.0.9): default ``verbosity`` changed from ``debug`` to ``info``, matching the node's own default. Debug verbosity produces far more log output than production needs and increases the impact of any log-hygiene issue. Set ``nuts.config.verbosity: debug`` in your own values to restore the old behavior. By @stevenvegt in https://github.com/nuts-foundation/nuts-node/pull/4439
Expand Down
80 changes: 49 additions & 31 deletions http/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,26 +91,47 @@ func checkRedirect(req *http.Request, via []*http.Request) error {
// This of course heavily depends on the use case, but 1MB is a reasonable default.
const DefaultMaxHttpResponseSize = 1024 * 1024

// limitedReadAll reads the given reader until the DefaultMaxHttpResponseSize is reached.
// It returns an error if more data is available than DefaultMaxHttpResponseSize.
func limitedReadAll(reader io.Reader) ([]byte, error) {
result, err := io.ReadAll(io.LimitReader(reader, DefaultMaxHttpResponseSize+1))
if len(result) > DefaultMaxHttpResponseSize {
return nil, fmt.Errorf("data to read exceeds max. safety limit of %d bytes", DefaultMaxHttpResponseSize)
// limitedReadAll reads the given reader until the given limit is reached.
// It returns an error if more data is available than the limit.
func limitedReadAll(reader io.Reader, limit int64) ([]byte, error) {
result, err := io.ReadAll(io.LimitReader(reader, limit+1))
if int64(len(result)) > limit {
return nil, fmt.Errorf("data to read exceeds max. safety limit of %d bytes", limit)
}
return result, err
}

// Option configures a StrictHTTPClient.
type Option func(*StrictHTTPClient)

// WithMaxResponseSize overrides the maximum HTTP response body size (in bytes) the client reads.
// The default is DefaultMaxHttpResponseSize. Only raise it for endpoints that are operator-configured
// and known to return large responses, since the response is buffered in memory.
func WithMaxResponseSize(size int64) Option {
return func(client *StrictHTTPClient) {
client.maxResponseSize = size
}
}

// New creates a new HTTP client with the given timeout.
func New(timeout time.Duration) *StrictHTTPClient {
func New(timeout time.Duration, options ...Option) *StrictHTTPClient {
transport := getTransport(SafeHttpTransport)
return &StrictHTTPClient{
client: &http.Client{
Transport: transport,
Timeout: timeout,
CheckRedirect: checkRedirect,
},
return newStrictHTTPClient(&http.Client{
Transport: transport,
Timeout: timeout,
CheckRedirect: checkRedirect,
}, options)
}

func newStrictHTTPClient(httpClient *http.Client, options []Option) *StrictHTTPClient {
result := &StrictHTTPClient{
client: httpClient,
maxResponseSize: DefaultMaxHttpResponseSize,
}
for _, option := range options {
option(result)
}
return result
}

// getTransport wraps the given transport with OpenTelemetry instrumentation if tracing is enabled.
Expand All @@ -126,34 +147,31 @@ func getTransport(base http.RoundTripper) http.RoundTripper {

// NewWithCache creates a new HTTP client with the given timeout.
// It uses the DefaultCachingTransport as the underlying transport.
func NewWithCache(timeout time.Duration) *StrictHTTPClient {
func NewWithCache(timeout time.Duration, options ...Option) *StrictHTTPClient {
transport := getTransport(DefaultCachingTransport)
return &StrictHTTPClient{
client: &http.Client{
Transport: transport,
Timeout: timeout,
CheckRedirect: checkRedirect,
},
}
return newStrictHTTPClient(&http.Client{
Transport: transport,
Timeout: timeout,
CheckRedirect: checkRedirect,
}, options)
}

// NewWithTLSConfig creates a new HTTP client with the given timeout and TLS configuration.
// It copies the http.DefaultTransport and sets the TLSClientConfig to the given tls.Config.
// As such, it can't be used in conjunction with the CachingRoundTripper.
func NewWithTLSConfig(timeout time.Duration, tlsConfig *tls.Config) *StrictHTTPClient {
func NewWithTLSConfig(timeout time.Duration, tlsConfig *tls.Config, options ...Option) *StrictHTTPClient {
transport := SafeHttpTransport.Clone()
transport.TLSClientConfig = tlsConfig
return &StrictHTTPClient{
client: &http.Client{
Transport: getTransport(transport),
Timeout: timeout,
CheckRedirect: checkRedirect,
},
}
return newStrictHTTPClient(&http.Client{
Transport: getTransport(transport),
Timeout: timeout,
CheckRedirect: checkRedirect,
}, options)
}

type StrictHTTPClient struct {
client *http.Client
client *http.Client
maxResponseSize int64
}

func (s *StrictHTTPClient) Do(req *http.Request) (*http.Response, error) {
Expand All @@ -168,7 +186,7 @@ func (s *StrictHTTPClient) Do(req *http.Request) (*http.Response, error) {
return nil, err
}
if result.Body != nil {
body, err := limitedReadAll(result.Body)
body, err := limitedReadAll(result.Body, s.maxResponseSize)
if err != nil {
return nil, err
}
Expand Down
37 changes: 35 additions & 2 deletions http/client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ package client
import (
"crypto/tls"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
Expand Down Expand Up @@ -236,20 +237,52 @@ func TestStrictHTTPClient_RedirectScheme(t *testing.T) {
func TestLimitedReadAll(t *testing.T) {
t.Run("less than limit", func(t *testing.T) {
data := strings.Repeat("a", 10)
result, err := limitedReadAll(strings.NewReader(data))
result, err := limitedReadAll(strings.NewReader(data), DefaultMaxHttpResponseSize)

assert.NoError(t, err)
assert.Equal(t, []byte(data), result)
})
t.Run("more than limit", func(t *testing.T) {
data := strings.Repeat("a", DefaultMaxHttpResponseSize+1)
result, err := limitedReadAll(strings.NewReader(data))
result, err := limitedReadAll(strings.NewReader(data), DefaultMaxHttpResponseSize)

assert.EqualError(t, err, "data to read exceeds max. safety limit of 1048576 bytes")
assert.Nil(t, result)
})
}

func TestWithMaxResponseSize(t *testing.T) {
body := strings.Repeat("a", DefaultMaxHttpResponseSize+1)
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
_, _ = writer.Write([]byte(body))
}))
t.Cleanup(server.Close)

t.Run("default limit rejects response", func(t *testing.T) {
request, _ := http.NewRequest(http.MethodGet, server.URL, nil)

_, err := New(time.Second).Do(request)

assert.EqualError(t, err, "data to read exceeds max. safety limit of 1048576 bytes")
})
t.Run("raised limit accepts response", func(t *testing.T) {
request, _ := http.NewRequest(http.MethodGet, server.URL, nil)

response, err := New(time.Second, WithMaxResponseSize(2*DefaultMaxHttpResponseSize)).Do(request)

require.NoError(t, err)
data, _ := io.ReadAll(response.Body)
assert.Len(t, data, len(body))
})
t.Run("applies to all constructors", func(t *testing.T) {
option := WithMaxResponseSize(123)
assert.Equal(t, int64(123), New(time.Second, option).maxResponseSize)
assert.Equal(t, int64(123), NewWithCache(time.Second, option).maxResponseSize)
assert.Equal(t, int64(123), NewWithTLSConfig(time.Second, &tls.Config{}, option).maxResponseSize)
assert.Equal(t, int64(DefaultMaxHttpResponseSize), New(time.Second).maxResponseSize)
})
}

func TestMaxConns(t *testing.T) {
oldStrictMode := StrictMode
StrictMode = false
Expand Down
Loading