]> git.kaiwu.me - njs.git/log
njs.git
2 months agoVM: clean up failed AST serialization
Dmitry Volyntsev [Thu, 3 Sep 2026 15:53:25 +0000 (08:53 -0700)]
VM: clean up failed AST serialization

2 months agoParser: release non-global semantic state
Dmitry Volyntsev [Thu, 3 Sep 2026 15:53:14 +0000 (08:53 -0700)]
Parser: release non-global semantic state

Allocate non-global scopes, local variables, variable tree nodes, references,
and declaration arrays from the compilation pool.  The global scope and its
symbol table remain persistent for runtime lookup and later compilation.

Build closure-index arrays temporarily, then copy the finalized indexes into
VM-owned storage referenced by each lambda.  Preserve declaration-origin type
without retaining pointers to released block scopes.

2 months agoGenerator: release temporary compilation memory
Dmitry Volyntsev [Thu, 3 Sep 2026 15:53:14 +0000 (08:53 -0700)]
Generator: release temporary compilation memory

Use the parser invocation pool for generator stack entries, copied contexts,
blocks, jump patches, index caches, and traversal scratch.  Nested function
generators share the same temporary pool, while generated code and runtime
metadata remain in the VM pool.

2 months agoParser: release temporary compilation memory
Dmitry Volyntsev [Thu, 3 Sep 2026 15:53:03 +0000 (08:53 -0700)]
Parser: release temporary compilation memory

Create a pool for each parser invocation and allocate parser nodes, array-item
storage, continuations, preread tokens, and active labels from it.  Destroy the
pool after generation and on parser or generator failures.

Keep atoms, lambdas, regexp patterns, scopes, variables, bytecode, and runtime
constants in the VM pool.  Clear persistent scope AST roots before releasing
the temporary pool.

2 months agoParser: shrink variable flags
Dmitry Volyntsev [Thu, 3 Sep 2026 00:39:41 +0000 (17:39 -0700)]
Parser: shrink variable flags

2 months agoParser: store statement labels as atom identifiers
Dmitry Volyntsev [Thu, 3 Sep 2026 00:39:41 +0000 (17:39 -0700)]
Parser: store statement labels as atom identifiers

2 months agoParser: use compact active labels
Dmitry Volyntsev [Thu, 3 Sep 2026 00:39:41 +0000 (17:39 -0700)]
Parser: use compact active labels

2 months agoParser: reduce variable reference payload
Dmitry Volyntsev [Thu, 3 Sep 2026 00:39:41 +0000 (17:39 -0700)]
Parser: reduce variable reference payload

Store the atom identifier and resolved variable directly in parser nodes and
move the undefined-reference flag into the common node flags.  This removes the
separately allocated reference object and helps reduce parser nodes to the
64-byte memory-pool class.

2 months agoParser: compact array literal elements
Dmitry Volyntsev [Thu, 3 Sep 2026 00:39:41 +0000 (17:39 -0700)]
Parser: compact array literal elements

The generic object-property lowering used six parser nodes and several
generator continuations for each array element.  Store array values and indexes
in compact records and emit a dedicated immediate-index initializer instead.

This also avoids interning numeric element indexes and generates elements in
source order with constant continuation depth.  Keep template backing arrays
flat because template concatenation requires contiguous storage.

2 months agoFix large array literal initialization
Dmitry Volyntsev [Thu, 3 Sep 2026 00:39:41 +0000 (17:39 -0700)]
Fix large array literal initialization

Large literals switch to slow arrays above the fast-array capacity limit, but
the previous initialization path left their indexed values unset.  Initialize
slow arrays directly so literals above 32760 elements preserve their contents.

2 months agoTests: add parser stress benchmarks
Dmitry Volyntsev [Thu, 3 Sep 2026 00:39:41 +0000 (17:39 -0700)]
Tests: add parser stress benchmarks

Add generated workloads for large array literals, declarations, references,
closures, and labels.  These tests are intended to expose catastrophic parser
scaling and compare compilation changes; source construction and Function()
compilation are both included in the measured interval.

2 months agoFix Array.prototype.sort() with huge sparse arrays
Dmitry Volyntsev [Fri, 4 Sep 2026 01:48:39 +0000 (18:48 -0700)]
Fix Array.prototype.sort() with huge sparse arrays

Previously, sort deleted every index from the compacted output position to
the original length.  A sparse array with length near 2^32 therefore caused
billions of property-delete operations and unbounded memory growth.

