Skip to content
Merged
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
212 changes: 212 additions & 0 deletions src/wolfio.c
Original file line number Diff line number Diff line change
Expand Up @@ -2260,6 +2260,210 @@ 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])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 [Low] 6to4 (2002::/16) addresses bypass IPv6 SSRF screening · Certificate and trust chain validation bypass

wolfIO_OcspIPv6Blocked does not check the IPv6 6to4 prefix 2002::/16; an AIA URL containing [2002:7f00:0001::] (embedding 127.0.0.1) or [2002:a9fe:a9fe::] (embedding 169.254.169.254) passes the screening. Exploitability is constrained by 6to4 routing — loopback and link-local targets are reachable only on the 6to4 relay, not the victim — but RFC1918 targets can be reached if the relay is on the same internal network.

Fix: Add a check for a[0] == 0x20 && a[1] == 0x02 to block the 6to4 prefix, and screen the embedded IPv4 via wolfIO_OcspIPv4Blocked(a + 2).

{
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. */
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 */
Comment thread
ejohnstown marked this conversation as resolved.
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 @@ -2297,6 +2501,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 @@ -22675,6 +22675,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 */

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 [Low] NAT64 allow-path not tested in test_wolfIO_OcspDestAllowed · Missing edge-case coverage on a function the PR also changed

The NAT64 branch of wolfIO_OcspIPv6Blocked calls wolfIO_OcspIPv4Blocked(a+12), which returns 0 (allowed) or 1 (blocked). Only the blocked outcome is tested ("64:ff9b::7f00:1"); no test exercises the allowed return value, so a regression that unconditionally blocks all NAT64 destinations passes all existing tests.

Fix: Add EXPECT_ALLOWED("64:ff9b::8.8.8.8") (NAT64-mapped public IP) to cover the non-blocked return of the NAT64 branch.


/* 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 @@ -37278,6 +37374,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