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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .github/workflows/ocsp.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,51 @@ jobs:

- name: Test Look Up
run: ./examples/client/client -A ./certs/ocsp/root-ca-cert.pem -o

ocsp_ssrf_screen:
name: ocsp responder SSRF screening
if: ${{ (github.repository_owner == 'wolfssl') && (github.event_name != 'pull_request' || github.event.pull_request.draft == false) }}
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Checkout wolfSSL
uses: actions/checkout@v5

# Build with the opt-in OCSP responder destination screening enabled
# (WOLFSSL_OCSP_SCREEN_RESPONDER). This guards against a certificate AIA
# OCSP URL driving an outbound request to an internal address (SSRF,
# CWE-918). The screening is off by default, so it is not exercised by
# the ocsp_stapling job above (which uses localhost responders).
- name: Build wolfSSL with OCSP responder screening enabled
run: autoreconf -ivf && ./configure --enable-ocsp CPPFLAGS=-DWOLFSSL_OCSP_SCREEN_RESPONDER && make

# Run only the boundary unit test, not the localhost OCSP test scripts:
# with screening on, 127.0.0.1 responders are (correctly) rejected, so
# the stapling scripts do not apply to this build. Assert the test
# actually ran (passed) rather than being compiled out and skipped, so a
# future build-define change cannot turn this into a false-green signal.
- name: Run OCSP destination screening boundary tests
run: |
./tests/unit.test -test_wolfIO_OcspDestAllowed | tee out.txt
grep -Eq 'test_wolfIO_OcspDestAllowed[^_].*: passed' out.txt

ocsp_ssrf_screen_fallback:
name: ocsp responder SSRF screening (gethostbyname fallback)
if: ${{ (github.repository_owner == 'wolfssl') && (github.event_name != 'pull_request' || github.event.pull_request.draft == false) }}
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Checkout wolfSSL
uses: actions/checkout@v5

# Force the gethostbyname() resolver fallback (ac_cv_func_getaddrinfo=no)
# so the otherwise-untested fallback path of wolfIO_OcspDestAllowed is
# exercised. The fallback is IPv4-only and relies on glibc parsing
# numeric IPv4 literals locally.
- name: Build wolfSSL forcing the gethostbyname resolver fallback
run: autoreconf -ivf && ./configure --enable-ocsp ac_cv_func_getaddrinfo=no CPPFLAGS=-DWOLFSSL_OCSP_SCREEN_RESPONDER && make

- name: Run OCSP destination screening fallback boundary tests
run: |
./tests/unit.test -test_wolfIO_OcspDestAllowed_fallback | tee out.txt
grep -Eq 'test_wolfIO_OcspDestAllowed_fallback.*: passed' out.txt
215 changes: 215 additions & 0 deletions src/wolfio.c
Original file line number Diff line number Diff line change
Expand Up @@ -2201,6 +2201,213 @@ int wolfIO_HttpProcessResponseOcsp(int sfd, byte** respBuf,
respBuf, httpBuf, httpBufSz, DYNAMIC_TYPE_OCSP, heap);
}

#if defined(WOLFSSL_OCSP_SCREEN_RESPONDER) && defined(HAVE_SOCKADDR)

/* Return 1 if the given IPv4 address (4 octets, network byte order) falls in a
* loopback/private/link-local/reserved range that an OCSP responder must not
* live in, else 0. */
static int wolfIO_OcspIPv4Blocked(const unsigned char a[4])
{
if (a[0] == 0) /* 0.0.0.0/8 "this" net */
return 1;
if (a[0] == 127) /* 127.0.0.0/8 loopback */
return 1;
if (a[0] == 10) /* 10.0.0.0/8 private */
return 1;
if (a[0] == 172 && (a[1] & 0xF0) == 16) /* 172.16.0.0/12 private */
return 1;
if (a[0] == 192 && a[1] == 168) /* 192.168.0.0/16 private */
return 1;
if (a[0] == 169 && a[1] == 254) /* 169.254.0.0/16 link-local
* (incl. cloud metadata) */
return 1;
if (a[0] == 100 && (a[1] & 0xC0) == 64) /* 100.64.0.0/10 CGNAT */
return 1;
if (a[0] >= 224) /* 224.0.0.0/4 multicast,
* 240.0.0.0/4 reserved,
* 255.255.255.255 bcast */
return 1;
return 0;
}