The fix is to retain the direct cleanup loop for fast arrays and, for slow
arrays, enumerate and delete only existing indexed properties in the range.

The issue was introduced in 1a2a65f7 (0.8.0).

This closes #1095 issue on GitHub.

2 months agoFix reentrant object-to-primitive conversion
Dmitry Volyntsev [Fri, 4 Sep 2026 01:48:39 +0000 (18:48 -0700)]
Fix reentrant object-to-primitive conversion

Previously, njs_value_to_primitive() retained a pointer to the caller's value
slot while valueOf(), toString(), or property getters executed user code.
Reentrant code could overwrite that slot, after which the conversion treated
the replacement primitive as the original object.

The fix is to snapshot the input value before the first observable property
lookup and use the stable copy throughout conversion.

The issue was introduced in 64dbf104 (0.3.4).

This closes #1094 issue on GitHub.

2 months agoTypedArray: fix numeric property definitions
Dmitry Volyntsev [Fri, 4 Sep 2026 01:48:39 +0000 (18:48 -0700)]
TypedArray: fix numeric property definitions

Previously, integer-indexed exotic validation was restricted to keys marked
as strings.  Numeric atoms and the canonical string "NaN" could bypass the
check and be installed as ordinary typed-array properties.

The fix is to validate numeric atoms and canonical numeric strings after key
conversion, and to remove the obsolete source-type flag.

The numeric-key bypass was introduced in b28e50b1 (0.9.0); the incomplete
canonical-index handling dates to bf208048 (0.3.8).

This closes #1098 issue on GitHub.

2 months agoRegex: fix decoding of named capture indexes
Dmitry Volyntsev [Fri, 4 Sep 2026 01:48:11 +0000 (18:48 -0700)]
Regex: fix decoding of named capture indexes

Previously, PCRE name-table capture indexes were decoded through plain char.
On signed-char targets, bytes with the high bit set were sign-extended and
produced a wild or incorrect capture index used to read a match array.

The fix is to decode unsigned bytes through a checked output parameter and to
validate every named capture index against the compiled capture count.

The issue was introduced in 9496944a (0.3.2).

This closes #1088 issue on GitHub.

2 months agoFix Object.defineProperty() with reentrant descriptors
Dmitry Volyntsev [Fri, 4 Sep 2026 01:48:11 +0000 (18:48 -0700)]
Fix Object.defineProperty() with reentrant descriptors

Previously, njs_object_prop_define() retained a pointer to a target hash
property while descriptor getters executed user code.  Reentrant mutation of
the target could resize the flat hash and leave the retained pointer dangling.
Descriptor getters were also evaluated again after fast-array conversion.

The fix is to convert the descriptor before querying the target and to place
the retry point after descriptor conversion.

The issue was introduced in 2f288a9c (0.9.1).

This closes #1089 issue on GitHub.

2 months agoFix Array.prototype.concat() with sparse arrays
Dmitry Volyntsev [Fri, 4 Sep 2026 01:47:58 +0000 (18:47 -0700)]
Fix Array.prototype.concat() with sparse arrays

Previously, the fast concat path represented a missing source property with
njs_value_invalid and still created the corresponding destination property.
For a slow sparse destination this materialized an internal hole sentinel as
a normal property, which later property access could interpret as an invalid
accessor.

The fix is to advance the destination index without creating a property when
the source lookup reports a hole.

The issue was introduced in 232fb594 (0.7.2).

This closes #1095 issue on GitHub.

2 months agoFix indexed fast paths for exotic objects
Dmitry Volyntsev [Fri, 4 Sep 2026 01:47:58 +0000 (18:47 -0700)]
Fix indexed fast paths for exotic objects

Previously, Object.values() and Object.entries() erased the concrete type of
an exotic receiver, while indexed property fast paths accepted any object
with the fast_array bit.  Typed arrays could consequently be interpreted as
njs_array_t objects, causing invalid memory reads and writes.

The fix is to preserve the receiver type during enumeration and to use
njs_is_fast_array() for both indexed get and set fast paths.

The issue was introduced in 69072164 (0.8.6); the overbroad fast path dates
to 912ab387 (0.3.8).

This closes #1098 issue on GitHub.

2 months agoRegExp: fix cleanup of custom exec results
Dmitry Volyntsev [Fri, 4 Sep 2026 01:47:58 +0000 (18:47 -0700)]
RegExp: fix cleanup of custom exec results

