]> git.kaiwu.me - njs.git/log
njs.git
7 days agoVersion bump
Dmitry Volyntsev [Tue, 23 Jun 2026 21:49:43 +0000 (14:49 -0700)]
Version bump

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

2 weeks 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.

2 weeks 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.

2 weeks 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.

2 weeks 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().

2 weeks 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.

2 weeks 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.

2 weeks 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.

2 weeks 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).

2 weeks 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.

2 weeks 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.

2 weeks 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

2 weeks 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.

2 weeks 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.

2 weeks 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.

2 weeks 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.

2 weeks 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().

2 weeks 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.

2 weeks 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()

2 weeks 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().

2 weeks 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.

2 weeks 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().

3 weeks 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.

3 weeks 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.

3 weeks 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.

3 weeks 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.

3 weeks 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.

3 weeks 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.

3 weeks 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.

3 weeks 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.

3 weeks 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.

3 weeks 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.

4 weeks 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

4 weeks 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.

4 weeks 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.

4 weeks 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.

4 weeks 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

4 weeks agoFetch: fix keepalive with disabled TLS verification
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.

4 weeks agoCentralize commit message guidance
Dmitry Volyntsev [Fri, 29 May 2026 22:34:01 +0000 (15:34 -0700)]
Centralize commit message guidance

Make CONTRIBUTING.md the canonical source for commit message style and point
agent development notes to that guide instead of duplicating the rules.

5 weeks agoUpdated agent commit message guidance
Dmitry Volyntsev [Fri, 22 May 2026 03:58:24 +0000 (20:58 -0700)]
Updated agent commit message guidance

5 weeks agoParser: fixed function scope checks
Dmitry Volyntsev [Sat, 23 May 2026 01:36:33 +0000 (18:36 -0700)]
Parser: fixed function scope checks

5 weeks agoParser: fixed string escape lookahead bounds
Dmitry Volyntsev [Sat, 23 May 2026 01:32:16 +0000 (18:32 -0700)]
Parser: fixed string escape lookahead bounds

5 weeks agoFixed Buffer allocation length checks
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.

5 weeks agoModules: fixed integer conversion range checks
Dmitry Volyntsev [Sat, 23 May 2026 01:12:21 +0000 (18:12 -0700)]
Modules: fixed integer conversion range checks

5 weeks agoFixed memory overlap checks
Dmitry Volyntsev [Sat, 23 May 2026 00:07:47 +0000 (17:07 -0700)]
Fixed memory overlap checks

5 weeks agoFixed object enumeration cleanup
Dmitry Volyntsev [Fri, 22 May 2026 23:30:01 +0000 (16:30 -0700)]
Fixed object enumeration cleanup

5 weeks agoQuickJS: fixed fetch error-handling typos
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.

5 weeks agoModules: fixed variable name cleanup
Dmitry Volyntsev [Fri, 22 May 2026 04:02:32 +0000 (21:02 -0700)]
Modules: fixed variable name cleanup

5 weeks agoFixed compile failure reporting
Dmitry Volyntsev [Fri, 22 May 2026 03:48:22 +0000 (20:48 -0700)]
Fixed compile failure reporting

5 weeks agoFixed atom string fallback handling
Dmitry Volyntsev [Fri, 22 May 2026 03:44:57 +0000 (20:44 -0700)]
Fixed atom string fallback handling

5 weeks agoFixed Buffer float access alignment
Dmitry Volyntsev [Fri, 22 May 2026 03:31:42 +0000 (20:31 -0700)]
Fixed Buffer float access alignment

5 weeks agoModules: fixed QuickJS log argument conversion
Dmitry Volyntsev [Fri, 22 May 2026 00:23:09 +0000 (17:23 -0700)]
Modules: fixed QuickJS log argument conversion

5 weeks agoQuickJS: fixed Buffer.toJSON() data ownership
Dmitry Volyntsev [Thu, 21 May 2026 23:50:41 +0000 (16:50 -0700)]
QuickJS: fixed Buffer.toJSON() data ownership

The property definition consumes ownership, so freeing the result object
is enough on later error paths.

Returned JS_EXCEPTION on property-definition failures instead of the
unrelated typed-array lookup result.

5 weeks agoQuickJS: fixed Buffer.from() and encoding error paths
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).

5 weeks agoQuickJS: fixed process.kill() signal error handling
Dmitry Volyntsev [Thu, 21 May 2026 22:51:02 +0000 (15:51 -0700)]
QuickJS: fixed process.kill() signal error handling

5 weeks agoAdded agent developer guide.
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.

5 weeks agoVersion bump.
Dmitry Volyntsev [Wed, 20 May 2026 23:29:33 +0000 (16:29 -0700)]
Version bump.

6 weeks agoVersion 0.9.9. 0.9.9
Dmitry Volyntsev [Tue, 19 May 2026 00:29:57 +0000 (17:29 -0700)]
Version 0.9.9.

6 weeks agoModules: added jsVarNames() method.
Dmitry Volyntsev [Sat, 16 May 2026 22:14:37 +0000 (15:14 -0700)]
Modules: added jsVarNames() method.

The method returns names of variables declared with js_var.  An optional
string argument filters returned names by prefix.

This lets JavaScript code discover a configuration-defined variable schema
without duplicating every variable name in the script.  For example:

    js_var $extract_name;
    js_var $extract_arguments_workspace;

    function handler(r) {
        r.jsVarNames("extract_").forEach(function(name) {
            r.variables[name] = extract(name.slice(8));
        });
    }

The same API is available as s.jsVarNames() in stream.  Variables created by
js_set or by the core modules are not returned.

6 weeks agoFixed call argument value snapshotting.
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.

This fixes #1059 issue on Github.

6 weeks agoFixed allocator mismatch in drain/drop.
Dmitry Volyntsev [Tue, 21 Apr 2026 01:47:05 +0000 (18:47 -0700)]
Fixed allocator mismatch in drain/drop.

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().

6 weeks agoHTTP: fixed internalRedirect() in js_access.
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.

6 weeks agoHTTP: fixed r.return() with body in js_access.
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.

6 weeks agoImproved error stack frame handling.
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.

Found by Coverity (CID 1681309).

6 weeks agoHTTP: removed dead code introduced in 0ffc96df7.
Dmitry Volyntsev [Thu, 14 May 2026 00:33:35 +0000 (17:33 -0700)]
HTTP: removed dead code introduced in 0ffc96df7.

Found by Coverity (CID 1692699).

6 weeks agoHTTP: made request argument explicit in body resolver.
Dmitry Volyntsev [Thu, 14 May 2026 00:15:56 +0000 (17:15 -0700)]
HTTP: made request argument explicit in body resolver.

This makes the ownership of the request pointer clearer for readers and
avoids unnecessary confusion for static analyzers.

7 weeks agoFetch: fixed heap buffer overflow in proxy URL credentials.
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 bug appeared in dea8318 (0.9.4).

8 weeks agoHTTP: added r.readRequestForm().
Dmitry Volyntsev [Fri, 10 Apr 2026 05:56:01 +0000 (22:56 -0700)]
HTTP: added r.readRequestForm().

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.

8 weeks agoHTTP: added r.readRequestText() and friends.
Dmitry Volyntsev [Sat, 28 Mar 2026 01:43:56 +0000 (18:43 -0700)]
HTTP: added r.readRequestText() and friends.

Added async methods
    - r.readRequestText() as string
    - r.readRequestArrayBuffer() as ArrayBuffer
    - r.readRequestJSON() as object.

that return Promises resolving with the request body wrapped
as a corresponding type.

8 weeks agoHTTP: added js_access directive.
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.

8 weeks agoVersion bump.
Dmitry Volyntsev [Sat, 25 Apr 2026 00:38:07 +0000 (17:38 -0700)]
Version bump.

2 months agoVersion 0.9.8. 0.9.8
Dmitry Volyntsev [Wed, 22 Apr 2026 22:56:22 +0000 (15:56 -0700)]
Version 0.9.8.

2 months agoModules: fixed loading of the built-in "crypto" module.
Dmitry Volyntsev [Wed, 22 Apr 2026 22:56:18 +0000 (15:56 -0700)]
Modules: fixed loading of the built-in "crypto" module.

Since 3185ce81 (0.9.7), njs_crypto_module has been registered
conditionally in auto/modules under NJS_HAVE_OPENSSL.  libnjs.a is
built for the nginx module via nginx/config.make with
"./configure --no-openssl ...", so libnjs.a's njs_modules[] no
longer contains the crypto module.  The nginx addon lists
(njs_http_js_addon_modules[] and njs_stream_js_addon_modules[],
plus the qjs variants) were not updated accordingly, making
"import cr from 'crypto'" fail at nginx -t with ENOENT.

Added &njs_crypto_module and &qjs_crypto_module to the shared addon
list under the existing NJS_HAVE_OPENSSL guard, next to the webcrypto
entries.

While here, the near-identical addon arrays were factored into
shared macros in a new header nginx/ngx_js_modules.h, so adding a
future conditional addon needs a single edit instead of four.

This closes #1049 issue on Github.

2 months agoVersion bump.
Dmitry Volyntsev [Wed, 22 Apr 2026 22:44:33 +0000 (15:44 -0700)]
Version bump.

2 months agoVersion 0.9.7. 0.9.7
Dmitry Volyntsev [Mon, 20 Apr 2026 16:50:00 +0000 (09:50 -0700)]
Version 0.9.7.

2 months agoModules: removed "js vm init" during configuration parsing.
Dmitry Volyntsev [Tue, 7 Apr 2026 01:55:00 +0000 (18:55 -0700)]
Modules: removed "js vm init" during configuration parsing.

Previously, a notice-level log message "js vm init %s: %p" was emitted
for each JS engine created during configuration parsing.  This message
leaked to the compiled-in default error log even when the user
explicitly configured logging elsewhere, because config-time logging
uses the initial cycle log whose file descriptor is opened before the
user's error_log directive takes effect.

The log message was used for VM deduplication tests. The tests are
updated to verify unique engine identifiers via HTTP and stream
responses instead of grepping for log messages.

This fixes #1042 issue on Github.

2 months agoWebCrypto: added JWK unwrap() support.
Dmitry Volyntsev [Sat, 4 Apr 2026 00:47:57 +0000 (17:47 -0700)]
WebCrypto: added JWK unwrap() support.

Added JWK format support to unwrapKey: decrypted data is parsed as
JSON and imported through the shared import path.

2 months agoWebCrypto: added Ed25519 and X25519 support.
Dmitry Volyntsev [Fri, 3 Apr 2026 07:00:58 +0000 (00:00 -0700)]
WebCrypto: added Ed25519 and X25519 support.

Implemented Ed25519 sign/verify/generateKey/importKey/exportKey
Supports raw, PKCS8, SPKI, and JWK (OKP) key formats.

Implemented X25519 deriveBits/deriveKey/generateKey/importKey/
exportKey.

2 months agoWebCrypto: added AES-KW algorithm support.
Dmitry Volyntsev [Fri, 3 Apr 2026 05:32:24 +0000 (22:32 -0700)]
WebCrypto: added AES-KW algorithm support.

Supports 128, 192, and 256-bit key sizes with generateKey,
importKey, and exportKey operations in raw and JWK formats.

Also fixed deriveKey to accept 192-bit AES key lengths.

2 months agoWebCrypto: added crypto.randomUUID().
Dmitry Volyntsev [Fri, 3 Apr 2026 05:25:08 +0000 (22:25 -0700)]
WebCrypto: added crypto.randomUUID().

2 months agoWebCrypto: fixed RSA JWK test data for Node.js compatibility.
Dmitry Volyntsev [Sat, 4 Apr 2026 01:22:22 +0000 (18:22 -0700)]
WebCrypto: fixed RSA JWK test data for Node.js compatibility.

Removed the incorrect "alg":"RS256" field from RSA JWK test data
files.  The field is optional per RFC 7517, and the value RS256
(RSASSA-PKCS1-v1_5) was wrong for tests that use RSA-OAEP and
RSA-PSS algorithms.  Node.js correctly rejects JWK imports when
the "alg" field does not match the requested algorithm.

The hash mismatch negative test was updated to use an inline JWK
with an explicit "alg" field instead of the shared file.

This ensures: `test/test262 --binary=node test/webcrypto` pass.

2 months agoFixed length prop of an Array instance redefinition error handing.
Dmitry Volyntsev [Fri, 3 Apr 2026 00:49:31 +0000 (17:49 -0700)]
Fixed length prop of an Array instance redefinition error handing.

Previously, when njs_array_length_redefine() failed, the function
returned directly, bypassing the cleanup of the "keys" array.

3 months agoCrypto: switched to OpenSSL EVP for hashing.
Dmitry Volyntsev [Tue, 24 Mar 2026 01:46:02 +0000 (18:46 -0700)]
Crypto: switched to OpenSSL EVP for hashing.

Previously, the crypto module used built-in software implementations
for a limited set of hash algorithms (md5, sha1, sha256).  This
prevented users from using algorithms like sha384, sha512, and sha3
family, even when the underlying OpenSSL library supported them.

The change replaces built-in hash implementations with OpenSSL
EVP_MD_CTX for createHash() and HMAC_CTX for createHmac(), following
the webcrypto module.  Algorithm lookup now uses EVP_get_digestbyname(),
making any digest supported by the linked OpenSSL available to
JavaScript code.

The module now requires OpenSSL and is conditionally compiled, same as
the webcrypto module.  Builds without OpenSSL (--no-openssl) will no
longer have the crypto module available.

Tested with OpenSSL 3.0, OpenSSL 1.1.1w, LibreSSL 3.9.2, and
BoringSSL.  SHA-3 tests are skipped when the SSL library does not
support them (e.g. BoringSSL).

This closes #1037 feature request on Github.

3 months agoModules: removed shared dict expiration from read-locked paths.
Dmitry Volyntsev [Fri, 27 Mar 2026 01:03:41 +0000 (18:03 -0700)]
Modules: removed shared dict expiration from read-locked paths.

Previously, keys(), items(), and size() called ngx_js_dict_expire()
under a read lock.  Since ngx_js_dict_expire() deletes nodes from
both rbtrees and frees slab memory, concurrent readers on different
worker processes could corrupt shared memory by freeing the same
expired nodes simultaneously.

The fix removes ngx_js_dict_expire() calls from all read-locked
paths and instead skips expired entries during iteration, consistent
with how get() and has() already handle expiry.  Actual cleanup of
expired entries is deferred to write-side operations (set, add,
delete, clear).

3 months agoModules: improved shared dict eviction strategy.
Dmitry Volyntsev [Fri, 27 Mar 2026 00:16:25 +0000 (17:16 -0700)]
Modules: improved shared dict eviction strategy.

Previously, when a slab allocation failed in evict mode, only 16
entries were evicted with a single retry.  This could still result
in SharedMemoryError when the freed slab slots did not match the
requested allocation size class, even though the zone had plenty
of evictable entries.

In practice, it might happen when the following conditions are met:
    - The shared zone is full
    - evict flag is enabled
    - key/value entries differ in size

The allocation now retries in a loop, evicting 16 entries at a time,
until the allocation succeeds or no more entries remain in the expire
tree.

After this change, allocation with evict enabled can only fail when:
    - the value is larger than the zone's usable space
    - the expire tree has no entries left to evict
    - zone metadata overhead leaves insufficient room

3 months agoModules: fixed double-free in shared dict update with eviction.
Dmitry Volyntsev [Fri, 27 Mar 2026 00:16:11 +0000 (17:16 -0700)]
Modules: fixed double-free in shared dict update with eviction.

Previously, when updating an existing key's string value in a shared
dictionary with timeout and evict enabled, ngx_js_dict_alloc() could
trigger ngx_js_dict_evict() if the zone was full.  Since the node being
updated was still in the expire tree, eviction could free it.  The
subsequent ngx_slab_free_locked() call in the update path then freed the
already-freed string data, causing the "chunk is already free" alert
followed by a segfault.

The fix removes the node from the expire tree before allocating
memory for the new value, preventing eviction from reaching it.
On allocation failure the node is re-inserted with its original
expiry time.

3 months agoModules: preserved per-entry TTL on shared dict incr() calls.
Dmitry Volyntsev [Thu, 12 Mar 2026 23:50:34 +0000 (16:50 -0700)]
Modules: preserved per-entry TTL on shared dict incr() calls.

Previously, incr() without an explicit timeout argument always
reset the entry expiry to the directive default, discarding any
per-entry timeout set by a prior add(), set(), or incr() call.

This aligns the behavior with Redis INCR and OpenResty
ngx.shared.DICT:incr() where value mutation does not touch the
existing TTL.  An explicit timeout argument still updates it.

3 months agoModules: added ttl() method to shared dictionaries.
Dmitry Volyntsev [Thu, 12 Mar 2026 23:48:14 +0000 (16:48 -0700)]
Modules: added ttl() method to shared dictionaries.

The method returns the remaining time-to-live in milliseconds
for a given key, or undefined if the key does not exist or has
expired.  Throws TypeError if the dictionary was declared without
the timeout parameter.

3 months agoParser: allow await expressions in tagged templates.
Dmitry Volyntsev [Wed, 4 Mar 2026 23:16:15 +0000 (15:16 -0800)]
Parser: allow await expressions in tagged templates.

3 months agoParser: allow await expressions in call arguments.
Dmitry Volyntsev [Wed, 4 Mar 2026 07:22:59 +0000 (23:22 -0800)]
Parser: allow await expressions in call arguments.

3 months agoFixed call argument evaluation.
Dmitry Volyntsev [Wed, 4 Mar 2026 07:22:54 +0000 (23:22 -0800)]
Fixed call argument evaluation.

Previously, call lowering created FUNCTION_FRAME and METHOD_FRAME before
argument evaluation.  This made call ordering observably wrong: for
non-callable callees, the error was thrown before arguments with side
effects were evaluated, violating the ECMAScript specification.  It also
prevented await expressions in call arguments, which were rejected at
parse time because suspending inside a half-created frame was not
supported.

The fix evaluates arguments first, then emits the frame, PUT_ARG, and
FUNCTION_CALL.  Callee and receiver values are captured into temporaries
before argument evaluation to guard against argument side effects.
Method properties are resolved via PROPERTY_GET before arguments.

METHOD_FRAME is redefined from a composite opcode (property lookup +
callability check + frame creation) to a pure frame-creation opcode
that takes an already-resolved function and explicit "this" value.
The parser always wraps call expressions in a NJS_TOKEN_FUNCTION_CALL
node, removing the NJS_TOKEN_NAME special case.

3 months agoGenerator: derive property swap from node.
Dmitry Volyntsev [Wed, 4 Mar 2026 07:22:52 +0000 (23:22 -0800)]
Generator: derive property swap from node.

Previously, the "in" operator swap flag was relayed through generator
context.  This broke when call-end handlers switched to
njs_generator_stack_pop(NULL), which released the context before the
swap was read in njs_generate_3addr_operation_end().

The fix derives the swap directly from the node token type
(NJS_TOKEN_IN), eliminating the context relay.

3 months agoParser: isolate optional preserve wrapper details.
Dmitry Volyntsev [Wed, 4 Mar 2026 04:40:57 +0000 (20:40 -0800)]
Parser: isolate optional preserve wrapper details.

Introduce NJS_TOKEN_OPTIONAL_PRESERVE for optional-chain preserve nodes
instead of reusing OBJECT_VALUE, so OBJECT_VALUE remains strictly an
object/array literal structure token.

Route optional-preserve, object-value, and optional method-preserve
access through dedicated helper functions with narrow assertions,
removing direct u.object/left/right access from general parser and
generator paths.

No behavior change.

3 months agoPreserve "this" for grouped optional calls.
Dmitry Volyntsev [Wed, 4 Mar 2026 04:40:19 +0000 (20:40 -0800)]
Preserve "this" for grouped optional calls.

Previously, grouped optional calls like (o?.m)() resolved the callee
through the optional chain but dispatched via plain FUNCTION_CALL,
losing the original receiver.

The fix stores the receiver on the call node so the upcoming
call-argument reorder can emit METHOD_FRAME with an explicit "this".
Call-expression setup and optional-chain preserve lookup are routed
through named helpers with generator-side validation.

3 months agoParser: lower property consumers to PROPERTY_REF.
Dmitry Volyntsev [Wed, 4 Mar 2026 04:40:09 +0000 (20:40 -0800)]
Parser: lower property consumers to PROPERTY_REF.

Previously, the generator inferred reference intent from raw AST shape.

Now the parser marks the relevant property node as PROPERTY_REF before
building METHOD_CALL, assignment, or update nodes, and the generator
accepts both PROPERTY and PROPERTY_REF via
njs_generate_is_property_lvalue().

Introduce NJS_TOKEN_PROPERTY_REF as an explicit parser-side marker for
property accesses that carry reference semantics (assignment targets,
delete operands, increment/decrement, method-call receivers).

3 months agoVersion bump.
Dmitry Volyntsev [Wed, 4 Mar 2026 04:58:33 +0000 (20:58 -0800)]
Version bump.

3 months agoVersion 0.9.6. 0.9.6
Dmitry Volyntsev [Tue, 3 Mar 2026 01:15:26 +0000 (17:15 -0800)]
Version 0.9.6.

3 months agoWebCrypto: validate JWK key type against algorithm in importKey().
Dmitry Volyntsev [Mon, 2 Mar 2026 17:35:48 +0000 (09:35 -0800)]
WebCrypto: validate JWK key type against algorithm in importKey().

Previously, importKey() did not verify that the JWK "kty" field
matched the requested algorithm.  For example, importing a JWK with
kty "oct" (symmetric) while specifying an asymmetric algorithm like
ECDH caused a SEGV in EVP_PKEY_free() during cleanup.  This happened
because the symmetric key data written into the union's "raw" member
overlapped with the "pkey" pointer, corrupting it.

The fix validates kty before calling any JWK import function:
    - "RSA" is only accepted for RSA-OAEP, RSA-PSS, RSASSA-PKCS1-v1_5
    - "EC" is only accepted for ECDSA, ECDH
    - "oct" is only accepted for HMAC, AES-GCM, AES-CTR, AES-CBC

Found by Akshay Jain (akshaythe@gmail.com).

3 months agoFixed string offset map corruption in scope values hash.
Dmitry Volyntsev [Thu, 26 Feb 2026 15:45:46 +0000 (07:45 -0800)]
Fixed string offset map corruption in scope values hash.

The issue was introduced in e7caa46d (0.9.5).  When compile-time
UTF-8 string constants were copied into the values hash in
njs_scope_value_index(), the string data layout was calculated
incorrectly: the map offset did not account for the null terminator
added in e7caa46d, the "size" variable was overwritten corrupting
the subsequent memcpy, and the offset map was never initialized
to zero.

This caused SEGV/SIGBUS crashes for any multi-byte UTF-8 string
constant with more than 32 characters when accessing a character
at index >= 32 (e.g. via .replace() or bracket notation).  The bug
only manifested when the string byte size was 4-byte aligned, as
otherwise alignment padding absorbed the missing byte.

The fix factors out njs_string_data_size() and njs_string_data_init()
helpers shared by njs_string_alloc() and njs_scope_value_index(),
eliminating the duplicated layout logic that caused the divergence.

Found by Akshay Jain (akshaythe@gmail.com).