/* Only the getaddrinfo resolver path yields AF_INET6 results; the gethostbyname
* fallback is IPv4-only. Guard on HAVE_GETADDRINFO so this is not compiled as an
* unused function in a WOLFSSL_IPV6 + gethostbyname-fallback build. */
#if defined(WOLFSSL_IPV6) && defined(HAVE_GETADDRINFO)
/* Return 1 if the given IPv6 address (16 octets, network byte order) falls in a
* loopback/unspecified/unique-local/link-local/multicast range, else 0.
* IPv4-mapped (::ffff:0:0/96), IPv4-compatible (::a.b.c.d), and NAT64
* (64:ff9b::/96) embeddings are unwrapped and screened as IPv4. */
static int wolfIO_OcspIPv6Blocked(const unsigned char a[16])
{
static const unsigned char v4mapped[12] =
{ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xFF, 0xFF };
int i;
int zeroPrefix = 1; /* bytes 0..11 all zero (IPv4-compatible prefix) */

for (i = 0; i < 12; i++) {
if (a[i] != 0) {
zeroPrefix = 0;
break;
}
}
if ((a[0] & 0xFE) == 0xFC) /* fc00::/7 unique local */
return 1;
if (a[0] == 0xFE && (a[1] & 0xC0) == 0x80) /* fe80::/10 link-local */
return 1;
if (a[0] == 0xFF) /* ff00::/8 multicast */
return 1;
if (XMEMCMP(a, v4mapped, sizeof(v4mapped)) == 0) /* ::ffff:0:0/96 mapped */
return wolfIO_OcspIPv4Blocked(a + 12);
/* :: unspecified, ::1 loopback, and deprecated IPv4-compatible ::a.b.c.d
* all have 96 zero leading bits; screen the embedded IPv4 (covers them,
* since 0.0.0.0/8 is blocked). No public unicast address looks like this. */
if (zeroPrefix)
return wolfIO_OcspIPv4Blocked(a + 12);
if (a[0] == 0x00 && a[1] == 0x64 && a[2] == 0xFF && a[3] == 0x9B) {
/* NAT64 well-known prefix 64:ff9b::/96 -> screen embedded IPv4 */
int zeroMid = 1;
for (i = 4; i < 12; i++) {
if (a[i] != 0) {
zeroMid = 0;
break;
}
}
if (zeroMid)
return wolfIO_OcspIPv4Blocked(a + 12);
}
return 0;
}
#endif /* WOLFSSL_IPV6 && HAVE_GETADDRINFO */

#endif /* WOLFSSL_OCSP_SCREEN_RESPONDER && HAVE_SOCKADDR */

#ifdef WOLFSSL_OCSP_SCREEN_RESPONDER