Previously, RegExp.prototype[Symbol.replace]() and Symbol.split() used
non-NULL exotic slots as proof that an exec() result was an internally
allocated match array.  A custom exec() could return any exotic object, which
was then cast to an array and freed, corrupting unrelated VM state.

The fix is to record whether RegExpBuiltinExec produced each result and to
release only fresh builtin results that could not escape through user code.

The replace issue was introduced in f7813172 (0.8.2), and the split issue was
introduced in aa697e3e (0.8.2).

This closes #1090, #1091, #1092, #1093, #1096, and #1097 issues on GitHub.

2 months agoFix the runtime model in the agent developer guide
Dmitry Volyntsev [Thu, 27 Aug 2026 22:32:56 +0000 (15:32 -0700)]
Fix the runtime model in the agent developer guide

The guide claimed that module scope survives between requests on the same
worker and that module imports load once per worker.  Both are wrong.
js_import only compiles at configuration time; imported module bodies run
when a context is created.

While there, corrected the integration points:

*) js_periodic receives a periodic session rather than nothing, and lives
   in server { } for stream;

*) js_filter is installed in the stream filter chain rather than as a
   phase handler, and cannot call s.done() once filtering started;

*) stream js_access and js_preread may leave pending events, while stream
   js_set may not;

*) "no async support" means no pending nginx events rather than no async
   keyword;

*) HTTP has no js_filter directive, stream js_set receives s, and
   ngx.shared is cross-worker rather than process-wide and holds only
   strings and numbers.

2 months agoGenerator: select a for-in route by the target's own token type
Dmitry Volyntsev [Tue, 1 Sep 2026 14:42:49 +0000 (07:42 -0700)]
Generator: select a for-in route by the target's own token type

Previously, njs_generate_for_in_statement() worked out what a for-in header
declared from the shape of the tree: a declaration was recognised as a
STATEMENT wrapper whose right child held the kind, and the kind and the name
were reached through that wrapper.  The wrapper was navigated again in the
object and body states, and njs_generate_for_let_update() walked it to find
lexical bindings.  Nothing named the contract, and the property route used
the same "foreach->left->right" to mean the property key.

The fix is to let the parser put the declaration node under the "in" node
directly, so the target's token type says what it is: VAR, LET or CONST for a
declaration, PROPERTY or PROPERTY_REF for a member target, and NAME for a
plain one.  An unrecognised target is now an internal error instead of
falling into the plain-name route.  njs_generate_let_update() takes the
single declaration a for-in header has, and njs_generate_for_let_update()
keeps the list walk the classic "for" needs.

The instruction stream is unchanged.  The code map for PROPERTY_NEXT on the
declaration route now carries the line of the declaration instead of the zero
the removed wrapper held; backtraces are unaffected.

2 months agoParser: classify a "for" declaration header like an expression one
Dmitry Volyntsev [Tue, 1 Sep 2026 14:26:12 +0000 (07:26 -0700)]
Parser: classify a "for" declaration header like an expression one

Previously, njs_parser_for_var_binding_or_var_list() peeked one token past a
declaration binding and, on "in", built the for-in nodes itself, consumed the
tokens and used its own right-hand-side and body states.  A declaration
header therefore never reached the classifier the expression header uses, and
the two carried separate copies of the same completion.

The fix is to parse an identifier binding once under [~In] and let
njs_parser_for_var_in_of_expression() classify the token it stopped at.  The
"in" case now accepts a declaration result, checks there that a for-in
declaration has one binding and no initializer, and continues through the
same right-hand-side, body and NJS_TOKEN_FOR_IN construction as an
expression target.  njs_parser_for_var_in_statement() and its completion go
with the duplication, and the restricted-identifier check is left to the
ordinary declaration parser, which reports it identically.

A binding pattern still leaves this route before the classifier, into the
handlers that report it as unsupported.

The tree shape is unchanged, so the generator still selects a for-in route
the way it did, and the emitted code is identical.  The declaration node now
takes its source line from the ordinary declaration parser, so a header split
across lines reports the line of "in" rather than of the binding; this shows
only in the AST dump, not in generated code or backtraces.

2 months agoGenerator: stop parking a for-in index in the AST
Dmitry Volyntsev [Tue, 1 Sep 2026 14:01:44 +0000 (07:01 -0700)]
Generator: stop parking a for-in index in the AST

