Skip to content

fix(upstream): verify against ssl_trusted_certificate when only tls.verify is set - #14113

Merged
AlinsRan merged 9 commits into
apache:masterfrom
AlinsRan:fix/upstream-tls-verify-default-ca
Oct 29, 2026
Merged

AlinsRan merged 9 commits into
apache:masterfrom
AlinsRan:fix/upstream-tls-verify-default-ca

Conversation

@AlinsRan

@AlinsRan AlinsRan commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Description

An upstream that sets tls.verify: true without tls.ca_certs fails with upstream SSL certificate verify error: (20:unable to get local issuer certificate) for every upstream certificate, including ones signed by a CA listed in apisix.ssl.ssl_trusted_certificate. Turning on proxy_ssl_verify in nginx makes the same request succeed.

Root cause: nginx loads proxy_ssl_trusted_certificate (and proxy_ssl_crl) into the upstream SSL context only when proxy_ssl_verify is on at configuration time. APISIX renders proxy_ssl_trusted_certificate but leaves proxy_ssl_verify off, so when tls.verify enables verification per request, the handshake is verified against an empty trusted store. gRPC has the same problem, and APISIX does not render grpc_ssl_trusted_certificate at all.

An earlier revision of this PR loaded ssl_trusted_certificate into a Lua X509 store and applied it with set_ssl_trusted_store. That replaces nginx's store, so a CRL configured with proxy_ssl_crl (together with proxy_ssl_verify on) stopped being checked and a revoked certificate was accepted. That approach is dropped.

The fix now lives in the runtime: api7/apisix-nginx-module#137 loads the trusted certificates and the CRL into the upstream SSL context whenever *_ssl_trusted_certificate is set. This turns on neither verification nor *_ssl_verify_depth, which still applies only with *_ssl_verify on. With that, this PR:

tls.ca_certs keeps its current behaviour: it replaces the verify store for that upstream.

Upgrade note: before 3.19.0, tls.verify was not applied to HTTP upstreams. After upgrading, upstreams that set verify: true and present a certificate from an untrusted CA are rejected. In addition, grpc_ssl_trusted_certificate is now rendered at server scope from ssl_trusted_certificate. Like proxy_ssl_trusted_certificate, it overrides the same directive set in http_configuration_snippet. A deployment that configured a separate gRPC CA there has to add that CA to ssl_trusted_certificate.

Tests:

  • t/node/upstream-tls-trusted-ca.t (new): https and grpcs upstreams, with proxy_ssl_trusted_certificate / grpc_ssl_trusted_certificate and proxy_ssl_crl / grpc_ssl_crl configured in nginx:

    • static verify off, tls.verify: true: trusted certificate → 200, revoked → 502 (23:certificate revoked), untrusted CA → 502
    • static verify off, tls.verify unset: all three → 200 (unchanged)
    • static verify on, tls.verify: true: trusted → 200, revoked → 502. This covers the case where the Lua store dropped the CRL.
    • static verify on, tls.verify unset: revoked → 502
    • tls.verify: false, static verify on or off: untrusted CA → 200
    • a chain with two intermediates: tls.verify: true with static verify off → 200 (no depth limit is added); static verify on (default depth 1), with tls.verify either true or unset → 502

    On runtime 1.4.0, the trusted and revoked cases with tls.verify: true and static verify off fail. Both return (20:unable to get local issuer certificate).

  • t/cli/test_upstream_mtls.sh: new case using the real rendered nginx.conf with no proxy_ssl_verify snippet. tls.verify: true returns 200 when ssl_trusted_certificate covers the upstream certificate and 502 when it does not. It also checks that grpc_ssl_trusted_certificate is rendered.

Which issue(s) this PR fixes:

N/A

Checklist

  • I have explained the need for this PR and the problem it solves
  • I have explained the changes or the new features added to this PR
  • I have added tests corresponding to this change
  • I have updated the documentation to reflect this change
  • I have verified that this change is backward compatible

…erify is set