/* Screen an OCSP responder host before connecting, so that a certificate-
* supplied AIA URL cannot steer the request at an internal/reserved address
* (SSRF, CWE-918). The host is resolved and every returned address is checked;
* the destination is rejected if ANY resolved address is in a blocked range.
* Returns 1 if the destination is permitted, 0 if it must be blocked.
*
* This is a best-effort guard performed at the integration boundary; a host
* that cannot be resolved is treated as not permitted. Note that the connect
* resolves the name again, so this does not by itself defeat a DNS-rebinding
* responder -- deployments that need a hard guarantee should install a custom
* OCSP IO callback with wolfSSL_CTX_SetOCSP_Cb.
*
* This screening is OPT-IN: it is compiled and active only when
* WOLFSSL_OCSP_SCREEN_RESPONDER is defined. It is off by default because many
* deployments legitimately run an OCSP responder on loopback or an internal
* network (the in-tree OCSP tests use http://127.0.0.1, for example), and an
* operator-configured override responder (wolfSSL_CTX_SetOCSP_OverrideURL) is
* delivered to this same default callback and would be screened identically.
*
* Exposed as WOLFSSL_TEST_VIS (not static) so the unit tests can exercise the
* range boundaries with literal IP strings; not part of the public API. */
int wolfIO_OcspDestAllowed(const char* host)
{
#ifdef HAVE_SOCKADDR
int blocked = 0;
#if defined(HAVE_GETADDRINFO)
ADDRINFO hints;
ADDRINFO* answer = NULL;
ADDRINFO* cur;

if (host == NULL)
return 0;

XMEMSET(&hints, 0, sizeof(hints));
#ifdef WOLFSSL_IPV6
hints.ai_family = AF_UNSPEC;
#else
hints.ai_family = AF_INET;
#endif
hints.ai_socktype = SOCK_STREAM;
hints.ai_protocol = IPPROTO_TCP;

if (getaddrinfo(host, NULL, &hints, &answer) != 0 || answer == NULL) {
/* cannot resolve -> cannot verify destination -> deny */

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggested change
/* cannot resolve -> cannot verify destination -> deny */
if (getaddrinfo(host, NULL, &hints, &answer) != 0) {
if (answer != NULL) freeaddrinfo(answer);
return 0;
}
if (answer == NULL) {
/* cannot resolve -> cannot verify destination -> deny */
return 0;
}

Z3 formally proves this is UNSAT (no leak) under all platform models — see the formal proof in the thread comment.

return 0;
}

for (cur = answer; cur != NULL && !blocked; cur = cur->ai_next) {
if (cur->ai_family == AF_INET) {
SOCKADDR_IN* s = (SOCKADDR_IN*)cur->ai_addr;
blocked = wolfIO_OcspIPv4Blocked(
(const unsigned char*)&s->sin_addr.s_addr);
}
#ifdef WOLFSSL_IPV6
else if (cur->ai_family == AF_INET6) {
SOCKADDR_IN6* s = (SOCKADDR_IN6*)cur->ai_addr;
blocked = wolfIO_OcspIPv6Blocked(
(const unsigned char*)&s->sin6_addr);
}
#endif
}
freeaddrinfo(answer);
#else /* !HAVE_GETADDRINFO: gethostbyname fallback */
/* gethostbyname() returns non-reentrant static storage; on multi-threaded
* glibc use gethostbyname_r() with a heap buffer, matching the resolver
* pattern in wolfIO_TcpConnect(). */
#if defined(__GLIBC__) && (__GLIBC__ >= 2) && defined(__USE_MISC) && \
!defined(SINGLE_THREADED)
#define WOLFSSL_OCSP_GHBN_R
#endif
#ifdef WOLFSSL_OCSP_GHBN_R
HOSTENT entry_buf, *entry = NULL;
char* ghbn_r_buf;
int ghbn_r_errno;
#else
HOSTENT* entry;
#endif
int i;

if (host == NULL)
return 0;

#ifdef WOLFSSL_OCSP_GHBN_R
/* 2048 is the same empirically-chosen buffer size used in
* wolfIO_TcpConnect(). */
ghbn_r_buf = (char*)XMALLOC(2048, NULL, DYNAMIC_TYPE_TMP_BUFFER);
if (ghbn_r_buf != NULL) {
gethostbyname_r(host, &entry_buf, ghbn_r_buf, 2048, &entry,
&ghbn_r_errno);
}
#else
entry = gethostbyname(host);
#endif
if (entry == NULL) {
#ifdef WOLFSSL_OCSP_GHBN_R
XFREE(ghbn_r_buf, NULL, DYNAMIC_TYPE_TMP_BUFFER);
#endif
return 0;
}

/* gethostbyname()/gethostbyname_r() resolve only IPv4 (h_addrtype
* AF_INET); IPv6 would require gethostbyname2()/getipnodebyname(), which
* are not used here, so screen each returned address as IPv4. */
for (i = 0; entry->h_addr_list[i] != NULL && !blocked; i++) {
if (entry->h_addrtype == AF_INET) {
blocked = wolfIO_OcspIPv4Blocked(
(const unsigned char*)entry->h_addr_list[i]);
}
}
#ifdef WOLFSSL_OCSP_GHBN_R
XFREE(ghbn_r_buf, NULL, DYNAMIC_TYPE_TMP_BUFFER);
#undef WOLFSSL_OCSP_GHBN_R
#endif
#endif /* HAVE_GETADDRINFO */

return blocked ? 0 : 1;
#else /* !HAVE_SOCKADDR: no address support to screen */
(void)host;
return 1;
#endif /* HAVE_SOCKADDR */
}

#endif /* WOLFSSL_OCSP_SCREEN_RESPONDER */

/* in default wolfSSL callback ctx is the heap pointer */
int EmbedOcspLookup(void* ctx, const char* url, int urlSz,
byte* ocspReqBuf, int ocspReqSz, byte** ocspRespBuf)
Expand Down Expand Up @@ -2238,6 +2445,14 @@ int EmbedOcspLookup(void* ctx, const char* url, int urlSz,
else if (wolfIO_DecodeUrl(url, urlSz, domainName, path, &port) < 0) {
WOLFSSL_MSG("Unable to decode OCSP URL");
}
#ifdef WOLFSSL_OCSP_SCREEN_RESPONDER
/* Opt-in: reject responders that resolve to private/loopback/link-local
* ranges to keep a certificate-supplied AIA URL from forcing an internal
* request (SSRF, CWE-918). */
else if (!wolfIO_OcspDestAllowed(domainName)) {
WOLFSSL_MSG("OCSP responder destination not permitted");
}
#endif
else {
/* Note, the library uses the EmbedOcspRespFree() callback to
* free this buffer. */
Expand Down
98 changes: 98 additions & 0 deletions tests/api.c
Original file line number Diff line number Diff line change
Expand Up @@ -21106,6 +21106,102 @@ static int test_wolfSSL_OCSP_parse_url(void)
return EXPECT_RESULT();
}

static int test_wolfIO_OcspDestAllowed(void)
{
EXPECT_DECLS;
#if defined(HAVE_OCSP) && defined(HAVE_SOCKADDR) && \
defined(HAVE_GETADDRINFO) && defined(WOLFSSL_OCSP_SCREEN_RESPONDER)
/* Boundary tests for the OCSP responder SSRF destination screening
* (wolfIO_OcspDestAllowed). Literal IP strings resolve locally via
* getaddrinfo (no DNS), so results are deterministic. The HAVE_GETADDRINFO
* guard matters: the gethostbyname fallback cannot parse IPv6 literals.
* 1 = permitted, 0 = blocked. */
#define EXPECT_BLOCKED(ip) ExpectIntEQ(wolfIO_OcspDestAllowed(ip), 0)
#define EXPECT_ALLOWED(ip) ExpectIntEQ(wolfIO_OcspDestAllowed(ip), 1)

/* A NULL or unresolvable host cannot be verified -> denied. */
EXPECT_BLOCKED(NULL);

/* IPv4 blocked ranges. */
EXPECT_BLOCKED("0.0.0.1"); /* 0.0.0.0/8 "this" network */
EXPECT_BLOCKED("127.0.0.1"); /* 127.0.0.0/8 loopback */
EXPECT_BLOCKED("10.0.0.1"); /* 10.0.0.0/8 private */
EXPECT_BLOCKED("192.168.1.1"); /* 192.168.0.0/16 private */
EXPECT_BLOCKED("169.254.169.254"); /* 169.254.0.0/16 cloud metadata */
EXPECT_BLOCKED("224.0.0.1"); /* 224.0.0.0/4 multicast */
EXPECT_BLOCKED("240.0.0.1"); /* 240.0.0.0/4 reserved */

/* IPv4 /12 (172.16.0.0/12) edges. */
EXPECT_ALLOWED("172.15.255.255"); /* just below */
EXPECT_BLOCKED("172.16.0.0"); /* low edge */
EXPECT_BLOCKED("172.31.255.255"); /* high edge */
EXPECT_ALLOWED("172.32.0.0"); /* just above */

/* IPv4 /10 CGNAT (100.64.0.0/10) edges. */
EXPECT_ALLOWED("100.63.255.255"); /* just below */
EXPECT_BLOCKED("100.64.0.0"); /* low edge */
EXPECT_BLOCKED("100.127.255.255"); /* high edge */
EXPECT_ALLOWED("100.128.0.0"); /* just above */

/* IPv4 multicast lower edge. */
EXPECT_ALLOWED("223.255.255.255"); /* just below 224/4 */

/* IPv4 permitted (public) addresses. */
EXPECT_ALLOWED("8.8.8.8");
EXPECT_ALLOWED("1.1.1.1");

#ifdef WOLFSSL_IPV6
/* IPv6 blocked ranges. */
EXPECT_BLOCKED("::1"); /* loopback */
EXPECT_BLOCKED("::"); /* unspecified */
EXPECT_BLOCKED("fc00::1"); /* fc00::/7 unique local */
EXPECT_BLOCKED("fd00::1"); /* fc00::/7 unique local */
EXPECT_BLOCKED("fe80::1"); /* fe80::/10 link-local */
EXPECT_BLOCKED("ff02::1"); /* ff00::/8 multicast */
EXPECT_BLOCKED("::ffff:127.0.0.1"); /* IPv4-mapped loopback */
EXPECT_BLOCKED("::7f00:1"); /* IPv4-compatible 127.0.0.1 */
EXPECT_BLOCKED("64:ff9b::7f00:1"); /* NAT64 of 127.0.0.1 */

/* IPv6 permitted addresses. */
EXPECT_ALLOWED("::ffff:8.8.8.8"); /* IPv4-mapped public */
EXPECT_ALLOWED("2001:4860:4860::8888");
#endif /* WOLFSSL_IPV6 */

#undef EXPECT_BLOCKED
#undef EXPECT_ALLOWED
#endif
return EXPECT_RESULT();
}

static int test_wolfIO_OcspDestAllowed_fallback(void)
{
EXPECT_DECLS;
#if defined(HAVE_OCSP) && defined(HAVE_SOCKADDR) && \
!defined(HAVE_GETADDRINFO) && defined(WOLFSSL_OCSP_SCREEN_RESPONDER)
/* Exercises the gethostbyname() fallback resolver path of
* wolfIO_OcspDestAllowed (the getaddrinfo path is covered by
* test_wolfIO_OcspDestAllowed). The fallback is IPv4-only and relies on the
* resolver parsing numeric IPv4 literals locally (glibc does). Only runs in
* a build configured without getaddrinfo -- see the ocsp_ssrf_screen
* fallback CI job. IPv4 deny/allow boundaries plus the NULL-host deny. */
#define EXPECT_BLOCKED(ip) ExpectIntEQ(wolfIO_OcspDestAllowed(ip), 0)
#define EXPECT_ALLOWED(ip) ExpectIntEQ(wolfIO_OcspDestAllowed(ip), 1)
EXPECT_BLOCKED(NULL);
EXPECT_BLOCKED("127.0.0.1");
EXPECT_BLOCKED("10.0.0.1");
EXPECT_BLOCKED("169.254.169.254");
EXPECT_BLOCKED("172.16.0.0"); /* /12 low edge */
EXPECT_ALLOWED("172.15.255.255"); /* just below /12 */
EXPECT_BLOCKED("100.64.0.0"); /* CGNAT /10 low edge */
EXPECT_ALLOWED("100.63.255.255"); /* just below /10 */
EXPECT_ALLOWED("8.8.8.8");
EXPECT_ALLOWED("1.1.1.1");
#undef EXPECT_BLOCKED
#undef EXPECT_ALLOWED
#endif
return EXPECT_RESULT();
}

#if defined(OPENSSL_ALL) && defined(HAVE_OCSP) && \
defined(WOLFSSL_SIGNER_DER_CERT) && !defined(NO_FILESYSTEM) && \
!defined(NO_ASN_TIME) && \
Expand Down Expand Up @@ -34941,6 +35037,8 @@ TEST_CASE testCases[] = {
TEST_DECL(test_wolfSSL_OCSP_resp_count),
TEST_DECL(test_wolfSSL_OCSP_resp_get0),
TEST_DECL(test_wolfSSL_OCSP_parse_url),
TEST_DECL(test_wolfIO_OcspDestAllowed),
TEST_DECL(test_wolfIO_OcspDestAllowed_fallback),
TEST_DECL(test_wolfSSL_OCSP_REQ_CTX),
TEST_DECL(test_wolfSSL_X509_get1_ca_issuers),
TEST_DECL(test_wolfSSL_X509_get1_aia_multi),
Expand Down
Loading
Loading