Dmitry Volyntsev [Wed, 19 Aug 2026 19:50:59 +0000 (12:50 -0700)]
Fetch: fix empty response status text handling
A status line with an empty reason phrase terminated by a bare LF left
the status text start unset. The resulting invalid string slice crashed
the worker when JavaScript read Response.statusText.
Initialize both ends of an empty status text before returning from the
parser. This also avoids subtracting NULL pointers for status lines
without a reason phrase.
Dmitry Volyntsev [Thu, 13 Aug 2026 03:58:26 +0000 (20:58 -0700)]
XML: fix off-by-one in namespace prefix list parsing
njs_xml_parse_ns_list() allocated exactly size pointers, but the
terminating NULL was stored at the index equal to the number of parsed
tokens. When the token count matched the current capacity, the
terminator was written one pointer past the allocation.
The growing loop is replaced with a single allocation holding the array
of pointers followed by the writable copy of the list, the number of
tokens is counted beforehand.
The list is split on any XML whitespace character, empty tokens are not
produced.
Also, fixed potential in place modification of QuickJS string value.
Reported by Vladimir Vulnerability Research Tech Lead @ Cyera, evilgensec
Dmitry Volyntsev [Tue, 11 Aug 2026 01:14:38 +0000 (18:14 -0700)]
HTTP: fail closed on js_access body completion errors
Previously, an exception in an asynchronous readRequest*()
continuation could leave the js_access decision unset, allowing nginx
to continue processing the request as though the access check had
succeeded. An unhandled rejection could also be missed by the body-read
completion path or hidden by a pending internal redirect.
Now body-read completion errors set the access status to 500, and
access finalization checks execution errors before an uncommitted
access decision or internal redirect. Responses already sent by the
handler remain terminal.
Dmitry Volyntsev [Mon, 17 Aug 2026 16:33:34 +0000 (09:33 -0700)]
CI: bump the pinned quickjs-ng version
A static library has to be asked for explicitly since 0.16.0, otherwise
the library is installed as a shared object outside of the loader search
path, the configure test binary fails to start and the library is
reported as not working.
Dmitry Volyntsev [Mon, 17 Aug 2026 16:33:34 +0000 (09:33 -0700)]
QuickJS: fix empty stack in errors created by the host
An error created outside of a JS frame, from a fetch() completion
callback for instance, records an empty stack. Up to QuickJS-NG 0.15.1
deleting the recorded "stack" property let the engine attach a real one
where the error was thrown. Since 0.16.0 the stack is kept in the error
internal data behind an accessor, the property to delete no longer
exists and the empty stack became final.
JS_NewError() is JS_NewObjectClass() plus the stack recording, and the
first half alone is reachable through the public API. Creating the
object that way records nothing, so the engine attaches the stack at the
throw site. This works the same way for every supported engine, hence
the feature test is gone.
Dmitry Volyntsev [Mon, 17 Aug 2026 16:33:34 +0000 (09:33 -0700)]
QuickJS: fix compatibility with quickjs-ng 0.16.0 and later
Two separate problems prevented njs from working with the recent
quickjs-ng releases.
JS_NewArrayBuffer() has grown a "max_len" argument and its free callback
has become a realloc one. The calls are wrapped now, the callback has
to implement the whole realloc contract, because transfer() reallocates
the data even for a buffer which is not resizable.
The class ids used by njs started at 64, while the engine reserves the
ids below JS_CLASS_INIT_COUNT, which has reached 67. JS_NewClass()
failed with a collision and the context could not be created. The ids
are moved above the engine range and the remaining unchecked
JS_NewClass() calls now report the failure.
Dmitry Volyntsev [Fri, 14 Aug 2026 04:22:40 +0000 (21:22 -0700)]
Modules: make context clone setup transactional
A request can fail after a VM has been cloned, for example while
creating its request wrapper or installing the nginx pool cleanup.
The failed VM was neither destroyed nor returned to the reuse queue,
and a partially evaluated module could leave timers armed against it.
A second initialization attempt could then access rejection state from
the destroyed VM.
Keep each clone private until setup succeeds. On failure, cancel its
events, detach request state, and destroy the VM and engine pool for
both engines. Also clean up partially initialized QuickJS contexts and
wrapper objects.
Dmitry Volyntsev [Fri, 14 Aug 2026 03:50:52 +0000 (20:50 -0700)]
QuickJS: discard contexts with pending jobs
A job exception stops the teardown drain immediately. Jobs left behind
could then run after the context was bound to another request. Releasing
events and root values after the drain could also enqueue new jobs with
the same result.
Treat a successful, complete job drain as a precondition for context
reuse. Destroy the runtime when the drain fails or the queue becomes
nonempty during the remaining teardown.
Dmitry Volyntsev [Fri, 14 Aug 2026 03:41:33 +0000 (20:41 -0700)]
QuickJS: keep reused contexts in their origin queue
After an internal redirect, HTTP cleanup selected the destination
location configuration. If the source and destination used different
module graphs, the source context was inserted into the destination
reuse queue. A later request could then run a destination handler in
the source runtime, returning an incorrect response or a 500 error.
Record the originating location configuration in each cloned JavaScript
context and use it during teardown. Source and destination reuse queues
now remain isolated even though internal redirects mutate the request's
current location.
Dmitry Volyntsev [Fri, 14 Aug 2026 02:23:41 +0000 (19:23 -0700)]
QuickJS: mark nginx wrapper references during GC
The HTTP request and stream session wrappers hold JavaScript references
that were invisible to the QuickJS cycle collector. For example, this
creates a cycle through the native request wrapper:
function content(r) {
r.args.request = r;
r.return(200);
}
When the context was destroyed, the collector could leave this graph in
the runtime, causing a worker abort in an assertion-enabled build or a
leak otherwise. Cached request and response bodies had the same
ownership problem. A stream callback capturing its session formed an
equivalent native cycle.
Add gc_mark callbacks symmetric with the class finalizers. Stream
callbacks can now rely on normal cycle collection, so remove the special
teardown path that released them before the common exit-hook and pending
job processing.
Dmitry Volyntsev [Fri, 14 Aug 2026 02:10:28 +0000 (19:10 -0700)]
QuickJS: make Fetch finalization pool-independent
QuickJS may defer finalization of a Fetch object until after its native
backing pools have been destroyed. Context reuse is not required: a
local JavaScript cycle is sufficient.
function content(r) {
const response = new Response("body");
response.self = response;
r.return(200);
}
With context reuse disabled, the cycle is collected while
JS_FreeContext() destroys the per-request context. At that point the
engine pool has already been destroyed. The Response finalizer accessed
the pool-backed native structure and attempted to destroy its response
chain, causing a use-after-free. Request finalization had the same
lifetime dependency through its cached Headers value.
The fix introduces a QuickJS-runtime-owned wrapper for Fetch Request and
Response objects. It owns the cached Headers value and keeps only a
non-owning pointer to the pool-backed data. Finalizers now release only
runtime-owned state, while response chains continue to be reclaimed in
bulk with the engine pool.
Dmitry Volyntsev [Thu, 13 Aug 2026 22:14:42 +0000 (15:14 -0700)]
HTTP: fix missing validation of the r.return() redirect target
Previously, for a redirect status ngx_http_send_response() stores the
text passed to r.return() as the Location header value, but the text was
only checked when it was used as a response body, where control
characters are valid.
The text is now validated as a header value for the statuses that turn
it into Location, and is left as is otherwise.
Dmitry Volyntsev [Thu, 13 Aug 2026 21:48:02 +0000 (14:48 -0700)]
HTTP: fix missing validation of r.headersOut values
Previously, a property name used to store a response header was copied
into the header list as is, without checking it against the token
grammar, so a name containing control characters produced a malformed
response.
The name is now validated in the two places where it is resolved for a
store: ngx_http_js_ext_header_out() and its QuickJS counterpart, which
also covers Object.defineProperty(). An invalid name is rejected with a
TypeError.
Previously, values assigned to r.headersOut were stored as is, without
checking them for control characters, while ngx_js_check_header_value()
was applied only on the Fetch Headers path.
The conversion of a JS value into a header value is now done by
ngx_js_header_value() and ngx_qjs_header_value(), which validate the
result. All the r.headersOut store paths, including the special headers
and Content-Type, use them and reject an invalid value with a TypeError.
Dmitry Volyntsev [Tue, 11 Aug 2026 01:55:16 +0000 (18:55 -0700)]
Exclude development files from source archives
Add .gitattributes with "export-ignore" attributes, so that agent
instructions, CI configuration and the release machinery are not part
of the tarballs created by "git archive", including the ones GitHub
generates for a tag.
Dmitry Volyntsev [Tue, 11 Aug 2026 01:54:49 +0000 (18:54 -0700)]
Build: remove Mercurial leftovers
The "dist" target invoked "hg archive" and has been broken since the
migration to git. For the same reason ts/COMMITHASH ended up empty in
every njs-types package published after the migration.
The .hgignore and .hgtags files are stale as well.
Dmitry Volyntsev [Thu, 13 Aug 2026 00:22:55 +0000 (17:22 -0700)]
Fetch: preserve QuickJS header names
Previously, Headers.set() converted the header name with
JS_ToCStringLen(). When a new header was added, the list retained that
pointer after it was released with JS_FreeCString(), leaving the header
with a dangling name.
The fix is to copy the name into the request pool only when set() creates
a new list entry. Replacing an existing entry requires no additional
allocation, and the temporary QuickJS string can always be released
after the mutation.
Dmitry Volyntsev [Thu, 13 Aug 2026 00:22:31 +0000 (17:22 -0700)]
Fetch: centralize Headers mutations
Previously, Headers.set() validated its arguments only when the header
did not already exist. Existing values were overwritten without
trimming or validating the new value and without checking the immutable
guard. It also detached duplicate values only from the first header's
chain, while leaving the corresponding list entries active. The stale
values could reappear when the Headers object was copied or serialized.
The fix is to move append(), set(), and delete() validation and list
updates into engine-independent helpers, deactivate every duplicate on
replacement, reject empty names, and enforce immutable guards
consistently. Content-Type presence is now derived from the header list
instead of a separate cached pointer.
Dmitry Volyntsev [Wed, 12 Aug 2026 00:13:13 +0000 (17:13 -0700)]
Webcrypto: fix QuickJS build with OpenSSL < 1.1.0
The issue was introduced in 75ca26f83471 (0.8.10).
The QuickJS WebCrypto implementation called BN_bn2binpad() and
ECDSA_SIG_set0() directly, although compatibility wrappers were already
used by the njs implementation. It also referenced an njs-only
string_info variable when HKDF was unavailable.
Use the shared OpenSSL compatibility wrappers and remove the stale
reference.
Tomas Psota [Mon, 27 Jul 2026 13:17:16 +0000 (15:17 +0200)]
Webcrypto: fix RSA-OAEP digest being ignored for non-SHA-1 hashes
The issue was introduced in 8cd5ff2fa413 (0.7.0), which replaced
EVP_PKEY_CTX_set_rsa_oaep_md() with EVP_PKEY_CTX_set_signature_md()
to restore building with OpenSSL <= 1.0.1.
The signature digest control is not valid for encryption or decryption.
Its unchecked failure left the OAEP digest at the SHA-1 default while
MGF1 used the requested hash. As a result, RSA-OAEP with a non-SHA-1
hash produced a non-standard digest pair that only interoperated with
njs itself.
Use the proper OAEP digest control and check all RSA context setters.
Preserve compatibility with older OpenSSL and LibreSSL versions by
supporting RSA-OAEP with SHA-1 and explicitly rejecting unsupported
digests.
Add externally generated SHA-256 and SHA-384 ciphertexts to ensure the
tests verify interoperability instead of an njs-only round trip.
Modules: fix SharedDict.pop() for unexpired keys in timeout zones
Previously, SharedDict.pop() removed an entry from the expiration rbtree
before checking whether it had expired. ngx_rbtree_delete() clears the
removed node's key, so the subsequent expiration check treated every
entry in a timeout zone as expired. pop() therefore deleted unexpired
entries but returned undefined instead of their values.
The fix preserves the expiration key before removing the node and uses
the saved value for the check in both the njs and QuickJS
implementations. Tests are added for popping an unexpired entry from a
timeout zone.
Test262: accept ENOTEMPTY and EEXIST in fs/rename.t.mjs
Renaming a regular file onto an existing directory is permitted by POSIX
to fail with EISDIR, ENOTDIR, ENOTEMPTY or EEXIST, and the exact errno
depends on the kernel and filesystem. The test only accepted ENOTDIR
and EISDIR, so it began failing on the ubuntu-26.04 CI runners whose
newer kernel returns ENOTEMPTY (or EEXIST) for this case.
Accept ENOTEMPTY and EEXIST in all three variants (sync, callback and
promise based) to make the test robust across kernels while still
rejecting genuinely unexpected errors.
QuickJS: fix unhandled rejection tracking in reused contexts
ngx_qjs_destroy() unregisters the host promise rejection tracker on the
runtime, but ngx_qjs_clone() did not re-register it when a context was
popped from the reuse queue. Every request served by a reused context
then silently dropped unhandled rejections instead of reporting them,
which also defeated any caller relying on ctx->rejected_promises.
Dmitry Volyntsev [Fri, 12 Jun 2026 01:20:01 +0000 (18:20 -0700)]
Webcrypto: fix buffer overflow when exporting large keys to JWK
njs_export_base64url_bignum() and qjs_export_base64url_bignum() wrote
BN_num_bytes() bytes into a fixed 512-byte stack buffer without a bound
check. An RSA key with a modulus larger than 4096 bits (over 512 bytes),
generated or imported via SPKI/PKCS8, overflowed the buffer during
exportKey("jwk").
While here, add exception throwing on a parallel import part in both
engines.
Dmitry Volyntsev [Thu, 25 Jun 2026 00:28:02 +0000 (17:28 -0700)]
QuickJS: add missing btoa() and atob() and improve njs version
The functions implement the WHATWG btoa()/atob() global functions for
the QuickJS engine, mirroring the built-in njs engine.
Both engines decode forgiving-base64 in a single pass using a bit
accumulator, without allocating a scratch buffer for the
whitespace-stripped copy.
The shared behaviour is corrected and aligned with WHATWG:
- atob() strips all ASCII whitespace (TAB, LF, FF, CR, SPACE), not only
SPACE; VT and NBSP are not ASCII whitespace and are rejected.
- btoa() error text says "(> U+00FF)" to match the cp > 0xff check.
The narrower njs-only btoa()/atob() unit tests are dropped in favor of
test/btoa.t.js, which exercises both engines.
Dmitry Volyntsev [Fri, 12 Jun 2026 01:59:36 +0000 (18:59 -0700)]
Honor source view offset in same-type copies
The same-type fast paths in the typed array constructor, slice(),
toReversed() and toSorted() copied from the start of the source buffer
instead of the view base, ignoring the source view's byte offset. For an
offset or subarray source they copied the wrong elements and could read
past the view into adjacent data.
Use njs_typed_array_start()/njs_typed_array_offset() for the copy source.
Dmitry Volyntsev [Fri, 12 Jun 2026 01:44:01 +0000 (18:44 -0700)]
Fix Array.prototype.slice() of large arrays in the non-fast keys path
When the slice result length was 32761 or more, the destination array was
non-fast and slice_copy() reached the keys path, which enumerated every
own index of the source and copied each at its original key, ignoring the
requested [start, start + length) window and the destination-relative
position. Array.prototype.slice() thus returned wrong results for such
arrays.
Filter the enumerated keys to the window and write them at index - start,
matching the fast-array path.
While here, also remove dead fast object path in slice_copy.
Dmitry Volyntsev [Fri, 12 Jun 2026 01:36:22 +0000 (18:36 -0700)]
QuickJS: fix infinite loop on inflate of a stream needing a dictionary
The qjs inflate loop treated only rc < 0 as an error. Z_NEED_DICT is
positive, so a zlib stream with the FDICT flag and no dictionary made
inflate() return Z_NEED_DICT with no progress every iteration, spinning
forever and growing the output chain on attacker-controlled input.
Handle Z_NEED_DICT explicitly, matching the njs zlib module.
Dmitry Volyntsev [Fri, 12 Jun 2026 01:34:26 +0000 (18:34 -0700)]
Buffer: fix infinite loop when filling from a zero-length typed array
njs_buffer_fill_typed_array() advanced the destination by
njs_min(byte_length, end - to) each iteration. When the fill source
typed array was empty, the step was always zero and the loop spun
forever, hanging the worker on attacker-controlled input.
Zero-fill the range for an empty source, matching njs_buffer_fill_string()
and the QuickJS qjs_buffer_fill().
Dmitry Volyntsev [Fri, 12 Jun 2026 01:31:27 +0000 (18:31 -0700)]
Buffer: fix type confusion in concat() with element getters
njs_buffer_concat() validated each list element as a typed array in the
length pass, then re-read the same elements with njs_value_property_i64()
in the copy pass and cast them with njs_typed_array() without rechecking.
For a non-fast array with an accessor element, a getter returning a typed
array during validation and a non-typed-array (or a detached buffer)
during the copy yielded a wild pointer and an out-of-bounds read.
Revalidate the type and detached state in the copy pass, matching the
length pass and the QuickJS implementation.
Dmitry Volyntsev [Fri, 12 Jun 2026 01:24:28 +0000 (18:24 -0700)]
Buffer: fix out-of-bounds access in readInt/writeInt with zero byteLength
The variable-length readIntLE/UIntLE/BE and writeIntLE/UIntLE/BE only
rejected byteLength > 6. byteLength == 0 passed, the bounds check
"size + index > byte_length" degenerated to "index > byte_length", and
switch (size) fell through to the 6-byte arm, reading or writing 6 bytes
past an attacker-chosen offset. Require byteLength in [1, 6] as Node does.
Dmitry Volyntsev [Mon, 15 Jun 2026 16:24:41 +0000 (09:24 -0700)]
HTTP: fix use-after-free in r.subrequest() on premature client close
Previously, since njs subrequests are background subrequests that nginx
does not wake on completion, the handlers ngx_http_js_subrequest_done()
and ngx_http_qjs_subrequest_done() woke the parent request themselves,
synchronously, via ngx_http_run_posted_requests(), from within
ngx_http_finalize_request() of the subrequest. When the downstream client
had already aborted, that re-entered request processing and freed the
request pool shared by all subrequests before finalization unwound; the
freed subrequest was then dereferenced in ngx_http_post_action()
(heap-use-after-free).
This handler is unlike the other event completions. Timers and
ngx.fetch() run their callbacks from their own event handlers, where
draining posted requests is safe; the subrequest completion handler runs
while the subrequest is still being finalized, where it is not. The wake
helper is shared, but this caller must be treated differently.
Before 0.8.1 the parent was woken by posting the request only; 0ee01840
(0.8.1) added a synchronous ngx_http_run_posted_requests(), which let an
inner run free the request while an outer one was still unwinding. d34fcb0 (0.8.5) dropped the synchronous run and posted the connection
write event instead, fixing a nested-run use-after-free when a subrequest
callback threw; but that lost the wake when the handler runs as a
subrequest of another module (lua), whose connection write handler is not
ours. 75d6b61 (0.9.5) reverted to posting the request plus the
synchronous run to fix the lost wake, reintroducing the use-after-free,
now seen on a premature client close.
The fix is to post the parent request without running posted requests in
place, as it was done before 0.8.1.
Dmitry Volyntsev [Wed, 10 Jun 2026 21:52:23 +0000 (14:52 -0700)]
Fix use-after-free in Array.prototype.sort()
Previously, njs_sort_indexed_properties() cached the fast array backing
store pointer (array->start) once and then iterates. For a hole it
performed a generic property lookup that walks the prototype chain and
may invoke a user-defined getter. A getter that grows the same array
triggers njs_array_expand(), which reallocates the backing store and
frees the old buffer, leaving the cached pointer dangling. The
remaining iterations then read freed memory, and the stale values are
sorted and written back, exposing freed heap contents to script.
Dmitry Volyntsev [Fri, 12 Jun 2026 03:57:14 +0000 (20:57 -0700)]
HTTP: fix segfault reading a request header with no cache slot
Reading a request header via r.headersIn[name] could crash the worker
with SIGSEGV. The header in question was "Proxy-Connection", but any
header that nginx registers without a dedicated ngx_table_elt_t * slot
was affected.
The getter mapped an arbitrary header name through ngx_hash_find() and
treated (char *) &r->headers_in + hh->offset as a pointer to a cache
slot. This only holds for headers whose parser stores into such a
slot. "Proxy-Connection" is registered in ngx_http_headers_in[] with
offset 0 and a handler that stores nothing, so the getter aliased the
ngx_list_t at the start of ngx_http_headers_in_t and followed its elts
pointer as a bogus ngx_table_elt_t.
The fix is to walk the parsed header list by name, as it is always safe.
The single-value and semicolon-join flags moved into the per-name header
table, and the now-unused slot pointer argument and its branch were
dropped from the generic getter.
The slot lookup that is removed was only an optimization for the names
in ngx_http_headers_in[]; for any other header it missed the hash and
walked the list anyway, so dropping it is not a performance regression.
Dmitry Volyntsev [Thu, 11 Jun 2026 23:50:23 +0000 (16:50 -0700)]
Fetch: fix Content-Length reservation in request building
Previously, the reservation passed to njs_chb_sprintf() was too
small for the maximum size_t output, so the header could be
silently truncated for very large request bodies.
Dmitry Volyntsev [Thu, 11 Jun 2026 23:48:37 +0000 (16:48 -0700)]
Fetch: fix missing CONNECT request terminator without proxy auth
Previously, ngx_js_http_build_connect_request() emitted the blank
line terminating the request headers only when a proxy auth header
was present. An HTTPS request through a proxy configured without
credentials sent a CONNECT request with no terminating blank line,
so the proxy kept waiting for more headers and the fetch timed out.
Dmitry Volyntsev [Fri, 12 Jun 2026 00:06:32 +0000 (17:06 -0700)]
Fetch: fix missing event cleanup when resolver start fails
Previously, when the resolver context allocation failed, both engines
returned without deleting the registered fetch event and reported the
error with a synchronous throw instead of rejecting the returned
promise, unlike all other failure paths.
Dmitry Volyntsev [Fri, 12 Jun 2026 00:34:23 +0000 (17:34 -0700)]
QuickJS: fix fetch() init property value leaks
Previously, the values of the buffer_size, max_response_body_size
and verify init properties were not released after conversion,
leaking heap values such as objects with a valueOf() method.
Dmitry Volyntsev [Thu, 11 Jun 2026 23:46:43 +0000 (16:46 -0700)]
QuickJS: fix promise and event leak on proxy URL evaluation failure
Previously, when dynamic proxy URL evaluation failed in
ngx_qjs_fetch(), the error was returned directly, bypassing the fail
path which deletes the registered fetch event and releases the
promise. The njs counterpart already goes through the fail path.
As a side effect, the error is now reported by rejecting the
returned promise instead of throwing synchronously, consistently
with the other failure paths.
Dmitry Volyntsev [Thu, 11 Jun 2026 04:20:04 +0000 (21:20 -0700)]
QuickJS: fix body_read_len mismatch in QuickJS readRequestJSON()
Previously, for the JSON body type without a NUL terminator,
ngx_http_qjs_body_to_value() round-tripped the raw body through
qjs_string_create() (JS_NewStringLen) and JS_ToCString(), then parsed
the result with JS_ParseJSON() using the original body_read_len.
JS_NewStringLen() collapses invalid UTF-8 (a run of continuation bytes
becomes a single U+FFFD), so the byte length of the C string no longer
matches body_read_len. With a body containing such bytes the parser was
handed the wrong length and a valid body was truncated and rejected as a
SyntaxError.
The fix is to use JS_ToCStringLen() to capture the actual C string
length and pass it to JS_ParseJSON().
Dmitry Volyntsev [Thu, 11 Jun 2026 04:12:25 +0000 (21:12 -0700)]
Modules: fix out-of-bounds read when loading a shared dict file
Previously, ngx_js_dict_parse_entry() parsed numeric values with
strtod((char *) p, &p), which has no end awareness. The state
file loader allocated a buffer sized to the exact file length and
passed end = buf + len, so a numeric token whose digits ran to the
very end of the allocation (for example a truncated or tampered
state file ending in '"value":123') let strtod() read past the
buffer into adjacent pool memory.
NUL-terminate the loaded buffer so strtod() stops at the file end.
Dmitry Volyntsev [Thu, 11 Jun 2026 04:11:06 +0000 (21:11 -0700)]
Fix out-of-bounds read in Buffer.prototype.toString()
Buffer.prototype.toString(encoding, start, end) on the njs engine
clamped start and end independently to the buffer length but never
enforced start <= end. With start > end the unsigned subtraction
end - start underflowed, the zero-length guard did not fire, and the
encoder read past the buffer.
Reject the start >= end case before computing the range, returning an
empty string to match Node.js semantics.
While here, add parallel missing functionality to QuickJS
for start and end arguments for Buffer.prototype.toString().
Overflow now trips during chain growth and surfaces as a catchable
RangeError("invalid string length") instead of continuing to inflate
the VM memory pool until the process OOMs.
Out of scope (non-JS-string chains):
- njs_vm.c AST serialization
- njs_json.c njs_vm_value_dump (console.log value dump)
- njs_function.c new Function() source assembly
- njs_error.c error message composition
- external/ modules on both engines (querystring, xml, zlib)
This fixes OOM in staging/sm/String/replace-math Tests262.
Add optional per-chain byte cap in chained buffers
Previously, njs_chb_t accumulated bytes unbounded through the memory
pool until the final string boundary (njs_string_create_chb()) enforced
the NJS_STRING_MAX_LENGTH limit. For string-producing chains this let
the pool grow to tens of GiB before the final alloc was refused, which
on adversarial inputs (such as String.prototype.replace with a regex
that matches the full string and a replacement containing thousands of
$N references) led to process OOM instead of a catchable RangeError.
The fix is to add two fields to njs_chb_t: total_size (O(1) running byte
count) and max_size (optional upper bound, 0 means unlimited).
Exceeding max_size puts the chain into a sticky NJS_CHB_ERR_OVERFLOW
state. As part of the change njs_chb_drain() now returns early on a
failed chain, in line with njs_chb_drop().
QuickJS property setter and define APIs consume the JSValue argument on
both success and failure. This applies to JS_SetProperty(),
JS_SetPropertyStr(), JS_SetPropertyUint32(), JS_DefinePropertyValue(),
JS_DefinePropertyValueStr(), and JS_DefinePropertyValueUint32().
Do not free values after passing them to these APIs, including failure
paths in shared cleanup labels. Target objects and containers are not
consumed by these calls and still need normal cleanup unless ownership
has been transferred by inserting them into another object.
Also fix adjacent cleanup paths that leaked target objects after a
setter consumed the value being attached.
Align common helper failures with the exception policy used by both engines:
invalid numeric conversion is TypeError, and missing external receiver/context
for common log helpers is TypeError rather than an internal host failure.
The numeric conversion message changes from "is not a number" to
"invalid number" to match the QuickJS helper text.
Report XML API misuse as TypeError and XML parse failures as SyntaxError,
aligning njs and QuickJS behavior.
The QuickJS message "'this' is not XMLNode or XMLDoc" is changed to
"value is not XMLNode or XMLDoc" because qjs_xml_node() is not always
validating this.
Report stream session API misuse as TypeError, including wrong receiver,
unknown or duplicate event handlers, wrong handler phase, and invalid variable
access. Report status bounds violations as RangeError and keep stream output
pipeline failures as InternalError.
Stream: fixed variable value state on allocation failure
Reset the variable value validity flag when allocating storage for a stream
variable fails. This matches the HTTP variable path and prevents a failed
assignment from leaving a half-initialized value marked as valid.
Report HTTP request API misuse as TypeError and status bounds violations as
RangeError. Keep nginx output and request body collection failures as
InternalError, since they are host/runtime failures rather than invalid
JavaScript arguments.
Set pending exceptions before returning NJS_ERROR from njs response output
paths. Previously sendHeader(), send(), and finish() could fail without an
exception being available to the VM.
Report API misuse in Fetch, Request, Response, and Headers as TypeError, and
report status bounds violations as RangeError. Keep internal host failures as
InternalError and preserve conversion helper exceptions where they already
provide the real cause.
Preserve exceptions raised by QuickJS conversions and synthesize an out of
memory exception only for silent pool allocation failures in ngx_qjs_string()
callers.
Use JS_GetOpaque() where a class mismatch is a normal miss, avoiding a stale
pending TypeError while continuing with non-Headers and non-Request input.
Also release the headers init value before throwing for a non-object Headers
option.
Wrong receiver errors are caused by JS API misuse, not by internal host
failures. Report TypeError for TextEncoder, TextDecoder, console, and
fs Stats receiver checks.
Dmitry Volyntsev [Fri, 29 May 2026 22:58:34 +0000 (15:58 -0700)]
Fetch: reject unsafe request methods
Reject empty methods, C0 control bytes, space, and DEL before a fetch
request can be serialized. Preserve existing forbidden-method checks,
common-method normalization, and valid server-side extension methods.
Dmitry Volyntsev [Thu, 28 May 2026 05:06:55 +0000 (22:06 -0700)]
Fetch: fix keepalive with disabled TLS verification
HTTPS connections established with certificate verification disabled are
now excluded from the Fetch keepalive cache. This prevents a later
verified request to the same destination from reusing a TLS connection
that was created with verification disabled.
Dmitry Volyntsev [Sat, 23 May 2026 01:18:39 +0000 (18:18 -0700)]
Fixed Buffer allocation length checks
On 32-bit platforms, where size_t is 32 bits, callers passing
int64_t lengths >= 2^32 to njs_buffer_alloc() had the value
silently truncated before reaching the UINT32_MAX check in
njs_array_buffer_alloc(), so Buffer.from({length: 0x100000000})
returned a zero-sized buffer instead of raising a RangeError.
Widened the size parameter to uint64_t so the value reaches
njs_typed_array_alloc() intact and the "invalid index" range
error fires consistently on 32-bit and 64-bit builds.
Buffer.concat() is protected by the same change: it likewise
reads an int64_t length from JS and forwards it to
njs_buffer_alloc() without an upper bound check of its own.
Dmitry Volyntsev [Fri, 22 May 2026 04:05:59 +0000 (21:05 -0700)]
QuickJS: fixed fetch error-handling typos
Returned the JS_ThrowOutOfMemory() result from
ngx_qjs_fetch_response_ctor() on ngx_list_init() failure: previously
the call result was discarded and execution fell through.
Replaced a stale "ret" check with "rc" after ngx_qjs_headers_fill(),
so a non-NGX_OK return is no longer ignored.
Added a NULL check after JS_ToCStringLen() on the header key in
ngx_qjs_headers_ext_keys(), and replaced a stale "value" check with
"item" when testing JS_NewStringLen() for failure.
Dmitry Volyntsev [Thu, 21 May 2026 23:01:56 +0000 (16:01 -0700)]
QuickJS: fixed Buffer.from() and encoding error paths
Freed the typed array constructor object after reading constructor.name
while detecting Float32Array in Buffer.from(). This keeps the exception
path from leaking the constructor object.
Handled JS_ToCString() failure for constructor.name before comparing the
name, avoiding a NULL dereference when the property converts to an
exception, such as a Symbol value.
Divided the source offset by the element size in qjs_buffer_from()
typed-array path so the offset addresses the right element for 2-, 4-,
and 8-byte element types (previously the offset was left in byte units
while size was already in element units).
Added a NULL check on JS_ToCStringLen() in qjs_buffer_encoding(), and
moved the JS_FreeCString() call after the JS_ThrowTypeError() so the
encoding name remains valid while the error message is formatted.
Routed array source errors in qjs_buffer_from_object() through a single
fail label so the destination buffer is freed once on every failure path
(previously leaked on three of them).
Dmitry Volyntsev [Wed, 20 May 2026 23:27:46 +0000 (16:27 -0700)]
Added agent developer guide.
The new top-level AGENTS.md (with CLAUDE.md symlinked to it) is the
canonical index for agent and contributor instructions. Detailed
guides live under docs/agent/:
docs/agent/engine-dev.md building, testing, debugging the engine
and the nginx modules.
docs/agent/js-dev.md writing JavaScript for either engine,
with the common nginx API surface and
engine differences.
docs/agent/js-dev-njs.md specifics of the deprecated njs engine
and migration to QuickJS.
Dmitry Volyntsev [Fri, 15 May 2026 23:28:47 +0000 (16:28 -0700)]
Fixed call argument value snapshotting.
Since fd5e523f (0.9.7), njs has evaluated all call argument expressions
before emitting the frame and PUT_ARG instructions. This fixed await
expressions in call arguments and preserved argument side effects before
callee validation, but left PUT_ARG reading argument values only after later
argument expressions had already run.
As a result, an earlier argument backed by a mutable variable slot could
observe a later mutation of the same slot. For example, f(a, a = 2) passed
2 as both arguments instead of preserving the first value as 1. The same
issue applied to later argument effects such as getters.
The fix is to preserve affected argument values in temporary registers where
appropriate.
njs_chb_destroy() frees chain nodes through chain->free() and guards
against a NULL free callback. njs_chb_drain() and the tail-freeing
path of njs_chb_drop() called njs_mp_free() directly, which is wrong
for chains initialized with NJS_CHB_CTX_INIT() where chain->free is
js_free(), and unsafe for NGX_CHB_CTX_INIT() chains where chain->free
is NULL.
Both paths now route through chain->free() with the same NULL guard
as njs_chb_destroy().
Dmitry Volyntsev [Thu, 14 May 2026 23:10:33 +0000 (16:10 -0700)]
HTTP: fixed internalRedirect() in js_access.
Previously, r.internalRedirect() only stored the redirect URI and set
ctx->status to NGX_DONE. The stored URI was handled only by the js_content
finalization path.
As a result, when internalRedirect() was called from js_access, the access
handler returned NGX_DONE to the phase engine without starting the redirect,
leaving the request unfinished.
Now the internal redirect handling is shared by js_access and js_content, so
internalRedirect() works consistently in both handlers.
Dmitry Volyntsev [Thu, 14 May 2026 02:06:24 +0000 (19:06 -0700)]
HTTP: fixed r.return() with body in js_access.
Previously, when a js_access handler called r.return(status, body),
ngx_http_send_response() sent the response but returned NGX_OK. The
access handler then returned NGX_OK to the phase engine, which treated
the access check as allowed and continued to the content phase.
As a result, a denied request could still reach proxy_pass or another
content handler after the response had already been sent.
Now the access handler finalizes the request if a response was already
sent during js_access execution. This keeps r.return() behavior in
js_content unchanged, while making r.return(status, body) terminal in
js_access.
Dmitry Volyntsev [Thu, 14 May 2026 00:41:05 +0000 (17:41 -0700)]
Improved error stack frame handling.
Native frames always have an associated function. Keep the function
lookup in the native-frame branch to make the invariant explicit and to
avoid confusing static analysis.
Dmitry Volyntsev [Tue, 12 May 2026 23:52:56 +0000 (16:52 -0700)]
Fetch: fixed heap buffer overflow in proxy URL credentials.
The destination buffers for the decoded user and password in
ngx_js_parse_proxy_url() were a fixed 128 bytes, while the encoded
input length was bounded only by the URL length. Since
ngx_unescape_uri() writes at most one byte per input byte, raw
credentials longer than 128 bytes overflowed the buffer; the
length check ran only after the decode.
The fix is to size the destination buffer based on the encoded
input length.
The async method parses the client request body as an HTML form
and returns a Promise resolving to a form object with get(),
getAll(), has(), forEach(), hasFiles() accessors.
Supports "application/x-www-form-urlencoded" and "multipart/form-data"
content types. File parts are detected but their contents are not
exposed. An optional maxKeys option caps the number of fields.
File parts are detected but their contents are not exposed. A
proper File API with streaming Blob semantics is a significant amount
of work and is out of scope.
Dmitry Volyntsev [Sat, 28 Mar 2026 00:33:37 +0000 (17:33 -0700)]
HTTP: added js_access directive.
The directive registers a JavaScript handler in the access phase,
running after built-in access checkers (allow/deny, auth_basic,
auth_request). r.subrequest(), ngx.fetch() and other async operations
are supported.
The handler defaults to NGX_OK (access granted) on normal completion,
matching the behavior of other access phase modules. The r.decline()
method allows the handler to return NGX_DECLINED (no opinion), deferring
the decision to other access checkers under "satisfy any".
The r.return() method can send any HTTP response from the access phase,
including 3xx redirects for authentication flows.