Previously, the declaration route of a for-in loop stored the resolved
variable index in the AST wrapper njs_parser_node_t, and read it back one
generator state later.  The other two routes keep the same value in
ctx->index_next_value, a field njs_generator_loop_ctx_t already has for it.
The inconsistency was the only difference between
njs_generate_for_in_body_wo_decl() and njs_generate_for_in_body(): the rest
was duplicated verbatim, including a guard that is dead in the copy.

The fix is to use the context on the declaration route as well and delete the
duplicate.  One AST field is no longer written by the generator, and one of
the three shape-selected for-in paths is gone.  The emitted code is unchanged.

2 months agoParser: carry the declaration kind across nested parsing
Dmitry Volyntsev [Tue, 1 Sep 2026 13:46:16 +0000 (06:46 -0700)]
Parser: carry the declaration kind across nested parsing

Previously, the kind of a declaration being parsed lived in a mutable
parser->var_type, written once when "var", "let" or "const" was seen and read
for every declarator in the list.  A declaration nested in a declarator
initializer overwrote it, so the declarators after it were recorded with the
wrong kind and lexical ones lost their per-iteration binding:

    let out = [];
    for (let a = function() { var z; }, b = 0; b < 2; b++) out.push(() => b);
    out[0]() + ',' + out[1]()

gave "2,2" instead of "0,1", because "b" was recorded as a var.

The fix is to carry the kind in njs_parser_stack_entry_t, as the [In]
parameter already is, so a continuation restores the kind in effect when it
was pushed.  njs_parser_stack_entry_t does not grow.

2 months agoParser: remove the dead cover parenthesized production
Dmitry Volyntsev [Tue, 1 Sep 2026 04:49:27 +0000 (21:49 -0700)]
Parser: remove the dead cover parenthesized production

Previously, njs_parser_cover_parenthesized_expression(), its two
continuations and njs_parser_binding_identifier_pattern() formed a closed
island: every reference to them came from inside it, and the only thing
linking it to the parser was a "(void)" cast left with a TODO in 2020.  It
honours neither contract this series introduces.  Two of its states are
reject landing states that never inspected parser->ret, so with a ")" or a
"," in hand they would consume the token and pop the stack after a reject.
Its parenthesized expression opens no [+In] region.

The fix is to delete the island rather than repair it.  Arrow parameters are
not going to be completed through this production, and the binding pattern
entry points it dispatched to are stubs reachable on their own.

2 months agoParser: remove the use_lhs parser flag
Dmitry Volyntsev [Tue, 1 Sep 2026 01:32:50 +0000 (18:32 -0700)]
Parser: remove the use_lhs parser flag

Previously, use_lhs told njs_parser_exponentiation_expression() to skip the
unary part and njs_parser_assignment_expression() to skip the arrow check, so
that an expression could resume from an already parsed left-hand side.

The fix is to remove it: its only writer went with the speculative "for"
initializer parse.

2 months agoParser: parse the "for" initializer once
Dmitry Volyntsev [Tue, 1 Sep 2026 04:07:06 +0000 (21:07 -0700)]
Parser: parse the "for" initializer once

Previously, the initializer was parsed twice: speculatively as a
LeftHandSideExpression so that the token after it could be inspected, and
then the rest of the expression was grafted onto that left-hand side by four
continuation states.  A dispatch list and an arrow pre-check existed only to
keep inputs away from the speculative parse.

The fix is to let [~In] end the initializer: the "in" operator is no longer
part of the expression, so the header is a single <Expression> and
njs_parser_for_var_in_of_expression(), which already classified the other
routes, classifies this one too.  Its post-hoc for-in detection goes with it.

A declaration header stopping at "in" now reports an invalid target instead
of an unexpected token:

    for (var a, b in []);

The first token's text was carried through the header only to be quoted in
the removed diagnostic, so the allocation goes with it.

2 months agoParser: report an invalid "for-in" target as a SyntaxError
Dmitry Volyntsev [Tue, 1 Sep 2026 01:31:20 +0000 (18:31 -0700)]
Parser: report an invalid "for-in" target as a SyntaxError

Previously, an invalid for-in target was a ReferenceError when the
initializer happened to parse as a LeftHandSideExpression and a SyntaxError
otherwise, so the same target changed class once parenthesized:

    for (a = 1 in {});     SyntaxError
    for ((a = 1) in {});   ReferenceError