nginx loads proxy_ssl_trusted_certificate into the upstream SSL context
only when proxy_ssl_verify is on. With it off (the default), enabling
tls.verify per upstream without tls.ca_certs verified the certificate
against an empty store, so every upstream failed with
"unable to get local issuer certificate".

Load apisix.ssl.ssl_trusted_certificate into a trusted store once per
worker and apply it when tls.verify is true and tls.ca_certs is unset.
Workers may run as a different user than the master, which reads
ssl_trusted_certificate for nginx itself. Loading the store in http_init
lets workers inherit it and turns a load failure into a startup error
instead of a 503 on the first request.
Assert that the route is created, so the case cannot pass against a
route left over from a previous case.
nic-6443
nic-6443 previously approved these changes Oct 28, 2026

@membphis membphis left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking P1: applying the default CA store currently replaces the NGINX verification store and drops configured upstream CRL enforcement. Please preserve the existing revocation policy and add a real-gateway regression before merging. Details are attached to apisix/upstream.lua.

Comment thread apisix/upstream.lua Outdated
store, err = trusted_store_cache(tls.ca_certs, up_conf.resource_version,
create_trusted_store, tls.ca_certs)
elseif tls.verify then
store = default_trusted_store

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Preserve configured CRL checks when applying default CAs

When proxy_ssl_verify on and proxy_ssl_crl are configured, an upstream with tls.verify: true and no tls.ca_certs previously used NGINX's CA+CRL store. This branch now replaces it with default_trusted_store, which is created using only store:load_file(path). The CRL entries and X509_V_FLAG_CRL_CHECK | X509_V_FLAG_CRL_CHECK_ALL flags are lost, so a certificate signed by a trusted CA but already revoked can pass verification.

NGINX loads the CRL and its flags into the original store in ngx_ssl_crl. The runtime applies the replacement using SSL_set1_verify_cert_store, rather than merging it with the existing store.

An isolated in-memory TLS handshake experiment with OpenSSL 3.6.3 confirmed the mechanism for the same revoked certificate:

NGINX-style CA+CRL store: verify_result=23 (certificate revoked)
CA-only replacement store: verify_result=0 (ok)

This was a standalone OpenSSL experiment, not a full gateway reproduction. Please load the default CAs into the existing SSL_CTX store, or otherwise preserve its CRLs and verification flags when applying the fallback. Add a real-gateway regression with proxy_ssl_verify on, proxy_ssl_crl, and upstream tls.verify: true without ca_certs, asserting that a valid certificate returns 200 and a revoked certificate still returns 502.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the latest revision. The Lua default-store replacement has been removed. APISIX now uses runtime 1.4.1, which includes api7/apisix-nginx-module#137: trusted CAs and CRLs are loaded into NGINX's existing upstream SSL context even when static verification is off, preserving the CRL entries and verification flags. This does not enable verification or change the verification depth when static verification is off.

Added t/node/upstream-tls-trusted-ca.t coverage for both HTTPS and gRPCS, including static verification on with a configured CRL and tls.verify: true without tls.ca_certs: a valid certificate returns 200, and a revoked certificate returns 502 with certificate revoked. The regression suite passes in CI. Explicit tls.ca_certs retains its existing behavior.

…copy

Replacing the verify store from Lua drops the CRL and CRL check flags
that nginx puts on its own store, so with proxy_ssl_verify on and
proxy_ssl_crl configured a revoked upstream certificate passed
tls.verify. The runtime now loads proxy_ssl_trusted_certificate and the
CRL into the upstream SSL context without proxy_ssl_verify on, so the
default trusted store is no longer needed.

Render grpc_ssl_trusted_certificate next to proxy_ssl_trusted_certificate
so grpcs upstreams get the same trusted certificates.
@AlinsRan
AlinsRan marked this pull request as draft October 28, 2026 09:29
@AlinsRan
AlinsRan marked this pull request as ready for review October 29, 2026 04:35

@membphis membphis left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@AlinsRan
AlinsRan merged commit ff1187e into apache:master Oct 29, 2026
27 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants