diff --git a/auth/services/oauth/relying_party.go b/auth/services/oauth/relying_party.go index 4a6e8e5570..70365c00ca 100644 --- a/auth/services/oauth/relying_party.go +++ b/auth/services/oauth/relying_party.go @@ -34,6 +34,7 @@ import ( "github.com/nuts-foundation/nuts-node/core" nutsCrypto "github.com/nuts-foundation/nuts-node/crypto" "github.com/nuts-foundation/nuts-node/didman" + httpclient "github.com/nuts-foundation/nuts-node/http/client" "github.com/nuts-foundation/nuts-node/vcr/credential" "github.com/nuts-foundation/nuts-node/vdr/didservice" "github.com/nuts-foundation/nuts-node/vdr/didstore" @@ -74,12 +75,14 @@ func (s *relyingParty) RequestAccessToken(ctx context.Context, jwtGrantToken str if s.secureMode && strings.ToLower(authorizationServerEndpoint.Scheme) != "https" { return nil, fmt.Errorf("authorization server endpoint must be HTTPS when in strict mode: %s", authorizationServerEndpoint.String()) } - httpClient := &http.Client{} + // The endpoint is resolved from another party's DID document, so use the transport + // with the strict-mode SSRF dial guard. + transport := httpclient.SafeHttpTransport if s.httpClientTLS != nil { - httpClient.Transport = &http.Transport{ - TLSClientConfig: s.httpClientTLS, - } + transport = transport.Clone() + transport.TLSClientConfig = s.httpClientTLS } + httpClient := &http.Client{Transport: transport} authClient, err := client.NewHTTPClient("", s.httpClientTimeout, client.WithHTTPClient(httpClient), client.WithRequestEditorFn(core.UserAgentRequestEditor)) if err != nil { return nil, fmt.Errorf("unable to create HTTP client: %w", err) diff --git a/core/http_client.go b/core/http_client.go index 3787771653..45b086ec99 100644 --- a/core/http_client.go +++ b/core/http_client.go @@ -141,6 +141,21 @@ func newEmptyTokenGenerator() AuthorizationTokenGenerator { // NewStrictHTTPClient creates a HTTPRequestDoer that only allows HTTPS calls when strictmode is enabled. func NewStrictHTTPClient(strictmode bool, client *http.Client) HTTPRequestDoer { + if strictmode && client.CheckRedirect == nil { + // The HTTPS check in Do only guards the first hop, so without this a valid remote + // host could redirect the client onto a plaintext internal endpoint. Setting + // CheckRedirect replaces the standard library's default policy, so the 10-redirect + // cap is reimplemented here. + client.CheckRedirect = func(req *http.Request, via []*http.Request) error { + if len(via) >= 10 { + return errors.New("stopped after 10 redirects") + } + if req.URL.Scheme != "https" { + return errors.New("strictmode is enabled, but redirect target is not over HTTPS") + } + return nil + } + } return &strictHTTPClient{ client: client, strictmode: strictmode, diff --git a/docs/pages/deployment/cli-reference.rst b/docs/pages/deployment/cli-reference.rst index cbab55e8ef..0874fe33e6 100755 --- a/docs/pages/deployment/cli-reference.rst +++ b/docs/pages/deployment/cli-reference.rst @@ -36,6 +36,8 @@ The following options apply to the server commands below: --events.nats.timeout int Timeout for NATS server operations (default 30) --goldenhammer.enabled Whether to enable automatically fixing DID documents with the required endpoints. (default true) --goldenhammer.interval duration The interval in which to check for DID documents to fix. (default 10m0s) + --http.client.allowedinternalcidrs strings IP ranges (CIDR notation, e.g. 10.0.0.0/8) exempted from the strict-mode SSRF guard, which otherwise blocks outbound requests to non-public networks. Use to permit internal flows that legitimately target a private address, such as an internal credential offering or an internal OAuth user flow. Leave empty to block all non-public addresses. + --http.client.deniedcidrs strings IP ranges (CIDR notation) that outbound HTTP requests must never target in strict mode, in addition to the built-in blocked ranges (non-public addresses and cloud metadata endpoints). Use for publicly routable ranges that are internal-only in your infrastructure. Takes precedence over http.client.allowedinternalcidrs. --http.default.address string Address and port the server will be listening to (default ":1323") --http.default.auth.audience string Expected audience for JWT tokens (default: hostname) --http.default.auth.authorizedkeyspath string Path to an authorized_keys file for trusted JWT signers diff --git a/docs/pages/deployment/production-configuration.rst b/docs/pages/deployment/production-configuration.rst index d868d296ef..7415bcc657 100644 --- a/docs/pages/deployment/production-configuration.rst +++ b/docs/pages/deployment/production-configuration.rst @@ -243,3 +243,57 @@ Special attention should be given to: Since the gRPC interface and ``/n2n`` HTTP endpoint are authenticated using the TLS client certificate, you can monitor (and potentially deny) access to these endpoint by identifying the exact client. + +.. _ssrf-protection: + +Outbound HTTP and SSRF protection +********************************* + +The Nuts node fetches URLs supplied by other parties, for example OAuth endpoints resolved from +DID documents and OpenID4VCI issuer and wallet endpoints. An attacker could register a URL that +resolves to an address inside your network and let the node request it on their behalf. This is +known as Server-Side Request Forgery (SSRF). + +In strict mode the node refuses these outbound HTTP connections to non-public addresses: loopback, +private (RFC 1918), carrier-grade NAT, link-local, unique local, and the other special-purpose ranges +published in the IANA +`IPv4 `_ and +`IPv6 `_ +special-purpose address registries. The check runs at connect time against the resolved IP address, so it +also covers redirects and cannot be bypassed with DNS rebinding. Cloud provider metadata endpoints (such as +``169.254.169.254`` and Azure's ``168.63.129.16``) are always blocked, following the +`OWASP SSRF prevention cheat sheet `_. +Redirects that downgrade from HTTPS to HTTP are refused as well. + +A blocked request fails with an error like:: + + strictmode: blocked connection to non-public address 10.0.0.5 + +Allowing internal ranges +^^^^^^^^^^^^^^^^^^^^^^^^ + +If a legitimate flow targets a private address, for example an internal OpenID4VCI issuer or an OAuth +flow that stays inside your network, then permit that range with ``http.client.allowedinternalcidrs``. +If part of an allowed range must stay unreachable, then deny that part with ``http.client.deniedcidrs``; +denied ranges take precedence: + +.. code-block:: yaml + + http: + client: + allowedinternalcidrs: + - 10.0.0.0/8 + deniedcidrs: + - 10.5.0.0/16 + +Keep allowed ranges as narrow as possible. Every address in an allowed range becomes reachable for any +URL an external party can make the node fetch. + +Blocking additional ranges +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Some networks use publicly routable addresses for internal-only systems. Public addresses are not +covered by the built-in guard, so add such ranges to ``http.client.deniedcidrs`` explicitly. + +The built-in blocks for cloud metadata endpoints cannot be lifted with ``allowedinternalcidrs``. +Both options only apply in strict mode; without strict mode the SSRF guard is disabled. diff --git a/docs/pages/deployment/server_options.rst b/docs/pages/deployment/server_options.rst index c5f390c25c..a5ac0e9180 100755 --- a/docs/pages/deployment/server_options.rst +++ b/docs/pages/deployment/server_options.rst @@ -2,86 +2,88 @@ :widths: 20 30 50 :class: options-table - ==================================== =============================================================================================================================================================================================================================================================================================================== ================================================================================================================================================================================================================================== - Key Default Description - ==================================== =============================================================================================================================================================================================================================================================================================================== ================================================================================================================================================================================================================================== - configfile nuts.yaml Nuts config file - cpuprofile When set, a CPU profile is written to the given path. Ignored when strictmode is set. - datadir ./data Directory where the node stores its files. - internalratelimiter true When set, expensive internal calls are rate-limited to protect the network. Always enabled in strict mode. - loggerformat text Log format (text, json) - strictmode true When set, insecure settings are forbidden. - verbosity info Log level (trace, debug, info, warn, error) - tls.certfile PEM file containing the certificate for the server (also used as client certificate). - tls.certheader Name of the HTTP header that will contain the client certificate when TLS is offloaded. - tls.certkeyfile PEM file containing the private key of the server certificate. - tls.offload Whether to enable TLS offloading for incoming connections. Enable by setting it to 'incoming'. If enabled 'tls.certheader' must be configured as well. - tls.truststorefile truststore.pem PEM file containing the trusted CA certificates for authenticating remote servers. - **Auth** - auth.accesstokenlifespan 60 defines how long (in seconds) an access token is valid. Uses default in strict mode. - auth.clockskew 5000 allowed JWT Clock skew in milliseconds - auth.contractvalidators [irma,uzi,dummy,employeeid] sets the different contract validators to use - auth.publicurl public URL which can be reached by a users IRMA client, this should include the scheme and domain: https://example.com. Additional paths should only be added if some sort of url-rewriting is done in a reverse-proxy. - auth.http.timeout 30 HTTP timeout (in seconds) used by the Auth API HTTP client - auth.irma.autoupdateschemas true set if you want automatically update the IRMA schemas every 60 minutes. - auth.irma.schememanager pbdf IRMA schemeManager to use for attributes. Can be either 'pbdf' or 'irma-demo'. - **Crypto** - crypto.storage fs Storage to use, 'external' for an external backend (experimental), 'fs' for file system (for development purposes), 'vaultkv' for Vault KV store (recommended, will be replaced by external backend in future). - crypto.external.address Address of the external storage service. - crypto.external.timeout 100ms Time-out when invoking the external storage backend, in Golang time.Duration string format (e.g. 1s). - crypto.vault.address The Vault address. If set it overwrites the VAULT_ADDR env var. - crypto.vault.pathprefix kv The Vault path prefix. - crypto.vault.timeout 5s Timeout of client calls to Vault, in Golang time.Duration string format (e.g. 1s). - crypto.vault.token The Vault token. If set it overwrites the VAULT_TOKEN env var. - **Events** - events.nats.hostname 0.0.0.0 Hostname for the NATS server - events.nats.port 4222 Port where the NATS server listens on - events.nats.storagedir Directory where file-backed streams are stored in the NATS server - events.nats.timeout 30 Timeout for NATS server operations - **GoldenHammer** - goldenhammer.enabled true Whether to enable automatically fixing DID documents with the required endpoints. - goldenhammer.interval 10m0s The interval in which to check for DID documents to fix. - **HTTP** - http.default.address \:1323 Address and port the server will be listening to - http.default.log metadata What to log about HTTP requests. Options are 'nothing', 'metadata' (log request method, URI, IP and response code), and 'metadata-and-body' (log the request and response body, in addition to the metadata). - http.default.tls Whether to enable TLS for the default interface, options are 'disabled', 'server', 'server-client'. Leaving it empty is synonymous to 'disabled', - http.default.auth.audience Expected audience for JWT tokens (default: hostname) - http.default.auth.authorizedkeyspath Path to an authorized_keys file for trusted JWT signers - http.default.auth.type Whether to enable authentication for the default interface, specify 'token_v2' for bearer token mode or 'token' for legacy bearer token mode. - http.default.cors.origin [] When set, enables CORS from the specified origins on the default HTTP interface. - **JSONLD** - jsonld.contexts.localmapping [https://www.w3.org/2018/credentials/v1=assets/contexts/w3c-credentials-v1.ldjson,https://w3c-ccg.github.io/lds-jws2020/contexts/lds-jws2020-v1.json=assets/contexts/lds-jws2020-v1.ldjson,https://schema.org=assets/contexts/schema-org-v13.ldjson,https://nuts.nl/credentials/v1=assets/contexts/nuts.ldjson] This setting allows mapping external URLs to local files for e.g. preventing external dependencies. These mappings have precedence over those in remoteallowlist. - jsonld.contexts.remoteallowlist [https://schema.org,https://www.w3.org/2018/credentials/v1,https://w3c-ccg.github.io/lds-jws2020/contexts/lds-jws2020-v1.json] In strict mode, fetching external JSON-LD contexts is not allowed except for context-URLs listed here. - **Network** - network.bootstrapnodes [] List of bootstrap nodes (':') which the node initially connect to. - network.connectiontimeout 5000 Timeout before an outbound connection attempt times out (in milliseconds). - network.enablediscovery true Whether to enable automatic connecting to other nodes. - network.enabletls true Whether to enable TLS for gRPC connections, which can be disabled for demo/development purposes. It is NOT meant for TLS offloading (see 'tls.offload'). Disabling TLS is not allowed in strict-mode. - network.grpcaddr \:5555 Local address for gRPC to listen on. If empty the gRPC server won't be started and other nodes will not be able to connect to this node (outbound connections can still be made). - network.maxbackoff 24h0m0s Maximum between outbound connections attempts to unresponsive nodes (in Golang duration format, e.g. '1h', '30m'). - network.nodedid Specifies the DID of the organization that operates this node, typically a vendor for EPD software. It is used to identify the node on the network. If the DID document does not exist of is deactivated, the node will not start. - network.protocols [] Specifies the list of network protocols to enable on the server. They are specified by version (1, 2). If not set, all protocols are enabled. - network.v2.diagnosticsinterval 5000 Interval (in milliseconds) that specifies how often the node should broadcast its diagnostic information to other nodes (specify 0 to disable). - network.v2.gossipinterval 5000 Interval (in milliseconds) that specifies how often the node should gossip its new hashes to other nodes. - **PKI** - pki.maxupdatefailhours 4 Maximum number of hours that a denylist update can fail - pki.softfail true Do not reject certificates if their revocation status cannot be established when softfail is true - **Storage** - storage.debug false When true, enables extra logging of storage-layer problems (e.g. performance issues). - storage.bbolt.locktimeout 1s Maximum time to wait for acquiring a lock on the BBolt database before giving up and returning an error. Formatted as Golang duration (e.g. 1s, 1m). - storage.bbolt.backup.directory Target directory for BBolt database backups. - storage.bbolt.backup.interval 0s Interval, formatted as Golang duration (e.g. 10m, 1h) at which BBolt database backups will be performed. - storage.redis.address Redis database server address. This can be a simple 'host:port' or a Redis connection URL with scheme, auth and other options. - storage.redis.database Redis database name, which is used as prefix every key. Can be used to have multiple instances use the same Redis instance. - storage.redis.password Redis database password. If set, it overrides the username in the connection URL. - storage.redis.username Redis database username. If set, it overrides the username in the connection URL. - storage.redis.sentinel.master Name of the Redis Sentinel master. Setting this property enables Redis Sentinel. - storage.redis.sentinel.nodes [] Addresses of the Redis Sentinels to connect to initially. Setting this property enables Redis Sentinel. - storage.redis.sentinel.password Password for authenticating to Redis Sentinels. - storage.redis.sentinel.username Username for authenticating to Redis Sentinels. - storage.redis.tls.truststorefile PEM file containing the trusted CA certificate(s) for authenticating remote Redis servers. Can only be used when connecting over TLS (use 'rediss://' as scheme in address). - **VCR** - vcr.openid4vci.definitionsdir Directory with the additional credential definitions the node could issue (experimental, may change without notice). - vcr.openid4vci.enabled true Enable issuing and receiving credentials over OpenID4VCI. - vcr.openid4vci.timeout 30s Time-out for OpenID4VCI HTTP client operations. - ==================================== =============================================================================================================================================================================================================================================================================================================== ================================================================================================================================================================================================================================== + ==================================== =============================================================================================================================================================================================================================================================================================================== ===================================================================================================================================================================================================================================================================================================================================================== + Key Default Description + ==================================== =============================================================================================================================================================================================================================================================================================================== ===================================================================================================================================================================================================================================================================================================================================================== + configfile nuts.yaml Nuts config file + cpuprofile When set, a CPU profile is written to the given path. Ignored when strictmode is set. + datadir ./data Directory where the node stores its files. + internalratelimiter true When set, expensive internal calls are rate-limited to protect the network. Always enabled in strict mode. + loggerformat text Log format (text, json) + strictmode true When set, insecure settings are forbidden. + verbosity info Log level (trace, debug, info, warn, error) + tls.certfile PEM file containing the certificate for the server (also used as client certificate). + tls.certheader Name of the HTTP header that will contain the client certificate when TLS is offloaded. + tls.certkeyfile PEM file containing the private key of the server certificate. + tls.offload Whether to enable TLS offloading for incoming connections. Enable by setting it to 'incoming'. If enabled 'tls.certheader' must be configured as well. + tls.truststorefile truststore.pem PEM file containing the trusted CA certificates for authenticating remote servers. + **Auth** + auth.accesstokenlifespan 60 defines how long (in seconds) an access token is valid. Uses default in strict mode. + auth.clockskew 5000 allowed JWT Clock skew in milliseconds + auth.contractvalidators [irma,uzi,dummy,employeeid] sets the different contract validators to use + auth.publicurl public URL which can be reached by a users IRMA client, this should include the scheme and domain: https://example.com. Additional paths should only be added if some sort of url-rewriting is done in a reverse-proxy. + auth.http.timeout 30 HTTP timeout (in seconds) used by the Auth API HTTP client + auth.irma.autoupdateschemas true set if you want automatically update the IRMA schemas every 60 minutes. + auth.irma.schememanager pbdf IRMA schemeManager to use for attributes. Can be either 'pbdf' or 'irma-demo'. + **Crypto** + crypto.storage fs Storage to use, 'external' for an external backend (experimental), 'fs' for file system (for development purposes), 'vaultkv' for Vault KV store (recommended, will be replaced by external backend in future). + crypto.external.address Address of the external storage service. + crypto.external.timeout 100ms Time-out when invoking the external storage backend, in Golang time.Duration string format (e.g. 1s). + crypto.vault.address The Vault address. If set it overwrites the VAULT_ADDR env var. + crypto.vault.pathprefix kv The Vault path prefix. + crypto.vault.timeout 5s Timeout of client calls to Vault, in Golang time.Duration string format (e.g. 1s). + crypto.vault.token The Vault token. If set it overwrites the VAULT_TOKEN env var. + **Events** + events.nats.hostname 0.0.0.0 Hostname for the NATS server + events.nats.port 4222 Port where the NATS server listens on + events.nats.storagedir Directory where file-backed streams are stored in the NATS server + events.nats.timeout 30 Timeout for NATS server operations + **GoldenHammer** + goldenhammer.enabled true Whether to enable automatically fixing DID documents with the required endpoints. + goldenhammer.interval 10m0s The interval in which to check for DID documents to fix. + **HTTP** + http.client.allowedinternalcidrs [] IP ranges (CIDR notation, e.g. 10.0.0.0/8) exempted from the strict-mode SSRF guard, which otherwise blocks outbound requests to non-public networks. Use to permit internal flows that legitimately target a private address, such as an internal credential offering or an internal OAuth user flow. Leave empty to block all non-public addresses. + http.client.deniedcidrs [] IP ranges (CIDR notation) that outbound HTTP requests must never target in strict mode, in addition to the built-in blocked ranges (non-public addresses and cloud metadata endpoints). Use for publicly routable ranges that are internal-only in your infrastructure. Takes precedence over http.client.allowedinternalcidrs. + http.default.address \:1323 Address and port the server will be listening to + http.default.log metadata What to log about HTTP requests. Options are 'nothing', 'metadata' (log request method, URI, IP and response code), and 'metadata-and-body' (log the request and response body, in addition to the metadata). + http.default.tls Whether to enable TLS for the default interface, options are 'disabled', 'server', 'server-client'. Leaving it empty is synonymous to 'disabled', + http.default.auth.audience Expected audience for JWT tokens (default: hostname) + http.default.auth.authorizedkeyspath Path to an authorized_keys file for trusted JWT signers + http.default.auth.type Whether to enable authentication for the default interface, specify 'token_v2' for bearer token mode or 'token' for legacy bearer token mode. + http.default.cors.origin [] When set, enables CORS from the specified origins on the default HTTP interface. + **JSONLD** + jsonld.contexts.localmapping [https://schema.org=assets/contexts/schema-org-v13.ldjson,https://nuts.nl/credentials/v1=assets/contexts/nuts.ldjson,https://www.w3.org/2018/credentials/v1=assets/contexts/w3c-credentials-v1.ldjson,https://w3c-ccg.github.io/lds-jws2020/contexts/lds-jws2020-v1.json=assets/contexts/lds-jws2020-v1.ldjson] This setting allows mapping external URLs to local files for e.g. preventing external dependencies. These mappings have precedence over those in remoteallowlist. + jsonld.contexts.remoteallowlist [https://schema.org,https://www.w3.org/2018/credentials/v1,https://w3c-ccg.github.io/lds-jws2020/contexts/lds-jws2020-v1.json] In strict mode, fetching external JSON-LD contexts is not allowed except for context-URLs listed here. + **Network** + network.bootstrapnodes [] List of bootstrap nodes (':') which the node initially connect to. + network.connectiontimeout 5000 Timeout before an outbound connection attempt times out (in milliseconds). + network.enablediscovery true Whether to enable automatic connecting to other nodes. + network.enabletls true Whether to enable TLS for gRPC connections, which can be disabled for demo/development purposes. It is NOT meant for TLS offloading (see 'tls.offload'). Disabling TLS is not allowed in strict-mode. + network.grpcaddr \:5555 Local address for gRPC to listen on. If empty the gRPC server won't be started and other nodes will not be able to connect to this node (outbound connections can still be made). + network.maxbackoff 24h0m0s Maximum between outbound connections attempts to unresponsive nodes (in Golang duration format, e.g. '1h', '30m'). + network.nodedid Specifies the DID of the organization that operates this node, typically a vendor for EPD software. It is used to identify the node on the network. If the DID document does not exist of is deactivated, the node will not start. + network.protocols [] Specifies the list of network protocols to enable on the server. They are specified by version (1, 2). If not set, all protocols are enabled. + network.v2.diagnosticsinterval 5000 Interval (in milliseconds) that specifies how often the node should broadcast its diagnostic information to other nodes (specify 0 to disable). + network.v2.gossipinterval 5000 Interval (in milliseconds) that specifies how often the node should gossip its new hashes to other nodes. + **PKI** + pki.maxupdatefailhours 4 Maximum number of hours that a denylist update can fail + pki.softfail true Do not reject certificates if their revocation status cannot be established when softfail is true + **Storage** + storage.debug false When true, enables extra logging of storage-layer problems (e.g. performance issues). + storage.bbolt.locktimeout 1s Maximum time to wait for acquiring a lock on the BBolt database before giving up and returning an error. Formatted as Golang duration (e.g. 1s, 1m). + storage.bbolt.backup.directory Target directory for BBolt database backups. + storage.bbolt.backup.interval 0s Interval, formatted as Golang duration (e.g. 10m, 1h) at which BBolt database backups will be performed. + storage.redis.address Redis database server address. This can be a simple 'host:port' or a Redis connection URL with scheme, auth and other options. + storage.redis.database Redis database name, which is used as prefix every key. Can be used to have multiple instances use the same Redis instance. + storage.redis.password Redis database password. If set, it overrides the username in the connection URL. + storage.redis.username Redis database username. If set, it overrides the username in the connection URL. + storage.redis.sentinel.master Name of the Redis Sentinel master. Setting this property enables Redis Sentinel. + storage.redis.sentinel.nodes [] Addresses of the Redis Sentinels to connect to initially. Setting this property enables Redis Sentinel. + storage.redis.sentinel.password Password for authenticating to Redis Sentinels. + storage.redis.sentinel.username Username for authenticating to Redis Sentinels. + storage.redis.tls.truststorefile PEM file containing the trusted CA certificate(s) for authenticating remote Redis servers. Can only be used when connecting over TLS (use 'rediss://' as scheme in address). + **VCR** + vcr.openid4vci.definitionsdir Directory with the additional credential definitions the node could issue (experimental, may change without notice). + vcr.openid4vci.enabled true Enable issuing and receiving credentials over OpenID4VCI. + vcr.openid4vci.timeout 30s Time-out for OpenID4VCI HTTP client operations. + ==================================== =============================================================================================================================================================================================================================================================================================================== ===================================================================================================================================================================================================================================================================================================================================================== diff --git a/docs/pages/release_notes.rst b/docs/pages/release_notes.rst index 1f43106bfd..e32fa8f273 100644 --- a/docs/pages/release_notes.rst +++ b/docs/pages/release_notes.rst @@ -3,6 +3,13 @@ Release notes ############# +**************** +Unreleased +**************** + +## Security +* #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. See :ref:`Outbound HTTP and SSRF protection ` for deployment guidance. Reported by @raysabee, fixed by @stevenvegt in https://github.com/nuts-foundation/nuts-node/pull/4420 + ************************* Hazelnut update (v5.4.37) ************************* diff --git a/e2e-tests/openid4vci/issuer-initiated/node-A/nuts.yaml b/e2e-tests/openid4vci/issuer-initiated/node-A/nuts.yaml index 0d62994fcd..8b378a675d 100644 --- a/e2e-tests/openid4vci/issuer-initiated/node-A/nuts.yaml +++ b/e2e-tests/openid4vci/issuer-initiated/node-A/nuts.yaml @@ -9,6 +9,13 @@ http: n2n: address: :443 tls: server-client + client: + # Docker auto-assigns the compose network an arbitrary private subnet, so permit all RFC1918 + # ranges for the strict-mode SSRF guard. Narrow-allowlist precision is covered by unit tests. + allowedinternalcidrs: + - 10.0.0.0/8 + - 172.16.0.0/12 + - 192.168.0.0/16 auth: publicurl: http://nodeA:1323 contractvalidators: diff --git a/e2e-tests/openid4vci/issuer-initiated/node-B/nuts.yaml b/e2e-tests/openid4vci/issuer-initiated/node-B/nuts.yaml index 552d76b143..f5d833353f 100644 --- a/e2e-tests/openid4vci/issuer-initiated/node-B/nuts.yaml +++ b/e2e-tests/openid4vci/issuer-initiated/node-B/nuts.yaml @@ -9,6 +9,13 @@ http: n2n: address: :443 tls: server-client + client: + # Docker auto-assigns the compose network an arbitrary private subnet, so permit all RFC1918 + # ranges for the strict-mode SSRF guard. Narrow-allowlist precision is covered by unit tests. + allowedinternalcidrs: + - 10.0.0.0/8 + - 172.16.0.0/12 + - 192.168.0.0/16 auth: publicurl: http://nodeB:1323 contractvalidators: diff --git a/e2e-tests/openid4vci/network-issuance/node-A/nuts.yaml b/e2e-tests/openid4vci/network-issuance/node-A/nuts.yaml index ee4dd800f3..93194b02c6 100644 --- a/e2e-tests/openid4vci/network-issuance/node-A/nuts.yaml +++ b/e2e-tests/openid4vci/network-issuance/node-A/nuts.yaml @@ -9,6 +9,13 @@ http: n2n: address: :443 tls: server-client + client: + # Docker auto-assigns the compose network an arbitrary private subnet, so permit all RFC1918 + # ranges for the strict-mode SSRF guard. Narrow-allowlist precision is covered by unit tests. + allowedinternalcidrs: + - 10.0.0.0/8 + - 172.16.0.0/12 + - 192.168.0.0/16 auth: publicurl: http://nodeA:1323 contractvalidators: diff --git a/e2e-tests/openid4vci/network-issuance/node-B/nuts.yaml b/e2e-tests/openid4vci/network-issuance/node-B/nuts.yaml index 2ac57788af..ca13f4be9a 100644 --- a/e2e-tests/openid4vci/network-issuance/node-B/nuts.yaml +++ b/e2e-tests/openid4vci/network-issuance/node-B/nuts.yaml @@ -9,6 +9,13 @@ http: n2n: address: :443 tls: server-client + client: + # Docker auto-assigns the compose network an arbitrary private subnet, so permit all RFC1918 + # ranges for the strict-mode SSRF guard. Narrow-allowlist precision is covered by unit tests. + allowedinternalcidrs: + - 10.0.0.0/8 + - 172.16.0.0/12 + - 192.168.0.0/16 auth: publicurl: http://nodeB:1323 contractvalidators: diff --git a/http/client/client.go b/http/client/client.go new file mode 100644 index 0000000000..6415136737 --- /dev/null +++ b/http/client/client.go @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2024 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client + +import ( + "net" + "net/http" + "time" +) + +// SafeHttpTransport is a http.Transport that must be used as transport for HTTP clients +// that fetch URLs influenced by other parties, e.g. OpenID4VCI issuer/wallet endpoints and +// OAuth endpoints resolved from DID documents. It carries the strict-mode SSRF dial guard +// (see denyNonPublicAddr). It is deliberately NOT installed on http.DefaultTransport: +// PKI CRL fetching and external crypto storage clients may legitimately target +// non-public addresses. +var SafeHttpTransport *http.Transport + +func init() { + SafeHttpTransport = http.DefaultTransport.(*http.Transport).Clone() + // guard against SSRF: in strict mode, refuse to connect to non-public addresses, + // checked against the resolved IP so DNS-rebinding cannot bypass it. + // Keeps the default dialer timeouts used by http.DefaultTransport. + SafeHttpTransport.DialContext = (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + Control: denyNonPublicAddr, + }).DialContext +} + +// StrictMode is a flag that can be set to true to enable strict mode for the HTTP client. +var StrictMode bool diff --git a/http/client/ssrf.go b/http/client/ssrf.go new file mode 100644 index 0000000000..12562c8695 --- /dev/null +++ b/http/client/ssrf.go @@ -0,0 +1,207 @@ +/* + * Copyright (C) 2026 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client + +import ( + "fmt" + "net/netip" + "slices" + "syscall" +) + +// This file implements the strict-mode SSRF dial guard: it decides, per connection and based on +// the resolved IP address, whether an outbound request may proceed. The public/non-public split +// follows the IANA special-purpose address registries; the prefix sets mirror those maintained by +// github.com/daenney/ssrf, generated from the same registries. + +// allowedNonPublicNets holds IP networks that outbound requests may connect to even in strict +// mode, despite being on a non-public network. It is configured through SetAllowedNonPublicCIDRs +// from http.client.allowedinternalcidrs, for closed deployments that legitimately federate over a +// private network (e.g. an internal OAuth or credential flow). +var allowedNonPublicNets []netip.Prefix + +// SetAllowedNonPublicCIDRs parses the given CIDR strings and replaces the set of non-public +// networks that strict mode permits. It returns an error on the first invalid CIDR, leaving the +// previous value unchanged. +func SetAllowedNonPublicCIDRs(cidrs []string) error { + nets, err := parseCIDRs(cidrs) + if err != nil { + return err + } + allowedNonPublicNets = nets + return nil +} + +// deniedNets holds IP networks that outbound requests must never connect to in strict mode, +// regardless of the allowlist. It always contains deniedCloudMetadataPrefixes; operator-configured +// ranges from http.client.deniedcidrs (SetDeniedCIDRs) are appended, for deployments whose +// infrastructure uses publicly routable ranges that are internal-only. +var deniedNets = slices.Clone(deniedCloudMetadataPrefixes) + +// SetDeniedCIDRs parses the given CIDR strings and replaces the operator-configured part of the +// denied networks; the built-in cloud metadata prefixes are always retained. It returns an error +// on the first invalid CIDR, leaving the previous value unchanged. +func SetDeniedCIDRs(cidrs []string) error { + nets, err := parseCIDRs(cidrs) + if err != nil { + return err + } + deniedNets = append(slices.Clone(deniedCloudMetadataPrefixes), nets...) + return nil +} + +// parseCIDRs parses CIDR strings into masked prefixes, failing on the first invalid entry. +func parseCIDRs(cidrs []string) ([]netip.Prefix, error) { + nets := make([]netip.Prefix, 0, len(cidrs)) + for _, cidr := range cidrs { + prefix, err := netip.ParsePrefix(cidr) + if err != nil { + return nil, fmt.Errorf("invalid CIDR %q: %w", cidr, err) + } + nets = append(nets, prefix.Masked()) + } + return nets, nil +} + +// isAllowedNonPublic reports whether ip falls within one of the configured allowlisted networks. +func isAllowedNonPublic(ip netip.Addr) bool { + for _, n := range allowedNonPublicNets { + if n.Contains(ip) { + return true + } + } + return false +} + +// deniedCloudMetadataPrefixes are cloud provider metadata endpoints that sit on public address +// space, which the non-public check cannot cover. Blocking cloud metadata endpoints follows the +// OWASP SSRF prevention cheat sheet +// (https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html): +// metadata services hand out credentials and instance configuration, making them the primary SSRF +// target in cloud deployments. Of the endpoints OWASP lists, only Azure's needs an entry here; the +// others (169.254.169.254 used by AWS/GCP/Azure/OpenStack, Alibaba's 100.100.100.200, AWS's +// fd00:ec2::254) already fall in blocked non-public ranges (link-local, CGNAT, ULA). Kept separate +// from the IANA lists so the upstream drift test stays exact. +var deniedCloudMetadataPrefixes = []netip.Prefix{ + netip.MustParsePrefix("168.63.129.16/32"), // Azure wire server / metadata +} + +// isDenied reports whether ip falls in an explicitly denied range: a built-in cloud metadata +// endpoint or an operator-configured denied network. Denied ranges are refused even when the +// allowlist covers them. +func isDenied(ip netip.Addr) bool { + for _, prefix := range deniedNets { + if prefix.Contains(ip) { + return true + } + } + return false +} + +// deniedIPv4Prefixes are the IPv4 special-purpose ranges from the IANA registry +// (https://www.iana.org/assignments/iana-ipv4-special-registry/), following the set used by +// github.com/daenney/ssrf. None of these are legitimate targets for federation traffic. +var deniedIPv4Prefixes = []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/8"), // "This network" (RFC 791), includes the unspecified address + netip.MustParsePrefix("10.0.0.0/8"), // Private-Use (RFC 1918) + netip.MustParsePrefix("100.64.0.0/10"), // Shared Address Space / CGNAT (RFC 6598) + netip.MustParsePrefix("127.0.0.0/8"), // Loopback (RFC 1122) + netip.MustParsePrefix("169.254.0.0/16"), // Link Local (RFC 3927), includes cloud metadata 169.254.169.254 + netip.MustParsePrefix("172.16.0.0/12"), // Private-Use (RFC 1918) + netip.MustParsePrefix("192.0.0.0/24"), // IETF Protocol Assignments (RFC 6890) + netip.MustParsePrefix("192.0.2.0/24"), // Documentation TEST-NET-1 (RFC 5737) + netip.MustParsePrefix("192.31.196.0/24"), // AS112-v4 (RFC 7535) + netip.MustParsePrefix("192.52.193.0/24"), // AMT (RFC 7450) + netip.MustParsePrefix("192.88.99.0/24"), // Deprecated 6to4 Relay Anycast (RFC 7526) + netip.MustParsePrefix("192.168.0.0/16"), // Private-Use (RFC 1918) + netip.MustParsePrefix("192.175.48.0/24"), // Direct Delegation AS112 Service (RFC 7534) + netip.MustParsePrefix("198.18.0.0/15"), // Benchmarking (RFC 2544) + netip.MustParsePrefix("198.51.100.0/24"), // Documentation TEST-NET-2 (RFC 5737) + netip.MustParsePrefix("203.0.113.0/24"), // Documentation TEST-NET-3 (RFC 5737) + netip.MustParsePrefix("224.0.0.0/4"), // Multicast (RFC 1112) + netip.MustParsePrefix("240.0.0.0/4"), // Reserved (RFC 1112), includes the broadcast address +} + +// globalUnicastIPv6Prefix is the range IANA allocates global unicast addresses from, i.e. "the +// internet". IPv6 is guarded allowlist-style: anything outside this range is refused, which covers +// loopback, unspecified, unique local, link-local, site-local, multicast, the discard prefix and +// the NAT64 well-known prefix without enumerating them. +var globalUnicastIPv6Prefix = netip.MustParsePrefix("2000::/3") + +// deniedIPv6Prefixes are special-purpose ranges inside the global unicast range +// (https://www.iana.org/assignments/iana-ipv6-special-registry/), following the set used by +// github.com/daenney/ssrf. 2001::/23 includes Teredo and ORCHID; 2002::/16 (6to4) embeds an IPv4 +// address, so it could smuggle a connection toward an internal IPv4 target through a relay. +var deniedIPv6Prefixes = []netip.Prefix{ + netip.MustParsePrefix("2001::/23"), // IETF Protocol Assignments (RFC 2928) + netip.MustParsePrefix("2001:db8::/32"), // Documentation (RFC 3849) + netip.MustParsePrefix("2002::/16"), // 6to4 (RFC 3056) + netip.MustParsePrefix("2620:4f:8000::/48"), // Direct Delegation AS112 Service (RFC 7534) + netip.MustParsePrefix("3fff::/20"), // Documentation (RFC 9637) +} + +// isNonPublicAddr reports whether ip is not a public internet address. IPv4 (including IPv4-mapped +// IPv6, which the caller must Unmap first) is checked against the special-purpose denylist; IPv6 +// must be global unicast and outside the special-purpose ranges within it. IPv6 addresses with a +// zone never match a prefix and are treated as non-public. +func isNonPublicAddr(ip netip.Addr) bool { + if ip.Is4() { + for _, prefix := range deniedIPv4Prefixes { + if prefix.Contains(ip) { + return true + } + } + return false + } + if !globalUnicastIPv6Prefix.Contains(ip) { + return true + } + for _, prefix := range deniedIPv6Prefixes { + if prefix.Contains(ip) { + return true + } + } + return false +} + +// denyNonPublicAddr is a net.Dialer.Control hook. In strict mode it refuses connections whose +// resolved address is not a public internet address (see isNonPublicAddr). Because it inspects the +// actual IP the socket is about to connect to (after DNS resolution), it closes DNS-rebinding into +// those ranges, which URL-string validation such as core.ParsePublicURL cannot. +// +// Closed deployments that legitimately federate over a private network can permit specific ranges +// through SetAllowedNonPublicCIDRs (config http.client.allowedinternalcidrs). +func denyNonPublicAddr(_ string, address string, _ syscall.RawConn) error { + if !StrictMode { + return nil + } + // The Control hook runs after DNS resolution, so address holds a literal IP. + addrPort, err := netip.ParseAddrPort(address) + if err != nil { + return fmt.Errorf("strictmode: cannot parse connection address %q: %w", address, err) + } + ip := addrPort.Addr().Unmap() + if isDenied(ip) { + return fmt.Errorf("strictmode: blocked connection to denied address %s", ip) + } + if isNonPublicAddr(ip) && !isAllowedNonPublic(ip) { + return fmt.Errorf("strictmode: blocked connection to non-public address %s", ip) + } + return nil +} diff --git a/http/client/ssrf_test.go b/http/client/ssrf_test.go new file mode 100644 index 0000000000..26ee9cf71a --- /dev/null +++ b/http/client/ssrf_test.go @@ -0,0 +1,227 @@ +/* + * Copyright (C) 2026 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDenyNonPublicAddr(t *testing.T) { + setStrictMode := func(t *testing.T, v bool) { + old := StrictMode + StrictMode = v + t.Cleanup(func() { StrictMode = old }) + } + setAllowlist := func(t *testing.T, cidrs ...string) { + old := allowedNonPublicNets + require.NoError(t, SetAllowedNonPublicCIDRs(cidrs)) + t.Cleanup(func() { allowedNonPublicNets = old }) + } + t.Run("strict mode blocks non-public addresses", func(t *testing.T) { + setStrictMode(t, true) + blocked := map[string]string{ + "loopback IPv4": "127.0.0.1:443", + "loopback IPv6": "[::1]:443", + "private RFC1918 10/8": "10.0.0.5:443", + "private RFC1918 172.16/12": "172.16.0.1:443", + "private RFC1918 192.168/16": "192.168.1.1:443", + "unique local IPv6": "[fd00::1]:443", + "link-local IPv4": "169.254.169.254:443", + "link-local IPv6": "[fe80::1]:443", + "unspecified IPv4": "0.0.0.0:443", + "unspecified IPv6": "[::]:443", + "this-network 0/8": "0.1.2.3:443", + "CGNAT shared space": "100.64.1.1:443", + "IETF protocol assignments": "192.0.0.8:443", + "documentation TEST-NET-1": "192.0.2.1:443", + "documentation TEST-NET-2": "198.51.100.1:443", + "documentation TEST-NET-3": "203.0.113.1:443", + "benchmarking": "198.18.0.1:443", + "6to4 relay anycast": "192.88.99.1:443", + "multicast IPv4": "224.0.1.1:443", + "reserved 240/4": "240.0.0.1:443", + "broadcast": "255.255.255.255:443", + "v4-mapped IPv6 loopback": "[::ffff:127.0.0.1]:443", + "v4-mapped IPv6 private": "[::ffff:10.0.0.5]:443", + "NAT64 well-known prefix": "[64:ff9b::a00:1]:443", + "discard-only IPv6": "[100::1]:443", + "site-local IPv6 deprecated": "[fec0::1]:443", + "multicast IPv6": "[ff05::1]:443", + "6to4 embedding private": "[2002:a00:1::1]:443", + "Teredo": "[2001::1]:443", + "documentation IPv6": "[2001:db8::1]:443", + "documentation IPv6 3fff": "[3fff::1]:443", + "link-local IPv6 with zone": "[fe80::1%eth0]:443", + } + for name, address := range blocked { + t.Run(name, func(t *testing.T) { + err := denyNonPublicAddr("tcp", address, nil) + assert.ErrorContains(t, err, "blocked connection to non-public address") + }) + } + }) + t.Run("strict mode allows public addresses", func(t *testing.T) { + setStrictMode(t, true) + for _, address := range []string{"8.8.8.8:443", "93.184.216.34:443", "[2606:2800:220:1:248:1893:25c8:1946]:443"} { + t.Run(address, func(t *testing.T) { + assert.NoError(t, denyNonPublicAddr("tcp", address, nil)) + }) + } + }) + t.Run("strict mode allows non-public addresses that are in the allowlist", func(t *testing.T) { + setStrictMode(t, true) + setAllowlist(t, "10.0.0.0/8", "fd00::/8") + + assert.NoError(t, denyNonPublicAddr("tcp", "10.1.2.3:443", nil)) + assert.NoError(t, denyNonPublicAddr("tcp", "[fd00::1]:443", nil)) + // Non-public addresses outside the allowlisted ranges are still blocked. + assert.ErrorContains(t, denyNonPublicAddr("tcp", "192.168.1.1:443", nil), "blocked connection to non-public address") + assert.ErrorContains(t, denyNonPublicAddr("tcp", "127.0.0.1:443", nil), "blocked connection to non-public address") + }) + t.Run("non-strict mode allows non-public addresses", func(t *testing.T) { + setStrictMode(t, false) + assert.NoError(t, denyNonPublicAddr("tcp", "127.0.0.1:443", nil)) + assert.NoError(t, denyNonPublicAddr("tcp", "10.0.0.5:443", nil)) + }) +} + +func TestDeniedCIDRs(t *testing.T) { + setStrictMode := func(t *testing.T, v bool) { + old := StrictMode + StrictMode = v + t.Cleanup(func() { StrictMode = old }) + } + setDenylist := func(t *testing.T, cidrs ...string) { + old := deniedNets + require.NoError(t, SetDeniedCIDRs(cidrs)) + t.Cleanup(func() { deniedNets = old }) + } + setAllowlist := func(t *testing.T, cidrs ...string) { + old := allowedNonPublicNets + require.NoError(t, SetAllowedNonPublicCIDRs(cidrs)) + t.Cleanup(func() { allowedNonPublicNets = old }) + } + t.Run("strict mode blocks denied public addresses", func(t *testing.T) { + setStrictMode(t, true) + setDenylist(t, "8.8.8.0/24", "2001:4860:4860::/48") + + assert.ErrorContains(t, denyNonPublicAddr("tcp", "8.8.8.8:443", nil), "blocked connection to denied address") + assert.ErrorContains(t, denyNonPublicAddr("tcp", "[2001:4860:4860::8888]:443", nil), "blocked connection to denied address") + // Public addresses outside the denied ranges are still allowed. + assert.NoError(t, denyNonPublicAddr("tcp", "9.9.9.9:443", nil)) + }) + t.Run("denied wins over allowed", func(t *testing.T) { + setStrictMode(t, true) + setAllowlist(t, "10.0.0.0/8") + setDenylist(t, "10.5.0.0/16") + + assert.ErrorContains(t, denyNonPublicAddr("tcp", "10.5.1.1:443", nil), "blocked connection to denied address") + // The rest of the allowlisted range is still allowed. + assert.NoError(t, denyNonPublicAddr("tcp", "10.6.1.1:443", nil)) + }) + t.Run("cloud metadata endpoints are denied by default", func(t *testing.T) { + setStrictMode(t, true) + // Azure wire server / metadata endpoint: a public Microsoft-owned IP, so the + // non-public check does not cover it. + assert.ErrorContains(t, denyNonPublicAddr("tcp", "168.63.129.16:443", nil), "blocked connection to denied address") + // The other cloud metadata endpoints fall in already-blocked non-public ranges. + assert.Error(t, denyNonPublicAddr("tcp", "169.254.169.254:80", nil)) // AWS/GCP/OpenStack/Azure IMDS (link-local) + assert.Error(t, denyNonPublicAddr("tcp", "100.100.100.200:80", nil)) // Alibaba (CGNAT) + assert.Error(t, denyNonPublicAddr("tcp", "[fd00:ec2::254]:80", nil)) // AWS IMDS IPv6 (ULA) + }) + t.Run("the allowlist cannot re-admit cloud metadata endpoints", func(t *testing.T) { + setStrictMode(t, true) + setAllowlist(t, "168.63.129.16/32") + assert.ErrorContains(t, denyNonPublicAddr("tcp", "168.63.129.16:443", nil), "blocked connection to denied address") + }) + t.Run("non-strict mode ignores the denylist", func(t *testing.T) { + setStrictMode(t, false) + setDenylist(t, "8.8.8.0/24") + assert.NoError(t, denyNonPublicAddr("tcp", "8.8.8.8:443", nil)) + assert.NoError(t, denyNonPublicAddr("tcp", "168.63.129.16:443", nil)) + }) +} + +func TestSetDeniedCIDRs(t *testing.T) { + old := deniedNets + t.Cleanup(func() { deniedNets = old }) + builtins := len(deniedCloudMetadataPrefixes) + t.Run("valid appends to the built-in denylist", func(t *testing.T) { + require.NoError(t, SetDeniedCIDRs([]string{"192.0.32.0/24", "2001:500::/32"})) + assert.Len(t, deniedNets, builtins+2) + }) + t.Run("invalid leaves previous value unchanged", func(t *testing.T) { + require.NoError(t, SetDeniedCIDRs([]string{"192.0.32.0/24"})) + err := SetDeniedCIDRs([]string{"not-a-cidr"}) + assert.ErrorContains(t, err, "not-a-cidr") + assert.Len(t, deniedNets, builtins+1) + }) + t.Run("empty resets to the built-in denylist", func(t *testing.T) { + require.NoError(t, SetDeniedCIDRs([]string{"192.0.32.0/24"})) + require.NoError(t, SetDeniedCIDRs(nil)) + assert.Equal(t, deniedCloudMetadataPrefixes, deniedNets) + }) +} + +func TestSetAllowedNonPublicCIDRs(t *testing.T) { + old := allowedNonPublicNets + t.Cleanup(func() { allowedNonPublicNets = old }) + t.Run("valid", func(t *testing.T) { + require.NoError(t, SetAllowedNonPublicCIDRs([]string{"10.0.0.0/8", "fd00::/8"})) + assert.Len(t, allowedNonPublicNets, 2) + }) + t.Run("invalid leaves previous value unchanged", func(t *testing.T) { + require.NoError(t, SetAllowedNonPublicCIDRs([]string{"10.0.0.0/8"})) + err := SetAllowedNonPublicCIDRs([]string{"not-a-cidr"}) + assert.ErrorContains(t, err, "not-a-cidr") + assert.Len(t, allowedNonPublicNets, 1) + }) + t.Run("empty clears the allowlist", func(t *testing.T) { + require.NoError(t, SetAllowedNonPublicCIDRs([]string{"10.0.0.0/8"})) + require.NoError(t, SetAllowedNonPublicCIDRs(nil)) + assert.Empty(t, allowedNonPublicNets) + }) +} + +func TestSafeHttpTransport_SSRFDialGuard(t *testing.T) { + // httptest TLS server listens on a loopback address. + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + t.Run("strict mode blocks connection to loopback server before TLS handshake", func(t *testing.T) { + old := StrictMode + StrictMode = true + t.Cleanup(func() { StrictMode = old }) + + client := &http.Client{Transport: SafeHttpTransport, Timeout: time.Second} + req, _ := http.NewRequest("GET", server.URL, nil) + _, err := client.Do(req) + + require.Error(t, err) + assert.ErrorContains(t, err, "blocked connection to non-public address") + }) +} diff --git a/http/cmd/cmd.go b/http/cmd/cmd.go index e92e8fa7b9..97407622ae 100644 --- a/http/cmd/cmd.go +++ b/http/cmd/cmd.go @@ -44,6 +44,8 @@ func FlagSet() *pflag.FlagSet { flags.String("http.default.auth.audience", string(defs.Auth.Audience), "Expected audience for JWT tokens (default: hostname)") flags.String("http.default.auth.authorizedkeyspath", string(defs.Auth.AuthorizedKeysPath), "Path to an authorized_keys file for trusted JWT signers") flags.String("http.default.log", string(defs.Log), fmt.Sprintf("What to log about HTTP requests. Options are '%s', '%s' (log request method, URI, IP and response code), and '%s' (log the request and response body, in addition to the metadata).", http.LogNothingLevel, http.LogMetadataLevel, http.LogMetadataAndBodyLevel)) + flags.StringSlice("http.client.allowedinternalcidrs", defs.Client.AllowedInternalCIDRs, "IP ranges (CIDR notation, e.g. 10.0.0.0/8) exempted from the strict-mode SSRF guard, which otherwise blocks outbound requests to non-public networks. Use to permit internal flows that legitimately target a private address, such as an internal credential offering or an internal OAuth user flow. Leave empty to block all non-public addresses.") + flags.StringSlice("http.client.deniedcidrs", defs.Client.DeniedCIDRs, "IP ranges (CIDR notation) that outbound HTTP requests must never target in strict mode, in addition to the built-in blocked ranges (non-public addresses and cloud metadata endpoints). Use for publicly routable ranges that are internal-only in your infrastructure. Takes precedence over http.client.allowedinternalcidrs.") return flags } diff --git a/http/config.go b/http/config.go index f72a0569c2..2700060b17 100644 --- a/http/config.go +++ b/http/config.go @@ -36,6 +36,23 @@ type Config struct { // AltBinds contains binds for alternative HTTP interfaces. The key of the map is the first part of the path // of the URL (e.g. `/internal/some-api` -> `internal`), the value is the HTTP interface it must be bound to. AltBinds map[string]InterfaceConfig `koanf:"alt"` + // Client contains the configuration for outbound HTTP clients. + Client ClientConfig `koanf:"client"` +} + +// ClientConfig contains the configuration for outbound HTTP clients. +type ClientConfig struct { + // AllowedInternalCIDRs lists IP ranges in CIDR notation (e.g. "10.0.0.0/8") exempted from the + // strict-mode SSRF guard, which otherwise blocks outbound requests to non-public networks. Use + // to permit internal flows that legitimately target a private address, such as an internal + // credential offering or an internal OAuth user flow. Leave empty to block all non-public + // addresses. + AllowedInternalCIDRs []string `koanf:"allowedinternalcidrs"` + // DeniedCIDRs lists IP ranges in CIDR notation that outbound HTTP requests must never target + // in strict mode, in addition to the built-in blocked ranges (non-public addresses and cloud + // metadata endpoints). Use for publicly routable ranges that are internal-only in your + // infrastructure. Denied ranges take precedence over AllowedInternalCIDRs. + DeniedCIDRs []string `koanf:"deniedcidrs"` } // InterfaceConfig contains configuration for an HTTP interface, e.g. address. diff --git a/http/engine.go b/http/engine.go index 0572b952ef..c77bba4831 100644 --- a/http/engine.go +++ b/http/engine.go @@ -37,6 +37,7 @@ import ( "github.com/lestrrat-go/jwx/jwa" "github.com/nuts-foundation/nuts-node/core" cryptoEngine "github.com/nuts-foundation/nuts-node/crypto" + "github.com/nuts-foundation/nuts-node/http/client" "github.com/nuts-foundation/nuts-node/http/log" "github.com/nuts-foundation/nuts-node/http/tokenV2" ) @@ -63,6 +64,18 @@ type Engine struct { config Config } +// configureClient configures the outbound HTTP client settings (SSRF dial guard). +func (h *Engine) configureClient(serverConfig core.ServerConfig) error { + client.StrictMode = serverConfig.Strictmode + if err := client.SetAllowedNonPublicCIDRs(h.config.Client.AllowedInternalCIDRs); err != nil { + return err + } + if err := client.SetDeniedCIDRs(h.config.Client.DeniedCIDRs); err != nil { + return err + } + return nil +} + // Router returns the router of the HTTP engine, which can be used by other engines to register HTTP handlers. func (h Engine) Router() core.EchoRouter { return h.server @@ -70,6 +83,10 @@ func (h Engine) Router() core.EchoRouter { // Configure loads the configuration for the HTTP engine. func (h *Engine) Configure(serverConfig core.ServerConfig) error { + if err := h.configureClient(serverConfig); err != nil { + return err + } + // Override default Echo HTTP error when bearer token is expected but not provided. // Echo returns "Bad Request (400)" by default, but we use this for incorrect use of API parameters. // "Unauthorized (401)" is a better fit. diff --git a/http/engine_test.go b/http/engine_test.go index 1aabd0c8fc..73c2f94459 100644 --- a/http/engine_test.go +++ b/http/engine_test.go @@ -44,6 +44,7 @@ import ( "github.com/lestrrat-go/jwx/jwt" "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/crypto" + "github.com/nuts-foundation/nuts-node/http/client" "github.com/nuts-foundation/nuts-node/http/log" "github.com/nuts-foundation/nuts-node/test" "github.com/sirupsen/logrus" @@ -57,6 +58,58 @@ import ( func TestEngine_Configure(t *testing.T) { noop := func() {} + t.Run("client CIDR options", func(t *testing.T) { + resetGuardState := func(t *testing.T) { + oldStrictMode := client.StrictMode + t.Cleanup(func() { + client.StrictMode = oldStrictMode + require.NoError(t, client.SetAllowedNonPublicCIDRs(nil)) + require.NoError(t, client.SetDeniedCIDRs(nil)) + }) + } + newEngine := func() *Engine { + engine := New(noop, nil) + engine.config.InterfaceConfig.Address = fmt.Sprintf("localhost:%d", test.FreeTCPPort()) + return engine + } + t.Run("ok - propagated to the dial guard", func(t *testing.T) { + resetGuardState(t) + engine := newEngine() + engine.config.Client.AllowedInternalCIDRs = []string{"127.0.0.0/8"} + engine.config.Client.DeniedCIDRs = []string{"127.0.5.0/24"} + serverConfig := core.NewServerConfig() + serverConfig.Strictmode = true + require.NoError(t, engine.Configure(*serverConfig)) + + httpClient := &http.Client{Transport: client.SafeHttpTransport, Timeout: time.Second} + // Denied range wins over the allowlist. + deniedReq, _ := http.NewRequest("GET", "https://127.0.5.1:1", nil) + _, err := httpClient.Do(deniedReq) + require.Error(t, err) + assert.ErrorContains(t, err, "blocked connection to denied address") + // Allowlisted loopback address passes the guard (fails with an ordinary connection + // error instead, since nothing listens on the port). + allowedReq, _ := http.NewRequest("GET", "https://127.0.6.1:1", nil) + _, err = httpClient.Do(allowedReq) + require.Error(t, err) + assert.NotContains(t, err.Error(), "blocked connection to") + }) + t.Run("error - invalid allowedinternalcidrs fails startup", func(t *testing.T) { + resetGuardState(t) + engine := newEngine() + engine.config.Client.AllowedInternalCIDRs = []string{"not-a-cidr"} + err := engine.Configure(*core.NewServerConfig()) + assert.ErrorContains(t, err, `invalid CIDR "not-a-cidr"`) + }) + t.Run("error - invalid deniedcidrs fails startup", func(t *testing.T) { + resetGuardState(t) + engine := newEngine() + engine.config.Client.DeniedCIDRs = []string{"also-not-a-cidr"} + err := engine.Configure(*core.NewServerConfig()) + assert.ErrorContains(t, err, `invalid CIDR "also-not-a-cidr"`) + }) + }) + t.Run("ok, no TLS (default)", func(t *testing.T) { engine := New(noop, nil) engine.config.InterfaceConfig.Address = fmt.Sprintf("localhost:%d", test.FreeTCPPort()) diff --git a/vcr/openid4vci/identifiers.go b/vcr/openid4vci/identifiers.go index c2457e76ce..1abf15ab4a 100644 --- a/vcr/openid4vci/identifiers.go +++ b/vcr/openid4vci/identifiers.go @@ -23,6 +23,8 @@ import ( "fmt" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/core" + httpclient "github.com/nuts-foundation/nuts-node/http/client" + "github.com/nuts-foundation/nuts-node/vcr/log" "github.com/nuts-foundation/nuts-node/vdr/didservice" "github.com/nuts-foundation/nuts-node/vdr/types" @@ -144,7 +146,7 @@ func (t tlsIdentifierResolver) resolveFromCertificate(id did.DID) (string, error } // Resolve URLs - httpTransport := http.DefaultTransport.(*http.Transport).Clone() + httpTransport := httpclient.SafeHttpTransport.Clone() httpTransport.TLSClientConfig = t.config httpClient := &http.Client{ Timeout: 5 * time.Second, diff --git a/vcr/test/openid4vci_integration_test.go b/vcr/test/openid4vci_integration_test.go index 20bf132006..6352a968b1 100644 --- a/vcr/test/openid4vci_integration_test.go +++ b/vcr/test/openid4vci_integration_test.go @@ -23,6 +23,7 @@ import ( "encoding/json" "github.com/nuts-foundation/nuts-node/core" httpModule "github.com/nuts-foundation/nuts-node/http" + httpclient "github.com/nuts-foundation/nuts-node/http/client" "github.com/nuts-foundation/nuts-node/network/log" "github.com/nuts-foundation/nuts-node/vcr/openid4vci" "github.com/stretchr/testify/assert" @@ -88,6 +89,10 @@ func TestOpenID4VCIConnectionReuse(t *testing.T) { // so we expect max maxConnsPerHost*2*2 connections in total. const maxExpectedConnCount = maxConnsPerHost * 2 * 2 http.DefaultTransport.(*http.Transport).MaxConnsPerHost = maxConnsPerHost + // The OpenID4VCI issuer/wallet clients clone their transport from client.SafeHttpTransport + // (which carries the strict-mode SSRF dial guard). It was cloned from http.DefaultTransport + // at package init, so the line above does not reach it; set the limit there as well. + httpclient.SafeHttpTransport.MaxConnsPerHost = maxConnsPerHost ctx := audit.TestContext() baseURL, system := node.StartServer(t) diff --git a/vcr/vcr.go b/vcr/vcr.go index a9f9989aa8..c8e95582a7 100644 --- a/vcr/vcr.go +++ b/vcr/vcr.go @@ -41,7 +41,9 @@ import ( "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/crypto" "github.com/nuts-foundation/nuts-node/events" + httpclient "github.com/nuts-foundation/nuts-node/http/client" "github.com/nuts-foundation/nuts-node/jsonld" + "github.com/nuts-foundation/nuts-node/network" "github.com/nuts-foundation/nuts-node/storage" "github.com/nuts-foundation/nuts-node/vcr/assets" @@ -221,13 +223,13 @@ func (c *vcr) Configure(config core.ServerConfig) error { // meaning while the issuer allocated an HTTP connection the wallet will try to allocate one as well. // This moved back to 1 http.Client when the credential is requested asynchronously. // Should be fixed as part of https://github.com/nuts-foundation/nuts-node/issues/2039 - issuerTransport := http.DefaultTransport.(*http.Transport).Clone() + issuerTransport := httpclient.SafeHttpTransport.Clone() issuerTransport.TLSClientConfig = tlsConfig c.issuerHttpClient = core.NewStrictHTTPClient(config.Strictmode, &http.Client{ Timeout: c.config.OpenID4VCI.Timeout, Transport: issuerTransport, }) - walletTransport := http.DefaultTransport.(*http.Transport).Clone() + walletTransport := httpclient.SafeHttpTransport.Clone() walletTransport.TLSClientConfig = tlsConfig c.walletHttpClient = core.NewStrictHTTPClient(config.Strictmode, &http.Client{ Timeout: c.config.OpenID4VCI.Timeout,