The fix is to report a SyntaxError for both, as the specification and V8 do.
The type is observable through Function(), so this is a compatibility change.
njs keeps ReferenceError for invalid assignment targets elsewhere.

2 months agoParser: own the [In] grammar parameter
Dmitry Volyntsev [Tue, 1 Sep 2026 01:30:37 +0000 (18:30 -0700)]
Parser: own the [In] grammar parameter

Previously, the [In] parameter lived in the lexer, in a byte array indexed by
bracket nesting depth.  The lexer advanced the depth while prereading, the
parser wrote at the depth it had consumed to, so a delimiter in a conditional
consequent left the write in the wrong slot and the restriction was lost:

    for (a ? (b) : c in d; false;);

The fix is to carry the parameter in njs_parser_stack_entry_t: a frame
records the value in effect when it was pushed and restores it when popped,
including on the njs_parser_reject() landing frame.  An arrow initializer now
gets [~In] as well, which it escaped before.

This also allows to remove the [In] support from the lexer. The parser
owns the parameter now. njs_lexer_t drops from 104 to 80 bytes and no longer
allocates.

2 months agoParser: fix "for" initializer parsing after a failed speculative parse
Dmitry Volyntsev [Tue, 1 Sep 2026 01:29:41 +0000 (18:29 -0700)]
Parser: fix "for" initializer parsing after a failed speculative parse

Previously, the "for" initializer was parsed speculatively as a
LeftHandSideExpression, and njs_parser_for_expression_map_reparse() resumed
parsing whenever that parse produced no node.  njs_parser_reject() rewinds
the state stack only, so the resume continued from a later token with an
abandoned function scope still on parser->scope, and generated bytecode with
a scope that is never instantiated at run time.

The fix is to remove that landing state together with its frame, so a
rejected speculative parse reports a SyntaxError, and to dispatch the
initializer starts that cannot begin a LeftHandSideExpression to <Expression>
directly.  Routing "await" through that dispatch also applies [~In] to it, so
a bare "in" in an await initializer is now a SyntaxError.

Found by OSS-Fuzz.

2 months agoMemory pool: allocate the block descriptor separately with the sanitizer
Dmitry Volyntsev [Wed, 2 Sep 2026 23:12:01 +0000 (16:12 -0700)]
Memory pool: allocate the block descriptor separately with the sanitizer

Previously, njs_mp_alloc_large() embedded the block descriptor at the end of
the allocation for sizes which are not a power of two, so a read past the end
of an object stayed inside the malloc()ed region and was not detected by the
address sanitizer.

The fix is to always allocate the descriptor separately when the address
sanitizer is enabled.

2 months agoRegExp: fix Symbol.replace() with an inconsistent match object
Dmitry Volyntsev [Wed, 2 Sep 2026 23:06:22 +0000 (16:06 -0700)]
RegExp: fix Symbol.replace() with an inconsistent match object

Previously, the matched string returned by exec() was assumed to be a
substring of the subject at the reported index.
njs_string_get_substitution() converted "index + matched length" to a byte
offset without a bounds check, and the replace loop advanced the end of the
last match by the byte size of the matched string instead of its character
length.  As a result the $' expansion read outside the subject, and on a
multibyte subject the result could start in the middle of a UTF-8 sequence.

The fix is to pass the head and tail slices of the subject to
njs_string_get_substitution() and to track the end of the last match as a
character index.  The function no longer converts indexes to offsets, so the
out of range conversion is prevented by construction.

This is an extended fix for #1116 pull request on Github.

Reported by Basavaraj S m (basavaraj@digiscrypt.com).

2 months agoVersion bump
Dmitry Volyntsev [Wed, 2 Sep 2026 23:47:11 +0000 (16:47 -0700)]
Version bump

2 months agoVersion 1.0.1 release-1.0.1 1.0.1
Dmitry Volyntsev [Tue, 11 Aug 2026 01:24:12 +0000 (18:24 -0700)]
Version 1.0.1

2 months agoFetch: fix empty response status text handling
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.

2 months agoXML: fix off-by-one in namespace prefix list parsing
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

2 months agoHTTP: fail closed on js_access body completion errors
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.

Reported by Ta Duc Thien.

2 months agoCI: bump the pinned quickjs-ng version
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.

2 months agoQuickJS: fix empty stack in errors created by the host
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.

2 months agoQuickJS: fix compatibility with quickjs-ng 0.16.0 and later
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.

This fixes #1113 issue on Github.

2 months agoModules: make context clone setup transactional
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.

