fix(security): 1.35.6 hardening stack (14-vector audit remediation)#85
fix(security): 1.35.6 hardening stack (14-vector audit remediation)#85andypost wants to merge 13 commits into
Conversation
Validate at config time that a loaded private key matches its certificate, instead of letting a mismatched pair load cleanly and fail only at the first handshake. Guard the wildcard-name SAN matcher against an empty name: a certificate carrying a zero-length SAN entry caused a one-byte over-read of item->name.start[0] during SNI lookup. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Fix IPv4 /32 CIDR fallthrough so a single-host rule matches exactly. * Decode the request URI and the route pattern symmetrically. * Pass a non-zero ovector size to nxt_regex_match_create(); a 0 size is undefined per the PCRE2 documentation. * Reject malformed port ranges shorter than three bytes. * Document the intentionally case-insensitive host matcher, and that the capability module does not call capset(2) — privilege barriers are setuid + PR_SET_NO_NEW_PRIVS, not capability drops. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* CLOEXEC-protect accepted client sockets and pipe ends so they cannot be inherited by spawned application processes. * Close the already-created pipe end when nxt_fd_nonblocking() fails. * Close the source file descriptor on the compression mmap failure paths. * Fall back from accept4() only on ENOSYS. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Reject a truncated extended-length frame in libunit before reading the mask/length fields or advancing buf.free past buf.end (nxt_unit.c), and apply the same header-size check in nxt_unit_websocket_retain(). * Validate the most-significant bit of the 64-bit extended payload length (RFC 6455 section 5.2) before it feeds size arithmetic. * Fix a frame-size decrement in nxt_http_websocket.c that was a no-op because the inner copy loop had already drained the counter; without it the outer loop could copy bytes beyond the declared payload, from a following buffer, into the outgoing message. * Bounds-check the pos/len arguments to the Java sendWsFrame JNI entry points. * Guard uint64 wraparound on pending_payload_len in the Python ASGI WebSocket handler. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Offsets and counts in incoming mmap messages and in deserialized request structures come from a peer process; validate them before they reach pointer arithmetic. * Range-check chunk_id and chunk_id + nchunks against the mapped data area in nxt_port_mmap_get_incoming_buf() before computing buffer pointers. * Close a lookup-versus-dereference window in nxt_port_get_port_incoming_mmap() by retaining the handler refcount under the mutex; the caller adopts the reference and every error path releases it. * Reject integer overflow in the (max_fields_count, max_fields_size) response-buffer size formula in nxt_unit_response_init/realloc. * Validate every request sptr at arrival so an out-of-range offset is rejected before any libunit consumer dereferences it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* PHP: bound the response-header isspace() skip to the header buffer; free the realpath() temporary on script-resolution failure; document that PATH_INFO is length-delimited, not NUL-terminated. * Python: surface a failed environ-template refresh in the WSGI worker; fix two ASGI lifespan checks that tested the wrong handle. * Perl: scrub ERRSV on PSGI interpreter-init failure so a stale exception cannot reach the next interpreter. * Java: validate (off, len) in InputStream.readLine() against the byte array length. * WASM: bound guest-supplied offsets in send_headers/send_response and the per-request memcpy chain against the module memory size. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Require a matching peer UID (SO_PEERCRED / getpeereid) before accepting a connection on the unix control socket. * Resolve mount destinations with openat2(RESOLVE_BENEATH) so a symlink swapped under an attacker-influenced rootfs cannot redirect a mount outside the target tree. * Resolve relative cgroup paths against /proc/<child>/cgroup rather than /proc/self/cgroup, so the new process's own cgroup view drives placement. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Cap JSON parser nesting depth (100) and per-array/object element count (100k) so a malformed POST /config payload cannot exhaust the stack or the heap. * Scrub PHP TrueAsync EG exception state before the prototype fork so a worker does not inherit a stale exception across the boundary. * Guard the Ruby rack.input / rack.errors handles against NULL-deref when an app retains them past the request, and bind each handle to its originating request so a retained handle cannot read or write the body of a later, unrelated request on the same worker. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* When a proxied upstream sends more body bytes than its Content-Length declared, truncate the buffer chain to the advertised length and mark the response inconsistent (which disables keep-alive and closes the connection) instead of forwarding the excess downstream. * Log and flag an upstream Content-Length that is unparseable or exceeds NXT_OFF_T_MAX, rather than leaving content_length_n at -1. * Fix an nxt_is_complex_uri_encoded() off-by-one that read one byte past the buffer for a trailing "%" with fewer than two hex digits. * Reject length greater than the available buffer in nxt_rmemstrn(). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
nxt_unit_sptr_in_buf() computed "buf_size - sizeof(nxt_unit_sptr_t)" to bound sptr_off. buf_size is uint32_t and sizeof() is size_t, so the subtraction is evaluated in size_t and underflows to a huge value when buf_size < sizeof(nxt_unit_sptr_t). The guard then passes and the following "sptr->offset" dereference reads past a sub-struct-sized buffer -- an out-of-bounds read reachable from a compromised/corrupted shmem request representation (audit V10, APP-COMP). Reject buffers smaller than the struct up front; the short-circuit keeps the existing subtraction from underflowing. Corrects the inaccurate "underflow-safe" note. Amends f1ff376 ("fix(port): bounds-check untrusted shmem offsets"). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The openat2(RESOLVE_BENEATH) mount-destination check matched a rootfs prefix with a bare memcmp, so a sibling path like "<rootfs>-helper" was treated as living under "<rootfs>": rel became "-helper" and openat2 failed with ENOENT, aborting an otherwise valid isolation setup. Require dst[rootfs_len] == '/' (always true for real destinations, which are rootfs + a component starting with '/') so only true children match. Amends 03e6b0e ("fix(isolation): tighten control-socket, mount, and cgroup boundaries"); audit V6 (CONTROL). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
nxt_pipe_create() used pipe2(O_CLOEXEC) whenever the header advertised it, but pipe2() is probed at compile time only (auto/files sets no runtime test). A binary built on a newer toolchain therefore fails every pipe creation on an older kernel/libc that returns ENOSYS, breaking startup of features that need pipes. Fall back to the existing pipe() + FD_CLOEXEC path on ENOSYS, mirroring the accept4() handling in nxt_epoll_engine.c. Robustness/portability only (no security impact; the related fd-exhaustion DoS shape is out of the project threat model). Amends 73e4190 ("fix(fd): tighten FD/CLOEXEC lifetime across accept/pipe/compression"). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces a comprehensive set of security and robustness bugfixes across NGINX Unit, including hardened JNI bindings, stricter WebSocket frame validation, bounds-checked WASM guest offsets, and improved isolation boundaries via openat2 and peer credential checks. The code review successfully identified several critical issues in these changes: potential JVM crashes from missing NULL checks on JNI array parameters, a resource leak when truncating HTTP/1.1 proxy buffer chains, out-of-bounds read hazards in WebSocket frame header parsing, and a potential uninitialized stack memory read during getsockopt peer credential checks.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| array_len = (*env)->GetArrayLength(env, out); | ||
| if (off < 0 || len < 0 || off > array_len || len > array_len - off) { | ||
| nxt_java_throw_IllegalStateException(env, | ||
| "InputStream.readLine: off/len out of bounds"); | ||
| return -1; | ||
| } |
There was a problem hiding this comment.
The out array parameter is not checked for NULL before calling GetArrayLength. If a Java caller passes null, this will cause a JVM crash. A defensive NULL check should be added first.
if (out == NULL) {
nxt_java_throw_IllegalStateException(env,
"InputStream.readLine: output array is null");
return -1;
}
array_len = (*env)->GetArrayLength(env, out);
if (off < 0 || len < 0 || off > array_len || len > array_len - off) {
nxt_java_throw_IllegalStateException(env,
"InputStream.readLine: off/len out of bounds");
return -1;
}| cap = (*env)->GetArrayLength(env, arr); | ||
| if (pos < 0 || len < 0 | ||
| || pos > cap | ||
| || len > cap - pos) | ||
| { | ||
| nxt_java_throw_IllegalStateException(env, | ||
| "sendWsFrame: pos/len out of array length"); | ||
| return; | ||
| } |
There was a problem hiding this comment.
The arr parameter is not checked for NULL before calling GetArrayLength. If a Java caller passes null, this will cause a JVM crash. A defensive NULL check should be added first.
if (arr == NULL) {
nxt_java_throw_IllegalStateException(env,
"sendWsFrame: array is null");
return;
}
cap = (*env)->GetArrayLength(env, arr);
if (pos < 0 || len < 0
|| pos > cap
|| len > cap - pos)
{
nxt_java_throw_IllegalStateException(env,
"sendWsFrame: pos/len out of array length");
return;
}| /* Trim this buf to fit, drop everything after it. */ | ||
| b->mem.free = b->mem.pos | ||
| + (size_t) h1p->remainder - trimmed; | ||
| b->next = NULL; | ||
| break; |
There was a problem hiding this comment.
Truncating the buffer chain by setting b->next = NULL without freeing the remaining buffers in the chain will leak resources (such as shared memory chunks if they are port mmap buffers). Free the rest of the chain before truncating.
/* Trim this buf to fit, drop everything after it. */
b->mem.free = b->mem.pos
+ (size_t) h1p->remainder - trimmed;
nxt_buf_t *next = b->next;
b->next = NULL;
while (next != NULL) {
nxt_buf_t *temp = next->next;
nxt_buf_free(task, next);
next = temp;
}
break;| hsize = nxt_websocket_frame_header_size(ws_impl->ws.header); | ||
|
|
||
| /* | ||
| * Reject truncated frames before reading the extended length / | ||
| * mask fields or advancing buf.free past buf.end. A 2-byte | ||
| * frame whose header advertises a 14-byte extended length would | ||
| * otherwise OOB-read b->buf.start + hsize - 4 (mask) and the | ||
| * 8-byte extended length, and break the buffer invariant. | ||
| */ | ||
| if (nxt_slow_path((size_t) (b->buf.end - b->buf.start) < hsize)) { |
There was a problem hiding this comment.
The function nxt_websocket_frame_header_size reads the first 2 bytes of the header to determine its size. If the buffer has fewer than 2 bytes, calling it will result in an out-of-bounds read. Defensively check that the buffer has at least 2 bytes first.
if (nxt_slow_path((size_t) (b->buf.end - b->buf.start) < 2)) {
nxt_unit_warn(ctx, "#%"PRIu32": truncated websocket frame: "
"buf size %zu < 2",
req_impl->stream,
(size_t) (b->buf.end - b->buf.start));
nxt_unit_websocket_frame_release(&ws_impl->ws);
return NXT_UNIT_ERROR;
}
hsize = nxt_websocket_frame_header_size(ws_impl->ws.header);
/*
* Reject truncated frames before reading the extended length /
* mask fields or advancing buf.free past buf.end. A 2-byte
* frame whose header advertises a 14-byte extended length would
* otherwise OOB-read b->buf.start + hsize - 4 (mask) and the
* 8-byte extended length, and break the buffer invariant.
*/
if (nxt_slow_path((size_t) (b->buf.end - b->buf.start) < hsize)) {| hsize = nxt_websocket_frame_header_size(b); | ||
|
|
||
| /* Same OOB-read hazard as nxt_unit_process_websocket(). */ | ||
| if (nxt_slow_path(hsize > size)) { |
There was a problem hiding this comment.
The function nxt_websocket_frame_header_size reads the first 2 bytes of the header to determine its size. If size < 2, calling it will result in an out-of-bounds read. Defensively check that size >= 2 first.
if (nxt_slow_path(size < 2)) {
nxt_unit_free(ws->req->ctx, b);
return NXT_UNIT_ERROR;
}
hsize = nxt_websocket_frame_header_size(b);
/* Same OOB-read hazard as nxt_unit_process_websocket(). */
if (nxt_slow_path(hsize > size)) {| if (getsockopt(c->socket.fd, SOL_SOCKET, SO_PEERCRED, &cred, &len) | ||
| != 0) | ||
| { |
There was a problem hiding this comment.
|
@claude review once |
|
Claude finished @TuzelKO's task in 6m 33s —— View job Code Review — PR #85: 1.35.6 Hardening Stack
This is a substantial security hardening PR. The broad shape is sound, and most fixes are correct. The Gemini Code Assist findings I verified below are all real bugs that survived this PR's review cycle and should be fixed before merge. Critical / High — Must Fix1.
|
75e3b77 to
ca2fa45
Compare
Summary
The consolidated 1.35.6 security hardening — the code remediations for a 14-vector security audit (catalogued in andypost/unit#10,
security-audit.md), organized as one commit per area (the PR-A..PR-I stack), plus three follow-up fixes from automated review.CHANGESis updated; there is no behavioral change beyond the documented hardening.Remediations (PR-A..PR-I)
0e194531fix(websocket)— frame-bound checks in libunit + protocolf1ff3769fix(port)— bounds-check untrusted shmem offsets03e6b0eefix(isolation)— control-socket / mount / cgroup boundaries84c340fafix(tls)— cert/key pair + wildcard SAN matcherSSL_CTX_check_private_key, wildcard SAN OOB73e4190bfix(fd)— FD/CLOEXEC lifetime (accept/pipe/compression)f2596110fix(http)— proxy Content-Length + URI/string helpersnxt_is_complex_uri_encodedoff-by-one887e7949fix(sapi)— bounds-check app-supplied argumentsb59c55b1fix(controller)— JSON parser caps + request staterack.inputbleed499de4b2fix(routing)— CIDR / regex / port-range matcher edges6b8e72cedocs(changes)— record 1.35.6 hardening fixesFollow-up fixes (from automated review)
bb58aed6f1ff3769(V10)nxt_unit_sptr_in_buf()buf_size - sizeof()underflowed for sub-struct buffers, bypassing the OOB guard. Security.6dde1b4d03e6b0ee(V6)<rootfs>-helper) → spurious ENOENT / startup abort. Require a/separator.ca2fa45b73e4190b(V14)pipe2()probed compile-only → ENOSYS broke pipe creation on older kernels. Fall back topipe()+FD_CLOEXEC.Notes / scope
security-audit.mdfor the per-vector exploitability analysis.go-1.25test_go_isolation_rootfs_automount_tmpfs[_regression]is the rootfs automount reload race fixed separately in fix(isolation): close rootfs automount reload race (#83) #84 (approved). Once fix(isolation): close rootfs automount reload race (#83) #84 lands onpre-1.35.6, this branch rebases and that job goes green; the hardening here is independent of it.unitd+ language modules link; verified locally for the daemon and the PHP module). Fullbuild-testmatrix runs on this PR.pre-1.35.6.🤖 Generated with Claude Code