From 810d0f61cb75c5e45f82f3f17ec1c5c3dae187c7 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Sat, 25 Jul 2026 14:50:50 +0000 Subject: [PATCH 1/3] fix(http): block SSRF to non-public addresses in strict mode (#4420) Fixes potential SSRF vulnerability by preventing the internal http client of connecting to a curated IANA list of prohibited (internal) endpoints/ip ranges. This check is executed at the transport layer just after IP lookup so we don't rely on DNS. An administrator has the config options to allow or deny additional ip ranges. See #4420 for additional info. Assisted-by: AI --- docs/pages/deployment/server_options.rst | 132 ++++----- docs/pages/release_notes.rst | 7 + .../issuer-initiated/docker-compose.yml | 2 +- .../issuer-initiated/node-A/nuts.yaml | 7 + .../issuer-initiated/node-B/nuts.yaml | 7 + .../network-issuance/docker-compose.yml | 2 +- .../network-issuance/node-A/nuts.yaml | 7 + .../network-issuance/node-B/nuts.yaml | 7 + go.mod | 1 + go.sum | 2 + http/client/client.go | 45 ++- http/client/client_test.go | 61 ++++ http/client/ssrf.go | 207 ++++++++++++++ http/client/ssrf_test.go | 263 ++++++++++++++++++ http/cmd/cmd.go | 2 + http/config.go | 16 ++ http/engine.go | 13 +- http/engine_test.go | 57 ++++ vcr/vcr_test.go | 2 + 19 files changed, 767 insertions(+), 73 deletions(-) create mode 100644 http/client/ssrf.go create mode 100644 http/client/ssrf_test.go diff --git a/docs/pages/deployment/server_options.rst b/docs/pages/deployment/server_options.rst index 4863fce1bf..cfb553e8fc 100755 --- a/docs/pages/deployment/server_options.rst +++ b/docs/pages/deployment/server_options.rst @@ -2,66 +2,72 @@ :widths: 20 30 50 :class: options-table - ======================================== =================================================================================================================================================================================================================================================================================================================================================================================================================================================================== ============================================================================================================================================================================================================================================================================================================================================ - Key Default Description - ======================================== =================================================================================================================================================================================================================================================================================================================================================================================================================================================================== ============================================================================================================================================================================================================================================================================================================================================ - configfile ./config/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. - didmethods [web,nuts] Comma-separated list of enabled DID methods (without did: prefix). It also controls the order in which DIDs are returned by APIs, and which DID is used for signing if the verifying party does not impose restrictions on the DID method used. - 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. - url Public facing URL of the server (required). Must be HTTPS when strictmode is set. - verbosity info Log level (trace, debug, info, warn, error) - httpclient.timeout 30s Request time-out for HTTP clients, such as '10s'. Refer to Golang's 'time.Duration' syntax for a more elaborate description of the syntax. - **Auth** - auth.authorizationendpoint.enabled false enables the v2 API's OAuth2 Authorization Endpoint, used by OpenID4VP and OpenID4VCI. This flag might be removed in a future version (or its default become 'true') as the use cases and implementation of OpenID4VP and OpenID4VCI mature. - **Crypto** - crypto.storage Storage to use, 'fs' for file system (for development purposes), 'vaultkv' for HashiCorp Vault KV store, 'azure-keyvault' for Azure Key Vault, 'external' for an external backend (deprecated). - crypto.azurekv.hsm false Whether to store the key in a hardware security module (HSM). If true, the Azure Key Vault must be configured for HSM usage. Default: false - crypto.azurekv.timeout 10s Timeout of client calls to Azure Key Vault, in Golang time.Duration string format (e.g. 10s). - crypto.azurekv.url The URL of the Azure Key Vault. - crypto.azurekv.auth.type default Credential type to use when authenticating to the Azure Key Vault. Options: default, managed_identity (see https://github.com/Azure/azure-sdk-for-go/blob/main/sdk/azidentity/README.md for an explanation of the options). - 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. - **Discovery** - discovery.client.refreshinterval 10m0s Interval at which the client synchronizes with the Discovery Server; refreshing Verifiable Presentations of local DIDs and loading changes, updating the local copy. It only will actually refresh registrations of local DIDs that about to expire (less than 1/4th of their lifetime left). Specified as Golang duration (e.g. 1m, 1h30m). - discovery.definitions.directory ./config/discovery Directory to load Discovery Service Definitions from. If not set, the discovery service will be disabled. If the directory contains JSON files that can't be parsed as service definition, the node will fail to start. - discovery.server.ids [] IDs of the Discovery Service for which to act as server. If an ID does not map to a loaded service definition, the node will fail to start. - **HTTP** - http.clientipheader X-Forwarded-For Case-sensitive HTTP Header that contains the client IP used for audit logs. For the X-Forwarded-For header only link-local, loopback, and private IPs are excluded. Switch to X-Real-IP or a custom header if you see your own proxy/infra in the logs. - http.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). When debug vebosity is set the authorization headers are also logged when the request is fully logged. - http.cache.maxbytes 10485760 HTTP client maximum size of the response cache in bytes. If 0, the HTTP client does not cache responses. - http.internal.address 127.0.0.1:8081 Address and port the server will be listening to for internal-facing endpoints. - http.internal.auth.audience Expected audience for JWT tokens (default: hostname) - http.internal.auth.authorizedkeyspath Path to an authorized_keys file for trusted JWT signers - http.internal.auth.type Whether to enable authentication for /internal endpoints, specify 'token_v2' for bearer token mode or 'token' for legacy bearer token mode. - http.public.address \:8080 Address and port the server will be listening to for public-facing endpoints. - **JSONLD** - jsonld.contexts.localmapping [https://nuts.nl/credentials/2024=assets/contexts/nuts-2024.ldjson,https://nuts.nl/credentials/v1=assets/contexts/nuts.ldjson,https://schema.org=assets/contexts/schema-org-v13.ldjson,https://w3c-ccg.github.io/lds-jws2020/contexts/lds-jws2020-v1.json=assets/contexts/lds-jws2020-v1.ldjson,https://w3id.org/vc/status-list/2021/v1=assets/contexts/w3c-statuslist2021.ldjson,https://www.w3.org/2018/credentials/v1=assets/contexts/w3c-credentials-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,https://w3id.org/vc/status-list/2021/v1] In strict mode, fetching external JSON-LD contexts is not allowed except for context-URLs listed here. - **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.session.memcached.address [] List of Memcached server addresses. These can be a simple 'host:port' or a Memcached connection URL with scheme, auth and other options. - storage.session.redis.address Redis session database server address. This can be a simple 'host:port' or a Redis connection URL with scheme, auth and other options. If not set it, defaults to an in-memory database. - storage.session.redis.database Redis session database name, which is used as prefix every key. Can be used to have multiple instances use the same Redis instance. - storage.session.redis.password Redis session database password. If set, it overrides the username in the connection URL. - storage.session.redis.username Redis session database username. If set, it overrides the username in the connection URL. - storage.session.redis.sentinel.master Name of the Redis Sentinel master. Setting this property enables Redis Sentinel. - storage.session.redis.sentinel.nodes [] Addresses of the Redis Sentinels to connect to initially. Setting this property enables Redis Sentinel. - storage.session.redis.sentinel.password Password for authenticating to Redis Sentinels. - storage.session.redis.sentinel.username Username for authenticating to Redis Sentinels. - storage.session.redis.tls.truststorefile PEM file containing the trusted CA certificate(s) for authenticating remote Redis session servers. Can only be used when connecting over TLS (use 'rediss://' as scheme in address). - storage.sql.connection Connection string for the SQL database. If not set it, defaults to a SQLite database stored inside the configured data directory. Note: using SQLite is not recommended in production environments. If using SQLite anyways, remember to enable foreign keys ('_foreign_keys=on') and the write-ahead-log ('_journal_mode=WAL'). - **Tracing** - tracing.endpoint OTLP collector endpoint for OpenTelemetry tracing (e.g., 'localhost:4318'). When empty, tracing is disabled. - tracing.insecure false Disable TLS for the OTLP connection. - tracing.servicename Service name reported to the tracing backend. Defaults to 'nuts-node'. - **policy** - policy.directory ./config/policy Directory to read policy files from. Policy files are JSON files that contain a scope to PresentationDefinition mapping. - ======================================== =================================================================================================================================================================================================================================================================================================================================================================================================================================================================== ============================================================================================================================================================================================================================================================================================================================================ + ======================================== =================================================================================================================================================================================================================================================================================================================================================================================================================================================================== ===================================================================================================================================================================================================================================================================================================================================================== + Key Default Description + ======================================== =================================================================================================================================================================================================================================================================================================================================================================================================================================================================== ===================================================================================================================================================================================================================================================================================================================================================== + configfile ./config/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. + didmethods [web,nuts] Comma-separated list of enabled DID methods (without did: prefix). It also controls the order in which DIDs are returned by APIs, and which DID is used for signing if the verifying party does not impose restrictions on the DID method used. + 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. + url Public facing URL of the server (required). Must be HTTPS when strictmode is set. + verbosity info Log level (trace, debug, info, warn, error) + httpclient.timeout 30s Request time-out for HTTP clients, such as '10s'. Refer to Golang's 'time.Duration' syntax for a more elaborate description of the syntax. + **Auth** + auth.authorizationendpoint.enabled false enables the v2 API's OAuth2 Authorization Endpoint, used by OpenID4VP and OpenID4VCI. This flag might be removed in a future version (or its default become 'true') as the use cases and implementation of OpenID4VP and OpenID4VCI mature. + **Crypto** + crypto.storage Storage to use, 'fs' for file system (for development purposes), 'vaultkv' for HashiCorp Vault KV store, 'azure-keyvault' for Azure Key Vault, 'external' for an external backend (deprecated). + crypto.azurekv.hsm false Whether to store the key in a hardware security module (HSM). If true, the Azure Key Vault must be configured for HSM usage. Default: false + crypto.azurekv.timeout 10s Timeout of client calls to Azure Key Vault, in Golang time.Duration string format (e.g. 10s). + crypto.azurekv.url The URL of the Azure Key Vault. + crypto.azurekv.auth.type default Credential type to use when authenticating to the Azure Key Vault. Options: default, managed_identity (see https://github.com/Azure/azure-sdk-for-go/blob/main/sdk/azidentity/README.md for an explanation of the options). + 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. + **Discovery** + discovery.client.refreshinterval 10m0s Interval at which the client synchronizes with the Discovery Server; refreshing Verifiable Presentations of local DIDs and loading changes, updating the local copy. It only will actually refresh registrations of local DIDs that about to expire (less than 1/4th of their lifetime left). Specified as Golang duration (e.g. 1m, 1h30m). + discovery.definitions.directory ./config/discovery Directory to load Discovery Service Definitions from. If not set, the discovery service will be disabled. If the directory contains JSON files that can't be parsed as service definition, the node will fail to start. + discovery.server.ids [] IDs of the Discovery Service for which to act as server. If an ID does not map to a loaded service definition, the node will fail to start. + **HTTP** + http.clientipheader X-Forwarded-For Case-sensitive HTTP Header that contains the client IP used for audit logs. For the X-Forwarded-For header only link-local, loopback, and private IPs are excluded. Switch to X-Real-IP or a custom header if you see your own proxy/infra in the logs. + http.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). When debug vebosity is set the authorization headers are also logged when the request is fully logged. + http.cache.maxbytes 10485760 HTTP client maximum size of the response cache in bytes. If 0, the HTTP client does not cache responses. + 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.internal.address 127.0.0.1:8081 Address and port the server will be listening to for internal-facing endpoints. + http.internal.auth.audience Expected audience for JWT tokens (default: hostname) + http.internal.auth.authorizedkeyspath Path to an authorized_keys file for trusted JWT signers + http.internal.auth.type Whether to enable authentication for /internal endpoints, specify 'token_v2' for bearer token mode or 'token' for legacy bearer token mode. + http.public.address \:8080 Address and port the server will be listening to for public-facing endpoints. + **JSONLD** + jsonld.contexts.localmapping [https://nuts.nl/credentials/2024=assets/contexts/nuts-2024.ldjson,https://nuts.nl/credentials/v1=assets/contexts/nuts.ldjson,https://schema.org=assets/contexts/schema-org-v13.ldjson,https://w3c-ccg.github.io/lds-jws2020/contexts/lds-jws2020-v1.json=assets/contexts/lds-jws2020-v1.ldjson,https://w3id.org/vc/status-list/2021/v1=assets/contexts/w3c-statuslist2021.ldjson,https://www.w3.org/2018/credentials/v1=assets/contexts/w3c-credentials-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,https://w3id.org/vc/status-list/2021/v1] In strict mode, fetching external JSON-LD contexts is not allowed except for context-URLs listed here. + **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.session.memcached.address [] List of Memcached server addresses. These can be a simple 'host:port' or a Memcached connection URL with scheme, auth and other options. + storage.session.redis.address Redis session database server address. This can be a simple 'host:port' or a Redis connection URL with scheme, auth and other options. If not set it, defaults to an in-memory database. + storage.session.redis.database Redis session database name, which is used as prefix every key. Can be used to have multiple instances use the same Redis instance. + storage.session.redis.password Redis session database password. If set, it overrides the username in the connection URL. + storage.session.redis.username Redis session database username. If set, it overrides the username in the connection URL. + storage.session.redis.sentinel.master Name of the Redis Sentinel master. Setting this property enables Redis Sentinel. + storage.session.redis.sentinel.nodes [] Addresses of the Redis Sentinels to connect to initially. Setting this property enables Redis Sentinel. + storage.session.redis.sentinel.password Password for authenticating to Redis Sentinels. + storage.session.redis.sentinel.username Username for authenticating to Redis Sentinels. + storage.session.redis.tls.truststorefile PEM file containing the trusted CA certificate(s) for authenticating remote Redis session servers. Can only be used when connecting over TLS (use 'rediss://' as scheme in address). + storage.sql.connection Connection string for the SQL database. If not set it, defaults to a SQLite database stored inside the configured data directory. Note: using SQLite is not recommended in production environments. If using SQLite anyways, remember to enable foreign keys ('_foreign_keys=on') and the write-ahead-log ('_journal_mode=WAL'). + storage.sql.rdsiam.dbuser Database username for IAM authentication. If not specified, the username from the connection string will be used. The database user must be created with IAM authentication enabled. + storage.sql.rdsiam.enabled false Enable AWS RDS IAM authentication for the SQL database connection. When enabled, the node will use temporary IAM tokens instead of passwords. Requires the connection string to be a PostgreSQL or MySQL RDS endpoint without a password. + storage.sql.rdsiam.region AWS region where the RDS instance is located (e.g., 'us-east-1). Required when RDS IAM authentication is enabled. + storage.sql.rdsiam.tokenrefreshinterval 14m0s Interval at which to refresh the IAM authentication token. RDS tokens are valid for 15 minutes, so set this to ensure tokens are refreshed before expiry. Specified as Golang duration (e.g. 10m, 1h). + **Tracing** + tracing.endpoint OTLP collector endpoint for OpenTelemetry tracing (e.g., 'localhost:4318'). When empty, tracing is disabled. + tracing.insecure false Disable TLS for the OTLP connection. + tracing.servicename Service name reported to the tracing backend. Defaults to 'nuts-node'. + **policy** + policy.directory ./config/policy Directory to read policy files from. Policy files are JSON files that contain a scope to PresentationDefinition mapping. + ======================================== =================================================================================================================================================================================================================================================================================================================================================================================================================================================================== ===================================================================================================================================================================================================================================================================================================================================================== diff --git a/docs/pages/release_notes.rst b/docs/pages/release_notes.rst index 5223c0b9d9..ce072f4490 100644 --- a/docs/pages/release_notes.rst +++ b/docs/pages/release_notes.rst @@ -2,6 +2,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. Reported by @raysabee, fixed by @stevenvegt in https://github.com/nuts-foundation/nuts-node/pull/4420 + ***************** Peanut (v6.2.10) ***************** diff --git a/e2e-tests/openid4vci/issuer-initiated/docker-compose.yml b/e2e-tests/openid4vci/issuer-initiated/docker-compose.yml index d828dd09bb..f5b4b3edb7 100644 --- a/e2e-tests/openid4vci/issuer-initiated/docker-compose.yml +++ b/e2e-tests/openid4vci/issuer-initiated/docker-compose.yml @@ -44,4 +44,4 @@ services: - "../../shared_config/nodeB-http-nginx.conf:/etc/nginx/conf.d/nuts-http.conf:ro" - "../../tls-certs/nodeB-certificate.pem:/etc/nginx/ssl/server.pem:ro" - "../../tls-certs/nodeB-certificate.pem:/etc/nginx/ssl/key.pem:ro" - - "../../tls-certs/truststore.pem:/etc/nginx/ssl/truststore.pem:ro" \ No newline at end of file + - "../../tls-certs/truststore.pem:/etc/nginx/ssl/truststore.pem:ro" diff --git a/e2e-tests/openid4vci/issuer-initiated/node-A/nuts.yaml b/e2e-tests/openid4vci/issuer-initiated/node-A/nuts.yaml index 467ba49a0a..ce1fe2d3e0 100644 --- a/e2e-tests/openid4vci/issuer-initiated/node-A/nuts.yaml +++ b/e2e-tests/openid4vci/issuer-initiated/node-A/nuts.yaml @@ -5,6 +5,13 @@ internalratelimiter: false http: internal: address: :8081 + 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: contractvalidators: - dummy diff --git a/e2e-tests/openid4vci/issuer-initiated/node-B/nuts.yaml b/e2e-tests/openid4vci/issuer-initiated/node-B/nuts.yaml index b0acca9808..c1b72916ac 100644 --- a/e2e-tests/openid4vci/issuer-initiated/node-B/nuts.yaml +++ b/e2e-tests/openid4vci/issuer-initiated/node-B/nuts.yaml @@ -5,6 +5,13 @@ internalratelimiter: false http: internal: address: :8081 + 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: contractvalidators: - dummy diff --git a/e2e-tests/openid4vci/network-issuance/docker-compose.yml b/e2e-tests/openid4vci/network-issuance/docker-compose.yml index 51346390ef..a7a8f887fd 100644 --- a/e2e-tests/openid4vci/network-issuance/docker-compose.yml +++ b/e2e-tests/openid4vci/network-issuance/docker-compose.yml @@ -53,4 +53,4 @@ services: - "../../tls-certs/nodeB-certificate.pem:/etc/nginx/ssl/key.pem:ro" - "../../tls-certs/truststore.pem:/etc/nginx/ssl/truststore.pem:ro" depends_on: - - nodeB-backend \ No newline at end of file + - nodeB-backend diff --git a/e2e-tests/openid4vci/network-issuance/node-A/nuts.yaml b/e2e-tests/openid4vci/network-issuance/node-A/nuts.yaml index 65840f71c0..21876b7ced 100644 --- a/e2e-tests/openid4vci/network-issuance/node-A/nuts.yaml +++ b/e2e-tests/openid4vci/network-issuance/node-A/nuts.yaml @@ -6,6 +6,13 @@ datadir: /opt/nuts/data http: internal: address: :8081 + 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: contractvalidators: - dummy diff --git a/e2e-tests/openid4vci/network-issuance/node-B/nuts.yaml b/e2e-tests/openid4vci/network-issuance/node-B/nuts.yaml index 42a4e5b9b2..1c85067c7b 100644 --- a/e2e-tests/openid4vci/network-issuance/node-B/nuts.yaml +++ b/e2e-tests/openid4vci/network-issuance/node-B/nuts.yaml @@ -6,6 +6,13 @@ datadir: /opt/nuts/data http: internal: address: :8081 + 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: contractvalidators: - dummy diff --git a/go.mod b/go.mod index d442dbc6c9..553387cde9 100644 --- a/go.mod +++ b/go.mod @@ -199,6 +199,7 @@ require ( ) require ( + code.dny.dev/ssrf v0.3.0 github.com/aws/aws-sdk-go-v2 v1.41.1 github.com/aws/aws-sdk-go-v2/config v1.32.7 github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.17 diff --git a/go.sum b/go.sum index d3ab1a2601..414d88730a 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +code.dny.dev/ssrf v0.3.0 h1:QDbeKauc3vR5/w9TwlhVYAQwK15cECL7sHkCUUT55ms= +code.dny.dev/ssrf v0.3.0/go.mod h1:5thKUcQNqSxzz5TVBRkvHZOGmfRM3vI+WU2GGx9xUbw= filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw= filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q= diff --git a/http/client/client.go b/http/client/client.go index 2c9e4308df..46ce1151d2 100644 --- a/http/client/client.go +++ b/http/client/client.go @@ -24,6 +24,7 @@ import ( "errors" "fmt" "io" + "net" "net/http" "time" @@ -33,6 +34,10 @@ import ( ) // SafeHttpTransport is a http.Transport that can be used as a default transport for HTTP clients. +// It carries the strict-mode SSRF dial guard (see denyNonPublicAddr), but only that: the HTTPS +// requirement, the redirect-downgrade check and the response size limit live on StrictHTTPClient. +// Do not build a raw http.Client on this transport for outbound requests; use the New* constructors +// so all strict-mode protections apply. var SafeHttpTransport *http.Transport func init() { @@ -43,6 +48,14 @@ func init() { SafeHttpTransport.TLSClientConfig.MinVersion = tls.VersionTLS12 // to prevent slow responses from public clients to have significant impact (default was unlimited) SafeHttpTransport.MaxConnsPerHost = 5 + // guard against SSRF: in strict mode, refuse to connect to loopback/link-local/ + // unspecified 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 // set DefaultCachingTransport to SafeHttpTransport so it is set even when caching is disabled DefaultCachingTransport = SafeHttpTransport } @@ -55,6 +68,23 @@ func httpSpanName(_ string, r *http.Request) string { // StrictMode is a flag that can be set to true to enable strict mode for the HTTP client. var StrictMode bool +// checkRedirect is the http.Client.CheckRedirect policy for the strict HTTP client. +// Setting CheckRedirect replaces the standard library's default policy, so the +// 10-redirect cap is reimplemented here. In strict mode it also refuses to follow +// a redirect to a non-HTTPS target: the HTTPS check in Do only guards the first +// hop, and the dialer guard sees only the resolved IP and not the scheme, so +// without this a valid remote host could redirect the client onto a plaintext +// internal endpoint. +func checkRedirect(req *http.Request, via []*http.Request) error { + if len(via) >= 10 { + return errors.New("stopped after 10 redirects") + } + if StrictMode && req.URL.Scheme != "https" { + return errors.New("strictmode is enabled, but redirect target is not over HTTPS") + } + return nil +} + // DefaultMaxHttpResponseSize is a default maximum size of an HTTP response body that will be read. // Very large or unbounded HTTP responses can cause denial-of-service, so it's good to limit how much data is read. // This of course heavily depends on the use case, but 1MB is a reasonable default. @@ -75,8 +105,9 @@ func New(timeout time.Duration) *StrictHTTPClient { transport := getTransport(SafeHttpTransport) return &StrictHTTPClient{ client: &http.Client{ - Transport: transport, - Timeout: timeout, + Transport: transport, + Timeout: timeout, + CheckRedirect: checkRedirect, }, } } @@ -98,8 +129,9 @@ func NewWithCache(timeout time.Duration) *StrictHTTPClient { transport := getTransport(DefaultCachingTransport) return &StrictHTTPClient{ client: &http.Client{ - Transport: transport, - Timeout: timeout, + Transport: transport, + Timeout: timeout, + CheckRedirect: checkRedirect, }, } } @@ -112,8 +144,9 @@ func NewWithTLSConfig(timeout time.Duration, tlsConfig *tls.Config) *StrictHTTPC transport.TLSClientConfig = tlsConfig return &StrictHTTPClient{ client: &http.Client{ - Transport: getTransport(transport), - Timeout: timeout, + Transport: getTransport(transport), + Timeout: timeout, + CheckRedirect: checkRedirect, }, } } diff --git a/http/client/client_test.go b/http/client/client_test.go index 1a1b01366c..76c5665c75 100644 --- a/http/client/client_test.go +++ b/http/client/client_test.go @@ -35,6 +35,8 @@ import ( ) func TestStrictHTTPClient(t *testing.T) { + oldStrictMode := StrictMode + t.Cleanup(func() { StrictMode = oldStrictMode }) t.Run("caching transport", func(t *testing.T) { t.Run("strict mode enabled", func(t *testing.T) { rt := &stubRoundTripper{} @@ -99,6 +101,65 @@ func TestStrictHTTPClient(t *testing.T) { }) } +func TestStrictHTTPClient_RedirectScheme(t *testing.T) { + original := tracing.Enabled() + tracing.SetEnabled(false) // ensure the constructed client uses the raw transport + t.Cleanup(func() { tracing.SetEnabled(original) }) + + // httptest servers bind to loopback, which the strict-mode dial guard blocks. Permit loopback + // through the allowlist rather than bypassing the guard, so the guard stays active and the + // redirect scheme check is what must block the plaintext hop. + oldAllow := allowedNonPublicNets + require.NoError(t, SetAllowedNonPublicCIDRs([]string{"127.0.0.0/8", "::1/128"})) + t.Cleanup(func() { allowedNonPublicNets = oldAllow }) + + // Plaintext HTTP endpoint the redirect points to. + var reached atomic.Bool + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + reached.Store(true) + w.WriteHeader(http.StatusOK) + })) + defer target.Close() + + // Valid HTTPS remote that redirects the client onto the plaintext endpoint. + redirector := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, target.URL, http.StatusFound) + })) + defer redirector.Close() + + // Trust the test TLS server via NewWithTLSConfig, a real production constructor. + tlsConfig := redirector.Client().Transport.(*http.Transport).TLSClientConfig + + setStrictMode := func(t *testing.T, v bool) { + old := StrictMode + StrictMode = v + t.Cleanup(func() { StrictMode = old }) + } + + t.Run("strict mode refuses redirect from HTTPS to HTTP", func(t *testing.T) { + setStrictMode(t, true) + reached.Store(false) + + client := NewWithTLSConfig(time.Second, tlsConfig) + req, _ := http.NewRequest("GET", redirector.URL, nil) + _, err := client.Do(req) + + assert.Error(t, err, "strict mode must not follow a redirect from HTTPS to HTTP") + assert.False(t, reached.Load(), "the plaintext endpoint must not be contacted") + }) + t.Run("non-strict mode follows redirect to HTTP", func(t *testing.T) { + setStrictMode(t, false) + reached.Store(false) + + client := NewWithTLSConfig(time.Second, tlsConfig) + req, _ := http.NewRequest("GET", redirector.URL, nil) + _, err := client.Do(req) + + assert.NoError(t, err) + assert.True(t, reached.Load(), "non-strict mode should follow the redirect") + }) +} + func TestLimitedReadAll(t *testing.T) { t.Run("less than limit", func(t *testing.T) { data := strings.Repeat("a", 10) 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..188bccdfc8 --- /dev/null +++ b/http/client/ssrf_test.go @@ -0,0 +1,263 @@ +/* + * 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/http" + "net/http/httptest" + "net/netip" + "slices" + "testing" + "time" + + "code.dny.dev/ssrf" + "github.com/nuts-foundation/nuts-node/tracing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestPrefixesMatchUpstream cross-checks our prefix sets against code.dny.dev/ssrf (test-only +// dependency), which regenerates them from the IANA special-purpose registries. A failure after a +// dependency bump means the registries changed and our lists need updating. +func TestPrefixesMatchUpstream(t *testing.T) { + asStrings := func(prefixes []netip.Prefix) []string { + result := make([]string, 0, len(prefixes)) + for _, prefix := range prefixes { + result = append(result, prefix.String()) + } + slices.Sort(result) + return result + } + outdated := func(list, registry string) string { + return fmt.Sprintf("%s in ssrf.go is outdated: the IANA special-purpose registry changed "+ + "(see https://www.iana.org/assignments/%s/). Update it to match code.dny.dev/ssrf", list, registry) + } + assert.Equal(t, asStrings(ssrf.IPv4DeniedPrefixes), asStrings(deniedIPv4Prefixes), + outdated("deniedIPv4Prefixes", "iana-ipv4-special-registry")) + assert.Equal(t, asStrings(ssrf.IPv6DeniedPrefixes), asStrings(deniedIPv6Prefixes), + outdated("deniedIPv6Prefixes", "iana-ipv6-special-registry")) + assert.Equal(t, ssrf.IPv6GlobalUnicast, globalUnicastIPv6Prefix, + outdated("globalUnicastIPv6Prefix", "iana-ipv6-special-registry")) + // The NAT64 prefix lies outside global unicast, so the guard blocks it by construction. + assert.False(t, globalUnicastIPv6Prefix.Contains(ssrf.IPv6NAT64Prefix.Addr()), + "the NAT64 well-known prefix moved inside global unicast; block it explicitly in ssrf.go") +} + +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) { + original := tracing.Enabled() + tracing.SetEnabled(false) // ensure New() returns SafeHttpTransport directly + t.Cleanup(func() { tracing.SetEnabled(original) }) + + // 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 := New(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 1721738ebc..3700e5d3b1 100644 --- a/http/cmd/cmd.go +++ b/http/cmd/cmd.go @@ -37,6 +37,8 @@ func FlagSet() *pflag.FlagSet { flags.String("http.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). When debug vebosity is set the authorization headers are also logged when the request is fully logged.", http.LogNothingLevel, http.LogMetadataLevel, http.LogMetadataAndBodyLevel)) flags.String("http.clientipheader", defs.ClientIPHeaderName, "Case-sensitive HTTP Header that contains the client IP used for audit logs. For the X-Forwarded-For header only link-local, loopback, and private IPs are excluded. Switch to X-Real-IP or a custom header if you see your own proxy/infra in the logs.") flags.Int("http.cache.maxbytes", defs.ResponseCacheSize, "HTTP client maximum size of the response cache in bytes. If 0, the HTTP client does not cache responses.") + 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 35cd5d7477..8b4657c1f7 100644 --- a/http/config.go +++ b/http/config.go @@ -39,11 +39,27 @@ type Config struct { Log LogLevel `koanf:"log"` Public PublicConfig `koanf:"public"` Internal InternalConfig `koanf:"internal"` + Client ClientConfig `koanf:"client"` // ResponseCacheSize is the maximum number of bytes cached by HTTP clients. ResponseCacheSize int `koanf:"cache.maxbytes"` ClientIPHeaderName string `koanf:"clientipheader"` } +// 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"` +} + // PublicConfig contains the configuration for outside-facing HTTP endpoints. type PublicConfig struct { // Address holds the interface address the HTTP service must be bound to, in the format of `interface:port` (e.g. localhost:5555). diff --git a/http/engine.go b/http/engine.go index d2c1533056..9ea89bb67a 100644 --- a/http/engine.go +++ b/http/engine.go @@ -66,7 +66,9 @@ func (h Engine) Router() core.EchoRouter { // Configure loads the configuration for the HTTP engine. func (h *Engine) Configure(serverConfig core.ServerConfig) error { - h.configureClient(serverConfig) + if err := h.configureClient(serverConfig); err != nil { + return err + } // We have 2 HTTP interfaces: internal and public // The following paths (and their subpaths) are bound to the internal interface: @@ -98,12 +100,19 @@ func (h *Engine) Configure(serverConfig core.ServerConfig) error { return h.applyAuthMiddleware(h.server, InternalPath, h.config.Internal.Auth) } -func (h *Engine) configureClient(serverConfig core.ServerConfig) { +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 + } // Configure the HTTP caching client, if enabled. Set it to http.DefaultTransport so it can be used by any subsystem. if h.config.ResponseCacheSize > 0 { client.DefaultCachingTransport = client.NewCachingTransport(client.SafeHttpTransport, h.config.ResponseCacheSize) } + return nil } func (h *Engine) applyTracingMiddleware(echoServer core.EchoRouter) { diff --git a/http/engine_test.go b/http/engine_test.go index 2ba12c8292..b1e8101a75 100644 --- a/http/engine_test.go +++ b/http/engine_test.go @@ -36,6 +36,7 @@ import ( "github.com/lestrrat-go/jwx/v3/jwk" "github.com/lestrrat-go/jwx/v3/jwt" "github.com/nuts-foundation/nuts-node/core" + "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" @@ -69,6 +70,62 @@ func TestEngine_Configure(t *testing.T) { err = engine.Shutdown() assert.NoError(t, err) }) + t.Run("client CIDR options", func(t *testing.T) { + // Configure writes the options into package-global guard state in http/client; + // reset it afterwards so other tests see the defaults. + 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)) + }) + } + t.Run("ok - propagated to the dial guard", func(t *testing.T) { + resetGuardState(t) + engine := New(noop, nil) + engine.config = createTestConfig() + 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)) + + // The guard runs at dial time, before any connection is made, so no server is + // needed: a denied address fails with the guard's error, while an address + // admitted by the allowlist proceeds to the actual (failing) connection attempt. + httpClient := client.New(time.Second) + deniedReq, _ := http.NewRequest("GET", "https://127.0.5.1:1", nil) + _, err := httpClient.Do(deniedReq) + assert.ErrorContains(t, err, "blocked connection to denied address") + + allowedReq, _ := http.NewRequest("GET", "https://127.0.6.1:1", nil) + _, err = httpClient.Do(allowedReq) + require.Error(t, err) // nothing listens there; it must be a connection error, not the guard + assert.NotContains(t, err.Error(), "blocked connection to") + }) + t.Run("error - invalid allowedinternalcidrs fails startup", func(t *testing.T) { + resetGuardState(t) + engine := New(noop, nil) + engine.config = createTestConfig() + 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 := New(noop, nil) + engine.config = createTestConfig() + 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("middleware", func(t *testing.T) { t.Run("auth-tokenV2", func(t *testing.T) { // Ensure the server can be started and protected without applying a specific audience in the configuration diff --git a/vcr/vcr_test.go b/vcr/vcr_test.go index d4833943a9..62ba52e375 100644 --- a/vcr/vcr_test.go +++ b/vcr/vcr_test.go @@ -86,7 +86,9 @@ func TestVCR_Configure(t *testing.T) { }) t.Run("strictmode passed to client APIs", func(t *testing.T) { ctx := newMockContext(t) + oldStrictMode := client.StrictMode client.StrictMode = true + t.Cleanup(func() { client.StrictMode = oldStrictMode }) testVC := test.ValidNutsOrganizationCredential(t) issuerDID := did.MustParseDID(testVC.Issuer.String()) testDirectory := io.TestDirectory(t) From 7e20a6ac548c9b3851ed158c5478832244e7466a Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 27 Jul 2026 15:18:56 +0200 Subject: [PATCH 2/3] docs(deployment): document SSRF guard configuration Add an SSRF section to the security considerations page explaining the strict-mode outbound guard and how to configure allowedinternalcidrs and deniedcidrs in production. Link to it from the release notes entry. Assisted-by: AI --- .../deployment/security-considerations.rst | 53 +++++++++++++++++++ docs/pages/release_notes.rst | 2 +- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/docs/pages/deployment/security-considerations.rst b/docs/pages/deployment/security-considerations.rst index de201d0709..4b5556f7e8 100644 --- a/docs/pages/deployment/security-considerations.rst +++ b/docs/pages/deployment/security-considerations.rst @@ -15,6 +15,59 @@ In addition to securing the internal APIs, it's recommended to limit access to t This will allow you to control access to the public APIs, do TLS termination and add additional security measures. Block any path that's not used by the Nuts node. +.. _ssrf-protection: + +Outbound HTTP and SSRF protection +********************************* + +The Nuts node fetches URLs supplied by other parties, for example DID documents, OAuth metadata and +Discovery Service 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 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. + D(D)oS Protection ***************** diff --git a/docs/pages/release_notes.rst b/docs/pages/release_notes.rst index ce072f4490..2a2d9509a2 100644 --- a/docs/pages/release_notes.rst +++ b/docs/pages/release_notes.rst @@ -7,7 +7,7 @@ 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. Reported by @raysabee, fixed by @stevenvegt in https://github.com/nuts-foundation/nuts-node/pull/4420 +* #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 ***************** Peanut (v6.2.10) From 78553851631313b4afd1456ec00bc5f29655f5a7 Mon Sep 17 00:00:00 2001 From: reinkrul Date: Thu, 4 Jun 2026 09:46:53 +0200 Subject: [PATCH 3/3] test(storage): fix flaky minimemcached nil-pointer panic (#4301) * test(storage): fix flaky minimemcached nil-pointer panic The minimemcached test server spawns a goroutine that calls Accept() on its listener, while Close() nils that listener. On very short tests the cleanup's Close() could run before the goroutine entered Accept(), dereferencing a nil listener inside the dependency and crashing the test binary with a panic that no test can recover (it runs in a foreign goroutine). Block in the test helper until the server answers a probe connection, guaranteeing the accept loop is parked in Accept() before the test proceeds. Close() then unblocks it cleanly instead of racing startup. Assisted by AI * test(storage): link upstream issue for the minimemcached workaround Reference https://github.com/daangn/minimemcached/issues/14 (fix in PR #13) so the workaround can be removed once an upstream release ships the fix. Assisted by AI --- storage/session_memcached_test.go | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/storage/session_memcached_test.go b/storage/session_memcached_test.go index b5ce5964ed..342e9f188d 100644 --- a/storage/session_memcached_test.go +++ b/storage/session_memcached_test.go @@ -170,11 +170,27 @@ func memcachedTestServer(t *testing.T) *minimemcached.MiniMemcached { if err != nil { t.Fatal(err) } + // Workaround for https://github.com/daangn/minimemcached/issues/14 (fix proposed in PR #13). + // Wait until the server's accept loop is actually serving connections before returning. + // minimemcached.Run spawns a goroutine that calls Accept() on the listener; Close() nils that + // listener. On really short tests Close() (in the cleanup below) could run before the goroutine + // entered Accept(), causing a nil pointer dereference in the dependency. Probing the server until + // it answers guarantees the accept loop is parked in Accept(), so Close() unblocks it cleanly. + // Remove this workaround once a minimemcached release includes the upstream fix. + require.Eventually(t, func() bool { + conn, err := net.DialTimeout("tcp", fmt.Sprintf("localhost:%d", m.Port()), time.Second) + if err != nil { + return false + } + defer conn.Close() + if _, err = conn.Write([]byte("version\r\n")); err != nil { + return false + } + buf := make([]byte, 1) + _, err = conn.Read(buf) + return err == nil + }, 5*time.Second, 10*time.Millisecond, "memcached server did not start serving") t.Cleanup(func() { - // Note on DATA RACE - // minimemcached.Run creates a new go routine to listen for new connections. - // In certain cases the new go routine may be created after/simultaneous with this cleanup resulting in a data race / nil pointer dereference. - // Mostly happens on CI during really short tests. m.Close() }) return m