2 months agoQuickJS: discard contexts with pending jobs
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.

2 months agoQuickJS: keep reused contexts in their origin queue
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.

2 months agoQuickJS: mark nginx wrapper references during GC
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.

2 months agoQuickJS: make Fetch finalization pool-independent
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.

2 months agoHTTP: fix missing validation of the r.return() redirect target
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.

2 months agoHTTP: fix missing validation of r.headersOut values
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.

Reported by vladimirelitokarev@gmail.com.

2 months agoMisc: add a release target for source tarballs
Dmitry Volyntsev [Tue, 11 Aug 2026 01:59:07 +0000 (18:59 -0700)]
Misc: add a release target for source tarballs

"make -f misc/GNUmakefile release" packages a tag into a
njs-<version>.tar.gz tarball with a checksum file.

The optional "release-check" target builds the extracted tarball.

2 months agoExclude development files from source archives
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.

2 months agoBuild: remove Mercurial leftovers
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.

2 months agoFetch: preserve QuickJS header names
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.

2 months agoFetch: centralize Headers mutations
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.

Reported by vladimirelitokarev@gmail.com.

3 months agoWebcrypto: fix QuickJS build with OpenSSL < 1.1.0
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.

3 months agoWebcrypto: fix RSA-OAEP digest being ignored for non-SHA-1 hashes
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.

Co-authored-by: Cursor <cursoragent@cursor.com>
3 months agoModules: fix SharedDict.pop() for unexpired keys in timeout zones
horus [Tue, 28 Jul 2026 02:46:59 +0000 (10:46 +0800)]
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.

3 months agoUpdate README.md
Guillaume Rossolini [Wed, 29 Jul 2026 12:27:57 +0000 (12:27 +0000)]
Update README.md

Fixed js_import example; filenames didn't match

3 months agoTest262: accept ENOTEMPTY and EEXIST in fs/rename.t.mjs
Vadim Zhestikov [Wed, 29 Jul 2026 21:52:06 +0000 (14:52 -0700)]
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.

3 months agoUpdated security reporting methods
Maryna-f5 [Tue, 21 Jul 2026 17:51:28 +0000 (10:51 -0700)]
Updated security reporting methods

4 months agoQuickJS: fix unhandled rejection tracking in reused contexts
Dmitry Volyntsev [Thu, 2 Jul 2026 23:19:32 +0000 (16:19 -0700)]
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.

4 months agoWebcrypto: fix buffer overflow when exporting large keys to JWK
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.

Reported by Vaibhav Rajput.

4 months agoQuickJS: add missing btoa() and atob() and improve njs version
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.

4 months agoUpdate README.md
Dmitry Volyntsev [Tue, 23 Jun 2026 21:49:03 +0000 (14:49 -0700)]
Update README.md

4 months agoVersion bump
Dmitry Volyntsev [Tue, 23 Jun 2026 21:49:43 +0000 (14:49 -0700)]
Version bump

4 months agoVersion 1.0.0. 1.0.0
Dmitry Volyntsev [Mon, 22 Jun 2026 21:39:55 +0000 (14:39 -0700)]
Version 1.0.0.

4 months agoHonor source view offset in same-type copies
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.

4 months agoFix Array.prototype.slice() of large arrays in the non-fast keys path
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.

4 months agoQuickJS: fix infinite loop on inflate of a stream needing a dictionary
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.

4 months agoBuffer: fix infinite loop when filling from a zero-length typed array
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().

4 months agoBuffer: fix type confusion in concat() with element getters
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.

4 months agoBuffer: fix out-of-bounds access in readInt/writeInt with zero byteLength
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.

4 months agoHTTP: fix use-after-free in r.subrequest() on premature client close
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.

This closes #1077 issue on Github.

4 months agoFix use-after-free in Array.prototype.sort()
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.

Reported by Sangsoo Jeong (78ResearchLab).

5 months agoHTTP: fix segfault reading a request header with no cache slot
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.

This closes #1071 issue on Github.

5 months agoFetch: fix Content-Length reservation in request building
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.

5 months agoFetch: fix out-of-bounds read of a short fetch proxy URL
Dmitry Volyntsev [Thu, 11 Jun 2026 23:49:33 +0000 (16:49 -0700)]
Fetch: fix out-of-bounds read of a short fetch proxy URL

5 months agoFetch: fix missing CONNECT request terminator without proxy auth
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.

5 months agoFetch: fix missing event cleanup when resolver start fails
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.

5 months agoQuickJS: fix fetch() init property value leaks
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.

5 months agoQuickJS: fix promise and event leak on proxy URL evaluation failure
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.

5 months agoQuickJS: fix body_read_len mismatch in QuickJS readRequestJSON()
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().

5 months agoModules: fix out-of-bounds read when loading a shared dict file
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.

5 months agoFix typo in njs_vm_value_enumerate()
Dmitry Volyntsev [Thu, 11 Jun 2026 04:11:27 +0000 (21:11 -0700)]
Fix typo in njs_vm_value_enumerate()

5 months agoFix out-of-bounds read in Buffer.prototype.toString()
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().

5 months agoCap string-producing chb chains in core builtins
Dmitry Volyntsev [Tue, 21 Apr 2026 04:32:19 +0000 (21:32 -0700)]
Cap string-producing chb chains in core builtins

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.

5 months agoAdd optional per-chain byte cap in chained buffers
Dmitry Volyntsev [Tue, 21 Apr 2026 01:51:30 +0000 (18:51 -0700)]
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().

5 months agoQuickJS: fix consumed value cleanup
Dmitry Volyntsev [Wed, 3 Jun 2026 05:11:58 +0000 (22:11 -0700)]
QuickJS: fix consumed value cleanup

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.

Reported by R4mbb of KRsecurity.

5 months agoModules: fixed common helper exception classes
Dmitry Volyntsev [Wed, 3 Jun 2026 02:07:40 +0000 (19:07 -0700)]
Modules: fixed common helper exception classes

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.

5 months agoXML: fixed exception classes
Dmitry Volyntsev [Wed, 3 Jun 2026 02:07:15 +0000 (19:07 -0700)]
XML: fixed exception classes

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.

5 months agoStream: fixed exception classes
Dmitry Volyntsev [Wed, 3 Jun 2026 02:07:08 +0000 (19:07 -0700)]
Stream: fixed exception classes

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.

Also fix the async send error message spacing.

5 months agoStream: fixed variable value state on allocation failure
Dmitry Volyntsev [Wed, 3 Jun 2026 02:06:39 +0000 (19:06 -0700)]
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.

5 months agoHTTP: fixed exception classes
Dmitry Volyntsev [Wed, 3 Jun 2026 02:06:15 +0000 (19:06 -0700)]
HTTP: fixed exception classes

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.

5 months agoHTTP: fixed missing exceptions in njs handlers
Dmitry Volyntsev [Wed, 3 Jun 2026 02:05:53 +0000 (19:05 -0700)]
HTTP: fixed missing exceptions in njs handlers

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.

5 months agoFetch: fixed exception classes
Dmitry Volyntsev [Wed, 3 Jun 2026 02:05:19 +0000 (19:05 -0700)]
Fetch: fixed exception classes

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.

5 months agoFetch: fixed QuickJS conversion error handling
Dmitry Volyntsev [Wed, 3 Jun 2026 02:04:33 +0000 (19:04 -0700)]
Fetch: fixed QuickJS conversion error handling

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.

5 months agoQuickJS: fixed common receiver exception classes
Dmitry Volyntsev [Wed, 3 Jun 2026 02:00:28 +0000 (19:00 -0700)]
QuickJS: fixed common receiver exception classes

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.

5 months agoFetch: check keepalive connections before reuse
Dmitry Volyntsev [Fri, 29 May 2026 23:38:04 +0000 (16:38 -0700)]
Fetch: check keepalive connections before reuse

5 months agoFetch: reject unsafe request targets
Dmitry Volyntsev [Fri, 29 May 2026 23:04:56 +0000 (16:04 -0700)]
Fetch: reject unsafe request targets

Reject raw C0 control bytes, space, and DEL in the parsed request target
before fetch request serialization.  Leave percent-encoded bytes unchanged.

5 months agoFetch: reject unsafe request methods
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.

5 months agoFetch: reject invalid header values
Dmitry Volyntsev [Fri, 29 May 2026 22:26:15 +0000 (15:26 -0700)]
Fetch: reject invalid header values

Reject control characters and DEL in Fetch Headers values while
preserving OWS trimming and obs-text.

5 months agoFetch: disable keepalive for dynamic proxy
Dmitry Volyntsev [Thu, 28 May 2026 05:19:46 +0000 (22:19 -0700)]
Fetch: disable keepalive for dynamic proxy