From 102ab146bc1af093396a9856a5193a3b1dd0eed5 Mon Sep 17 00:00:00 2001 From: Dmitry Volyntsev Date: Fri, 24 Apr 2026 17:38:07 -0700 Subject: [PATCH 1/4] Version bump. --- src/njs.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/njs.h b/src/njs.h index 07e452fe6..fc212f8d4 100644 --- a/src/njs.h +++ b/src/njs.h @@ -11,8 +11,8 @@ #include -#define NJS_VERSION "0.9.8" -#define NJS_VERSION_NUMBER 0x000908 +#define NJS_VERSION "0.9.9" +#define NJS_VERSION_NUMBER 0x000909 #include From 79c43210157e373fbadd801acd7e90939c9f5c31 Mon Sep 17 00:00:00 2001 From: Dmitry Volyntsev Date: Fri, 27 Mar 2026 17:33:37 -0700 Subject: [PATCH 2/4] 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. --- nginx/ngx_http_js_module.c | 193 +++++++++++++++++ nginx/t/js_access.t | 400 ++++++++++++++++++++++++++++++++++++ nginx/t/js_access_satisfy.t | 175 ++++++++++++++++ ts/ngx_http_js_module.d.ts | 7 + 4 files changed, 775 insertions(+) create mode 100644 nginx/t/js_access.t create mode 100644 nginx/t/js_access_satisfy.t diff --git a/nginx/ngx_http_js_module.c b/nginx/ngx_http_js_module.c index d1908ff49..1654b310b 100644 --- a/nginx/ngx_http_js_module.c +++ b/nginx/ngx_http_js_module.c @@ -18,6 +18,7 @@ typedef struct { ngx_http_complex_value_t fetch_proxy_cv; + ngx_str_t access; ngx_str_t content; ngx_str_t header_filter; ngx_str_t body_filter; @@ -74,6 +75,8 @@ struct ngx_http_js_ctx_s { ngx_chain_t *in); ngx_js_periodic_t *periodic; + + unsigned in_progress:1; }; @@ -112,6 +115,8 @@ typedef struct { } ngx_http_js_entry_t; +static ngx_int_t ngx_http_js_access_handler(ngx_http_request_t *r); +static void ngx_http_js_access_write_event_handler(ngx_http_request_t *r); static ngx_int_t ngx_http_js_content_handler(ngx_http_request_t *r); static void ngx_http_js_content_event_handler(ngx_http_request_t *r); static void ngx_http_js_content_write_event_handler(ngx_http_request_t *r); @@ -192,6 +197,8 @@ static njs_int_t ngx_http_js_ext_finish(njs_vm_t *vm, njs_value_t *args, njs_uint_t nargs, njs_index_t unused, njs_value_t *retval); static njs_int_t ngx_http_js_ext_return(njs_vm_t *vm, njs_value_t *args, njs_uint_t nargs, njs_index_t unused, njs_value_t *retval); +static njs_int_t ngx_http_js_ext_decline(njs_vm_t *vm, njs_value_t *args, + njs_uint_t nargs, njs_index_t unused, njs_value_t *retval); static njs_int_t ngx_http_js_ext_internal_redirect(njs_vm_t *vm, njs_value_t *args, njs_uint_t nargs, njs_index_t unused, njs_value_t *retval); @@ -307,6 +314,8 @@ static JSValue ngx_http_qjs_ext_response_body(JSContext *cx, JSValueConst this_val, int type); static JSValue ngx_http_qjs_ext_return(JSContext *cx, JSValueConst this_val, int argc, JSValueConst *argv); +static JSValue ngx_http_qjs_ext_decline(JSContext *cx, JSValueConst this_val, + int argc, JSValueConst *argv); static JSValue ngx_http_qjs_ext_send(JSContext *cx, JSValueConst this_val, int argc, JSValueConst *argv); static JSValue ngx_http_qjs_ext_send_buffer(JSContext *cx, @@ -379,6 +388,8 @@ static char *ngx_http_js_periodic(ngx_conf_t *cf, ngx_command_t *cmd, void *conf); static char *ngx_http_js_set(ngx_conf_t *cf, ngx_command_t *cmd, void *conf); static char *ngx_http_js_var(ngx_conf_t *cf, ngx_command_t *cmd, void *conf); +static char *ngx_http_js_access(ngx_conf_t *cf, ngx_command_t *cmd, + void *conf); static char *ngx_http_js_content(ngx_conf_t *cf, ngx_command_t *cmd, void *conf); static char *ngx_http_js_shared_dict_zone(ngx_conf_t *cf, ngx_command_t *cmd, @@ -482,6 +493,13 @@ static ngx_command_t ngx_http_js_commands[] = { 0, NULL }, + { ngx_string("js_access"), + NGX_HTTP_LOC_CONF|NGX_HTTP_LIF_CONF|NGX_HTTP_LMT_CONF|NGX_CONF_TAKE1, + ngx_http_js_access, + NGX_HTTP_LOC_CONF_OFFSET, + 0, + NULL }, + { ngx_string("js_content"), NGX_HTTP_LOC_CONF|NGX_HTTP_LIF_CONF|NGX_HTTP_LMT_CONF|NGX_CONF_TAKE1, ngx_http_js_content, @@ -916,6 +934,17 @@ static njs_external_t ngx_http_js_ext_request[] = { } }, + { + .flags = NJS_EXTERN_METHOD, + .name.string = njs_str("decline"), + .writable = 1, + .configurable = 1, + .enumerable = 1, + .u.method = { + .native = ngx_http_js_ext_decline, + } + }, + { .flags = NJS_EXTERN_METHOD, .name.string = njs_str("send"), @@ -1132,6 +1161,7 @@ static const JSCFunctionListEntry ngx_http_qjs_ext_request[] = { JS_CGETSET_MAGIC_DEF("responseText", ngx_http_qjs_ext_response_body, NULL, NGX_JS_STRING), JS_CFUNC_DEF("return", 2, ngx_http_qjs_ext_return), + JS_CFUNC_DEF("decline", 0, ngx_http_qjs_ext_decline), JS_CFUNC_DEF("send", 1, ngx_http_qjs_ext_send), JS_CFUNC_DEF("sendBuffer", 2, ngx_http_qjs_ext_send_buffer), JS_CFUNC_DEF("sendHeader", 0, ngx_http_qjs_ext_send_header), @@ -1211,6 +1241,85 @@ qjs_module_t *njs_http_qjs_addon_modules[] = { #endif +static ngx_int_t +ngx_http_js_access_handler(ngx_http_request_t *r) +{ + ngx_int_t rc; + ngx_http_js_ctx_t *ctx; + ngx_http_js_loc_conf_t *jlcf; + + jlcf = ngx_http_get_module_loc_conf(r, ngx_http_js_module); + + if (jlcf->access.len == 0) { + return NGX_DECLINED; + } + + if (r != r->main) { + return NGX_DECLINED; + } + + rc = ngx_http_js_init_vm(r, ngx_http_js_request_proto_id); + if (rc != NGX_OK) { + return rc; + } + + ctx = ngx_http_get_module_ctx(r, ngx_http_js_module); + + if (ctx->in_progress) { + if (ngx_js_ctx_pending(ctx)) { + return NGX_AGAIN; + } + + ctx->in_progress = 0; + + if (ctx->rejected_promises != NULL + && ctx->rejected_promises->items > 0) + { + return NGX_HTTP_INTERNAL_SERVER_ERROR; + } + + return ctx->status; + } + + ctx->status = NGX_OK; + + ngx_log_debug1(NGX_LOG_DEBUG_HTTP, r->connection->log, 0, + "http js access handler \"%V\"", &jlcf->access); + + rc = ctx->engine->call((ngx_js_ctx_t *) ctx, &jlcf->access, &ctx->args[0], + 1); + + if (rc == NGX_ERROR) { + return NGX_HTTP_INTERNAL_SERVER_ERROR; + } + + if (ngx_js_ctx_pending(ctx)) { + ctx->in_progress = 1; + r->write_event_handler = ngx_http_js_access_write_event_handler; + return NGX_AGAIN; + } + + return ctx->status; +} + + +static void +ngx_http_js_access_write_event_handler(ngx_http_request_t *r) +{ + ngx_http_js_ctx_t *ctx; + + ctx = ngx_http_get_module_ctx(r, ngx_http_js_module); + + ngx_log_debug0(NGX_LOG_DEBUG_HTTP, r->connection->log, 0, + "http js access write event handler"); + + if (!ngx_js_ctx_pending(ctx)) { + ngx_http_core_run_phases(r); + return; + } +} + + static ngx_int_t ngx_http_js_content_handler(ngx_http_request_t *r) { @@ -2822,6 +2931,30 @@ ngx_http_js_ext_return(njs_vm_t *vm, njs_value_t *args, njs_uint_t nargs, } +static njs_int_t +ngx_http_js_ext_decline(njs_vm_t *vm, njs_value_t *args, njs_uint_t nargs, + njs_index_t unused, njs_value_t *retval) +{ + ngx_http_js_ctx_t *ctx; + ngx_http_request_t *r; + + r = njs_vm_external(vm, ngx_http_js_request_proto_id, + njs_argument(args, 0)); + if (r == NULL) { + njs_vm_error(vm, "\"this\" is not an external"); + return NJS_ERROR; + } + + ctx = ngx_http_get_module_ctx(r, ngx_http_js_module); + + ctx->status = NGX_DECLINED; + + njs_value_undefined_set(retval); + + return NJS_OK; +} + + static njs_int_t ngx_http_js_ext_internal_redirect(njs_vm_t *vm, njs_value_t *args, njs_uint_t nargs, njs_index_t unused, njs_value_t *retval) @@ -5502,6 +5635,26 @@ ngx_http_qjs_ext_return(JSContext *cx, JSValueConst this_val, } +static JSValue +ngx_http_qjs_ext_decline(JSContext *cx, JSValueConst this_val, + int argc, JSValueConst *argv) +{ + ngx_http_js_ctx_t *ctx; + ngx_http_request_t *r; + + r = ngx_http_qjs_request(this_val); + if (r == NULL) { + return JS_ThrowInternalError(cx, "\"this\" is not a request object"); + } + + ctx = ngx_http_get_module_ctx(r, ngx_http_js_module); + + ctx->status = NGX_DECLINED; + + return JS_UNDEFINED; +} + + static JSValue ngx_http_qjs_ext_status_get(JSContext *cx, JSValueConst this_val) { @@ -7773,12 +7926,24 @@ ngx_http_js_init_conf_vm(ngx_conf_t *cf, ngx_js_loc_conf_t *conf) static ngx_int_t ngx_http_js_init(ngx_conf_t *cf) { + ngx_http_handler_pt *h; + ngx_http_core_main_conf_t *cmcf; + ngx_http_next_header_filter = ngx_http_top_header_filter; ngx_http_top_header_filter = ngx_http_js_header_filter; ngx_http_next_body_filter = ngx_http_top_body_filter; ngx_http_top_body_filter = ngx_http_js_body_filter; + cmcf = ngx_http_conf_get_module_main_conf(cf, ngx_http_core_module); + + h = ngx_array_push(&cmcf->phases[NGX_HTTP_ACCESS_PHASE].handlers); + if (h == NULL) { + return NGX_ERROR; + } + + *h = ngx_http_js_access_handler; + return NGX_OK; } @@ -8156,6 +8321,24 @@ ngx_http_js_var(ngx_conf_t *cf, ngx_command_t *cmd, void *conf) } +static char * +ngx_http_js_access(ngx_conf_t *cf, ngx_command_t *cmd, void *conf) +{ + ngx_http_js_loc_conf_t *jlcf = conf; + + ngx_str_t *value; + + if (jlcf->access.data) { + return "is duplicate"; + } + + value = cf->args->elts; + jlcf->access = value[1]; + + return NGX_CONF_OK; +} + + static char * ngx_http_js_content(ngx_conf_t *cf, ngx_command_t *cmd, void *conf) { @@ -8275,6 +8458,7 @@ ngx_http_js_merge_loc_conf(ngx_conf_t *cf, void *parent, void *child) ngx_http_js_loc_conf_t *prev = parent; ngx_http_js_loc_conf_t *conf = child; + ngx_conf_merge_str_value(conf->access, prev->access, ""); ngx_conf_merge_str_value(conf->content, prev->content, ""); ngx_conf_merge_str_value(conf->header_filter, prev->header_filter, ""); ngx_conf_merge_str_value(conf->body_filter, prev->body_filter, ""); @@ -8287,6 +8471,15 @@ ngx_http_js_merge_loc_conf(ngx_conf_t *cf, void *parent, void *child) return NGX_CONF_ERROR; } + if (conf->access.len != 0) { + if (conf->imports == NGX_CONF_UNSET_PTR) { + ngx_log_error(NGX_LOG_EMERG, cf->log, 0, + "no imports defined for \"js_access\" \"%V\", " + "use \"js_import\" directive", &conf->access); + return NGX_CONF_ERROR; + } + } + if (conf->content.len != 0) { if (conf->imports == NGX_CONF_UNSET_PTR) { ngx_log_error(NGX_LOG_EMERG, cf->log, 0, diff --git a/nginx/t/js_access.t b/nginx/t/js_access.t new file mode 100644 index 000000000..29ac026eb --- /dev/null +++ b/nginx/t/js_access.t @@ -0,0 +1,400 @@ +#!/usr/bin/perl + +# (C) Dmitry Volyntsev +# (C) F5, Inc. + +# Tests for http njs module, js_access directive. + +############################################################################### + +use warnings; +use strict; + +use Test::More; + +use Socket qw/ CRLF /; + +BEGIN { use FindBin; chdir($FindBin::Bin); } + +use lib 'lib'; +use Test::Nginx; + +############################################################################### + +select STDERR; $| = 1; +select STDOUT; $| = 1; + +my $t = Test::Nginx->new()->has(qw/http rewrite proxy/) + ->write_file_expand('nginx.conf', <<'EOF'); + +%%TEST_GLOBALS%% + +daemon off; + +events { +} + +http { + %%TEST_GLOBALS_HTTP%% + + js_import test.js; + + js_var $foo; + js_var $upstream; + + server { + listen 127.0.0.1:8080; + server_name localhost; + + location /var { + js_content test.content; + } + + location /deny { + js_access test.deny; + js_content test.content; + } + + location /exception { + js_access test.exception; + js_content test.content; + } + + location /noop { + js_access test.noop; + js_content test.content; + } + + location /decline { + js_access test.decline; + js_content test.content; + } + + location /override { + js_access test.override; + js_content test.content; + } + + location /content_only { + js_content test.content_only; + } + + location /async_timeout { + js_access test.async_timeout; + js_content test.content; + } + + location /async_deny { + js_access test.async_deny; + js_content test.content; + } + + location /async_exception { + js_access test.async_exception; + js_content test.content; + } + + location /sr_skip { + js_content test.sr_skip; + } + + location /sub { + js_access test.deny; + js_content test.content; + } + + location /sr { + js_access test.sr; + js_content test.content; + } + + location /fetch { + js_access test.fetch; + js_content test.content; + } + + location /route { + js_access test.route; + proxy_pass http://$upstream; + } + + location /auth_check { + js_content test.auth_check; + } + + location /redirect { + js_access test.redirect; + js_content test.content; + } + + location /redirect_async { + js_access test.redirect_async; + js_content test.content; + } + + location /callback { + js_content test.content; + } + } + + server { + listen 127.0.0.1:8080; + server_name noaccess; + + location /no_access { + js_content test.content_only; + } + } + + server { + listen 127.0.0.1:8081; + + location / { + return 200 "backend1"; + } + } + + server { + listen 127.0.0.1:8082; + + location / { + return 200 "backend2"; + } + } +} + +EOF + +my $p0 = port(8080); +my $p1 = port(8081); +my $p2 = port(8082); + +$t->write_file('test.js', < setTimeout(resolve, 5)); + r.variables.foo = 'timeout_ok'; + } + + async function async_deny(r) { + await new Promise(resolve => setTimeout(resolve, 5)); + r.return(403); + } + + async function async_exception(r) { + await new Promise(resolve => setTimeout(resolve, 5)); + throw new Error("async_access_error"); + } + + async function sr_skip(r) { + let reply = await r.subrequest('/sub'); + r.return(reply.status, reply.responseText); + } + + async function fetch(r) { + let resp = await ngx.fetch( + \`http://127.0.0.1:$p0/auth_check?token=\${r.variables.arg_token}\`); + + if (resp.status != 200) { + r.return(resp.status); + return; + } + + r.variables.foo = await resp.text(); + } + + async function sr(r) { + let reply = await r.subrequest('/auth_check?token=' + + r.variables.arg_token); + if (reply.status != 200) { + r.return(reply.status); + return; + } + + r.variables.foo = reply.responseText; + } + + function route(r) { + let dest = r.variables.arg_dest; + r.variables.upstream = (dest === 'one') + ? '127.0.0.1:$p1' : '127.0.0.1:$p2'; + } + + function auth_check(r) { + let token = r.variables.arg_token; + + if (token === 'valid') { + r.return(200, 'authenticated'); + } else { + r.return(403); + } + } + + function redirect(r) { + r.return(302, 'http://127.0.0.1:$p0/callback'); + } + + async function redirect_async(r) { + await new Promise(resolve => setTimeout(resolve, 5)); + r.return(302, 'http://127.0.0.1:$p0/callback'); + } + + export default { content, deny, exception, noop, override, + decline, content_only, async_timeout, async_deny, + async_exception, sr_skip, sr, fetch, route, + auth_check, redirect, redirect_async }; + +EOF + +$t->write_file_expand('duplicate.conf', bad_conf( + http => 'js_import test.js;', + location => 'js_access test.noop; js_access test.noop;')); + +$t->write_file_expand('no_import.conf', bad_conf( + location => 'js_access test.noop;')); + +$t->try_run('no js_access')->plan(26); + +############################################################################### + +like(http_get('/deny'), qr/403 Forbidden/, + 'js_access sync r.return(403) rejects'); +like(http_post('/deny'), qr/403 Forbidden/, + 'js_access deny with request body'); +like(http_get('/exception'), qr/500 Internal Server Error/, + 'js_access sync exception returns 500'); +like(http_get('/noop'), qr/var:/, + 'js_access noop continues to content'); +like(http_get('/decline'), qr/var:/, + 'js_access decline continues to content'); +like(http_get('/override'), qr/var:overridden/, + 'js_access override in location'); +like(http_get('/content_only'), qr/content_only/, + 'js_content without js_access'); +like(http("GET /no_access HTTP/1.0" . CRLF . + "Host: noaccess" . CRLF . CRLF), + qr/content_only/, + 'js_access not inherited in sibling server'); +like(http_get('/async_timeout'), qr/var:timeout_ok/, + 'async js_access with setTimeout'); +like(http_get('/async_deny'), qr/403 Forbidden/, + 'async js_access r.return(403) rejects'); +like(http_get('/async_exception'), qr/500 Internal Server Error/, + 'async js_access exception returns 500'); +like(http_get('/sr_skip'), qr/var:/, + 'js_access skipped for subrequests'); +like(http_get('/sr?token=valid'), qr/var:authenticated/, + 'subrequest access allow'); +like(http_get('/sr?token=invalid'), qr/403 Forbidden/, + 'subrequest access deny'); +like(http_get('/fetch?token=valid'), qr/var:authenticated/, + 'fetch access allow'); +like(http_get('/fetch?token=invalid'), qr/403 Forbidden/, + 'fetch access deny'); +like(http_get('/route?dest=one'), qr/backend1/, + 'variable routing to backend1'); +like(http_get('/route?dest=two'), qr/backend2/, + 'variable routing to backend2'); +like(http_get('/redirect'), qr/302 Moved/, + 'js_access sync redirect'); +like(http_get('/redirect'), qr!Location: http://127.0.0.1:$p0/callback!, + 'js_access sync redirect Location header'); +like(http_get('/redirect_async'), qr/302 Moved/, + 'js_access async redirect'); +like(http_get('/redirect_async'), qr!Location: http://127.0.0.1:$p0/callback!, + 'js_access async redirect Location header'); + +my ($rc, $out) = nginx_test_conf($t, 'duplicate.conf'); + +isnt($rc, 0, 'duplicate js_access fails'); +like($out, qr/"js_access" directive is duplicate/, + 'duplicate js_access error'); + +($rc, $out) = nginx_test_conf($t, 'no_import.conf'); + +isnt($rc, 0, 'js_access without js_import fails'); +like($out, qr/no imports defined for "js_access" "test\.noop"/, + 'js_access without js_import error'); + +############################################################################### + +sub http_post { + my ($url, %extra) = @_; + + my $p = "POST $url HTTP/1.0" . CRLF . + "Host: localhost" . CRLF . + "Content-Length: 8" . CRLF . + CRLF . + "REQ-BODY"; + + return http($p, %extra); +} + +sub nginx_test_conf { + my ($t, $conf) = @_; + my $testdir = $t->testdir(); + my $cmd = "$Test::Nginx::NGINX -p $testdir/ -c $conf -t " + . "-e error.log 2>&1"; + + my $out = `$cmd`; + + return ($? >> 8, $out); +} + +sub bad_conf { + my %args = @_; + my $http = $args{http} // ''; + my $loc = $args{location} // ''; + + return <<"EOF"; + +%%TEST_GLOBALS%% + +daemon off; + +events { +} + +http { + %%TEST_GLOBALS_HTTP%% + + $http + + server { + listen 127.0.0.1:8080; + + location / { + $loc + } + } +} + +EOF +} diff --git a/nginx/t/js_access_satisfy.t b/nginx/t/js_access_satisfy.t new file mode 100644 index 000000000..926a496cd --- /dev/null +++ b/nginx/t/js_access_satisfy.t @@ -0,0 +1,175 @@ +#!/usr/bin/perl + +# (C) Dmitry Volyntsev +# (C) F5, Inc. + +# Tests for http njs module, js_access directive with satisfy. + +############################################################################### + +use warnings; +use strict; + +use Test::More; + +BEGIN { use FindBin; chdir($FindBin::Bin); } + +use lib 'lib'; +use Test::Nginx; + +############################################################################### + +select STDERR; $| = 1; +select STDOUT; $| = 1; + +my $t = Test::Nginx->new()->has(qw/http rewrite access/) + ->write_file_expand('nginx.conf', <<'EOF'); + +%%TEST_GLOBALS%% + +daemon off; + +events { +} + +http { + %%TEST_GLOBALS_HTTP%% + + js_import test.js; + + server { + listen 127.0.0.1:8080; + server_name localhost; + + location /all_decline_allow { + satisfy all; + allow all; + js_access test.decline; + js_content test.content; + } + + location /all_decline_deny { + satisfy all; + deny all; + js_access test.decline; + js_content test.content; + } + + location /any_allow_deny { + satisfy any; + deny all; + js_access test.allow; + js_content test.content; + } + + location /any_deny_allow { + satisfy any; + allow all; + js_access test.deny; + js_content test.content; + } + + location /any_both_deny { + satisfy any; + deny all; + js_access test.deny; + js_content test.content; + } + + location /any_decline_deny { + satisfy any; + deny all; + js_access test.decline; + js_content test.content; + } + + location /any_decline_allow { + satisfy any; + allow all; + js_access test.decline; + js_content test.content; + } + + location /any_async_allow_deny { + satisfy any; + deny all; + js_access test.async_allow; + js_content test.content; + } + + location /any_async_decline_deny { + satisfy any; + deny all; + js_access test.async_decline; + js_content test.content; + } + } +} + +EOF + +$t->write_file('test.js', <<'EOF'); + function allow(r) { + /* default: normal return yields NGX_OK */ + } + + function deny(r) { + r.return(403); + } + + function decline(r) { + r.decline(); + } + + async function async_allow(r) { + await new Promise(resolve => setTimeout(resolve, 5)); + } + + async function async_decline(r) { + await new Promise(resolve => setTimeout(resolve, 5)); + r.decline(); + } + + function content(r) { + r.return(200, 'PASSED'); + } + + export default { allow, deny, decline, async_allow, async_decline, + content }; +EOF + +$t->try_run('no js_access')->plan(9); + +############################################################################### + +# satisfy all + decline: ip decides +like(http_get('/all_decline_allow'), qr/PASSED/, + 'satisfy all: js declines + ip allows'); +like(http_get('/all_decline_deny'), qr/403 Forbidden/, + 'satisfy all: js declines + ip denies'); + +# satisfy any: js allows overrides ip deny +like(http_get('/any_allow_deny'), qr/PASSED/, + 'satisfy any: js allows + ip denies'); + +# satisfy any: ip allows overrides js deny +like(http_get('/any_deny_allow'), qr/PASSED/, + 'satisfy any: js denies + ip allows'); + +# satisfy any: both deny +like(http_get('/any_both_deny'), qr/403 Forbidden/, + 'satisfy any: both deny'); + +# satisfy any + decline: js has no opinion, ip decides +like(http_get('/any_decline_deny'), qr/403 Forbidden/, + 'satisfy any: js declines + ip denies'); +like(http_get('/any_decline_allow'), qr/PASSED/, + 'satisfy any: js declines + ip allows'); + +# async variants +like(http_get('/any_async_allow_deny'), qr/PASSED/, + 'satisfy any: async js allows + ip denies'); +like(http_get('/any_async_decline_deny'), qr/403 Forbidden/, + 'satisfy any: async js declines + ip denies'); + +############################################################################### diff --git a/ts/ngx_http_js_module.d.ts b/ts/ngx_http_js_module.d.ts index d7dd1c9ed..b0af0c5ab 100644 --- a/ts/ngx_http_js_module.d.ts +++ b/ts/ngx_http_js_module.d.ts @@ -414,6 +414,13 @@ interface NginxHTTPRequest { * @param body Respose body. */ return(status: number, body?: NjsStringOrBuffer): void; + /** + * Signals that the handler has no opinion about whether access + * should be allowed or denied. Useful with the ``satisfy any`` + * directive: without this call the handler implicitly allows + * access (returns NGX_OK to the access phase checker). + */ + decline(): void; /** * Sends a part of the response body to the client. */ From 0f8b858a6f4cc9a9d33e2b098b156a0c37031e95 Mon Sep 17 00:00:00 2001 From: Dmitry Volyntsev Date: Fri, 27 Mar 2026 18:43:56 -0700 Subject: [PATCH 3/4] 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. --- nginx/ngx_http_js_module.c | 639 ++++++++++++++++++++++++++++++++----- nginx/ngx_js.h | 4 + nginx/ngx_js_fetch.c | 3 - nginx/ngx_qjs_fetch.c | 31 +- nginx/t/js_access_body.t | 459 ++++++++++++++++++++++++++ test/ts/test.ts | 9 + ts/ngx_http_js_module.d.ts | 41 +++ 7 files changed, 1091 insertions(+), 95 deletions(-) create mode 100644 nginx/t/js_access_body.t diff --git a/nginx/ngx_http_js_module.c b/nginx/ngx_http_js_module.c index 1654b310b..a448ce579 100644 --- a/nginx/ngx_http_js_module.c +++ b/nginx/ngx_http_js_module.c @@ -77,6 +77,50 @@ struct ngx_http_js_ctx_s { ngx_js_periodic_t *periodic; unsigned in_progress:1; + + /* + * Body-read ownership state for the js_access phase handler. + * + * readRequest*() cannot call ngx_http_read_client_request_body() + * from the JS native directly. The phase handler must choose between + * NGX_AGAIN ("I still own the request") and NGX_DONE ("I finalized + * it myself"), but which one is correct depends on whether the body + * read completes synchronously or goes async -- and that is only + * known after the call returns. + * + * In the async case the body reader takes over r->read_event_handler + * and may call ngx_http_finalize_request() on I/O errors without + * invoking our post_handler. If the phase handler had returned + * NGX_AGAIN, both the phase engine and the body reader would own + * the request, causing a hang on errors such as chunked 413. + * + * IDLE no body read requested (initial and terminal state). + * DEFERRED JS called readRequest*(); the access handler + * will start the read after engine->call() returns. + * IN_PROGRESS ngx_http_read_client_request_body() returned + * NGX_AGAIN; request ownership transferred to the + * body reader via NGX_DONE. access_body_done + * callback resumes phases on completion. + * + * IDLE -> DEFERRED -> IDLE (sync completion or error) + * IDLE -> DEFERRED -> IN_PROGRESS -> IDLE (async completion) + */ +#define NGX_HTTP_JS_BODY_READ_IDLE 0 +#define NGX_HTTP_JS_BODY_READ_DEFERRED 1 +#define NGX_HTTP_JS_BODY_READ_IN_PROGRESS 2 + unsigned body_read_state:2; + + /* + * Collected request body as a contiguous buffer. + * Shared by both synchronous property getters (requestText, + * requestBuffer) and async readRequest*() methods. + */ + unsigned body_read_nul:1; + u_char *body_read_data; + size_t body_read_len; + + /* Pending promise/event for deferred body reads. */ + void *body_read_event; }; @@ -129,6 +173,25 @@ static ngx_int_t ngx_http_js_variable_var(ngx_http_request_t *r, ngx_http_variable_value_t *v, uintptr_t data); static ngx_int_t ngx_http_js_init_vm(ngx_http_request_t *r, njs_int_t proto_id); static void ngx_http_js_cleanup_ctx(void *data); +static void ngx_http_js_body_read_abort(ngx_http_js_ctx_t *ctx); +static ngx_int_t ngx_http_js_collect_body(ngx_http_request_t *r, + ngx_http_js_ctx_t *ctx); +static void ngx_http_js_access_body_finalize(ngx_http_request_t *r, + ngx_http_js_ctx_t *ctx, ngx_int_t rc); +static void ngx_http_js_access_body_done(ngx_http_request_t *r); +static ngx_int_t ngx_http_js_body_resolve(ngx_http_js_ctx_t *ctx, + void *event); + +static njs_int_t ngx_http_js_ext_read_request_body(njs_vm_t *vm, + njs_value_t *args, njs_uint_t nargs, njs_index_t magic, + njs_value_t *retval); +#if (NJS_HAVE_QUICKJS) +static JSValue ngx_http_qjs_ext_read_request_body(JSContext *cx, + JSValueConst this_val, int argc, JSValueConst *argv, int magic); +static ngx_int_t ngx_http_qjs_body_resolve(ngx_http_js_ctx_t *ctx, + void *event); +static void ngx_http_js_read_body_event_destructor(ngx_qjs_event_t *event); +#endif static njs_int_t ngx_http_js_ext_keys_header(njs_vm_t *vm, njs_value_t *value, njs_value_t *keys, ngx_list_t *headers); @@ -999,6 +1062,42 @@ static njs_external_t ngx_http_js_ext_request[] = { } }, + { + .flags = NJS_EXTERN_METHOD, + .name.string = njs_str("readRequestArrayBuffer"), + .writable = 1, + .configurable = 1, + .enumerable = 1, + .u.method = { + .native = ngx_http_js_ext_read_request_body, + .magic8 = NGX_JS_BODY_ARRAY_BUFFER, + } + }, + + { + .flags = NJS_EXTERN_METHOD, + .name.string = njs_str("readRequestJSON"), + .writable = 1, + .configurable = 1, + .enumerable = 1, + .u.method = { + .native = ngx_http_js_ext_read_request_body, + .magic8 = NGX_JS_BODY_JSON, + } + }, + + { + .flags = NJS_EXTERN_METHOD, + .name.string = njs_str("readRequestText"), + .writable = 1, + .configurable = 1, + .enumerable = 1, + .u.method = { + .native = ngx_http_js_ext_read_request_body, + .magic8 = NGX_JS_BODY_TEXT, + } + }, + { .flags = NJS_EXTERN_METHOD, .name.string = njs_str("subrequest"), @@ -1168,6 +1267,13 @@ static const JSCFunctionListEntry ngx_http_qjs_ext_request[] = { JS_CFUNC_DEF("setReturnValue", 1, ngx_http_qjs_ext_set_return_value), JS_CGETSET_DEF("status", ngx_http_qjs_ext_status_get, ngx_http_qjs_ext_status_set), + JS_CFUNC_MAGIC_DEF("readRequestArrayBuffer", 0, + ngx_http_qjs_ext_read_request_body, + NGX_JS_BODY_ARRAY_BUFFER), + JS_CFUNC_MAGIC_DEF("readRequestJSON", 0, + ngx_http_qjs_ext_read_request_body, NGX_JS_BODY_JSON), + JS_CFUNC_MAGIC_DEF("readRequestText", 0, + ngx_http_qjs_ext_read_request_body, NGX_JS_BODY_TEXT), JS_CFUNC_DEF("subrequest", 3, ngx_http_qjs_ext_subrequest), JS_CGETSET_MAGIC_DEF("uri", ngx_http_qjs_ext_string, NULL, offsetof(ngx_http_request_t, uri)), @@ -1293,6 +1399,38 @@ ngx_http_js_access_handler(ngx_http_request_t *r) return NGX_HTTP_INTERNAL_SERVER_ERROR; } + /* JS called readRequest*(). */ + + if (ctx->body_read_state == NGX_HTTP_JS_BODY_READ_DEFERRED) { + + rc = ngx_http_read_client_request_body(r, ngx_http_js_access_body_done); + + if (rc >= NGX_HTTP_SPECIAL_RESPONSE) { + ngx_http_js_body_read_abort(ctx); + return rc; + } + + r->preserve_body = 1; + + if (rc == NGX_OK) { + /* + * Sync: access_body_done callback already fired, resolved + * or rejected the promise. access_body_finalize() returned + * without running posted requests. Fall through to let + * the pending/status check handle the result. + */ + ctx->body_read_state = NGX_HTTP_JS_BODY_READ_IDLE; + goto done; + } + + ctx->body_read_state = NGX_HTTP_JS_BODY_READ_IN_PROGRESS; + ctx->in_progress = 1; + ngx_http_finalize_request(r, NGX_DONE); + return NGX_DONE; + } + +done: + if (ngx_js_ctx_pending(ctx)) { ctx->in_progress = 1; r->write_event_handler = ngx_http_js_access_write_event_handler; @@ -3129,14 +3267,9 @@ ngx_http_js_ext_get_request_body(njs_vm_t *vm, njs_object_prop_t *prop, uint32_t unused, njs_value_t *value, njs_value_t *setval, njs_value_t *retval) { - u_char *p, *body; - size_t len; - ssize_t n; uint32_t buffer_type; - ngx_buf_t *buf; njs_int_t ret; njs_value_t *request_body; - ngx_chain_t *cl; ngx_http_js_ctx_t *ctx; ngx_http_request_t *r; @@ -3164,6 +3297,43 @@ ngx_http_js_ext_get_request_body(njs_vm_t *vm, njs_object_prop_t *prop, return NJS_DECLINED; } + if (ngx_http_js_collect_body(r, ctx) != NGX_OK) { + njs_vm_internal_error(vm, "failed to read request body"); + return NJS_ERROR; + } + + ret = ngx_js_prop(vm, buffer_type, request_body, ctx->body_read_data, + ctx->body_read_len); + if (ret != NJS_OK) { + return NJS_ERROR; + } + + njs_value_assign(retval, request_body); + + return NJS_OK; +} + + +static ngx_int_t +ngx_http_js_collect_body(ngx_http_request_t *r, ngx_http_js_ctx_t *ctx) +{ + u_char *p, *body; + size_t len; + ssize_t n; + ngx_buf_t *buf; + ngx_chain_t *cl; + + if (ctx->body_read_data != NULL) { + return NGX_OK; + } + + if (r->request_body == NULL || r->request_body->bufs == NULL) { + ctx->body_read_data = (u_char *) ""; + ctx->body_read_len = 0; + ctx->body_read_nul = 1; + return NGX_OK; + } + cl = r->request_body->bufs; buf = cl->buf; @@ -3172,66 +3342,289 @@ ngx_http_js_ext_get_request_body(njs_vm_t *vm, njs_object_prop_t *prop, "http js reading request body from a temporary file"); if (buf == NULL || !buf->in_file) { - njs_vm_internal_error(vm, "cannot find request body"); - return NJS_ERROR; + return NGX_ERROR; } len = buf->file_last - buf->file_pos; - body = ngx_pnalloc(r->pool, len); + body = ngx_pnalloc(r->pool, len + 1); if (body == NULL) { - njs_vm_memory_error(vm); - return NJS_ERROR; + return NGX_ERROR; } n = ngx_read_file(buf->file, body, len, buf->file_pos); if (n != (ssize_t) len) { - njs_vm_internal_error(vm, "failed to read request body"); - return NJS_ERROR; + return NGX_ERROR; } - goto done; - } + body[len] = '\0'; + ctx->body_read_nul = 1; - if (cl->next == NULL) { + } else if (cl->next == NULL) { len = buf->last - buf->pos; body = buf->pos; - goto done; + } else { + len = buf->last - buf->pos; + cl = cl->next; + + for ( /* void */ ; cl; cl = cl->next) { + buf = cl->buf; + len += buf->last - buf->pos; + } + + p = ngx_pnalloc(r->pool, len + 1); + if (p == NULL) { + return NGX_ERROR; + } + + body = p; + cl = r->request_body->bufs; + + for ( /* void */ ; cl; cl = cl->next) { + buf = cl->buf; + p = ngx_cpymem(p, buf->pos, buf->last - buf->pos); + } + + *p = '\0'; + ctx->body_read_nul = 1; } - len = buf->last - buf->pos; - cl = cl->next; + ctx->body_read_data = body; + ctx->body_read_len = len; + + return NGX_OK; +} + - for ( /* void */ ; cl; cl = cl->next) { - buf = cl->buf; - len += buf->last - buf->pos; +static void +ngx_http_js_body_read_abort(ngx_http_js_ctx_t *ctx) +{ + if (ctx->body_read_event != NULL) { +#if (NJS_HAVE_QUICKJS) + if (ctx->engine->type == NGX_ENGINE_QJS) { + ngx_js_del_event(ctx, (ngx_qjs_event_t *) ctx->body_read_event); + } else +#endif + { + ngx_js_del_event(ctx, (ngx_js_event_t *) ctx->body_read_event); + } + + ctx->body_read_event = NULL; } - p = ngx_pnalloc(r->pool, len); - if (p == NULL) { + ctx->body_read_state = NGX_HTTP_JS_BODY_READ_IDLE; +} + + +static njs_int_t +ngx_http_js_body_to_value(njs_vm_t *vm, ngx_http_js_ctx_t *ctx, + ngx_uint_t type, njs_value_t *retval) +{ + njs_int_t ret; + njs_opaque_value_t arg; + + switch (type) { + case NGX_JS_BODY_ARRAY_BUFFER: + return njs_vm_value_array_buffer_set(vm, retval, + ctx->body_read_data, + ctx->body_read_len); + + case NGX_JS_BODY_JSON: + ret = njs_vm_value_string_create(vm, njs_value_arg(&arg), + ctx->body_read_data, + ctx->body_read_len); + if (ret != NJS_OK) { + return NJS_ERROR; + } + + return njs_vm_json_parse(vm, njs_value_arg(&arg), 1, retval); + + case NGX_JS_BODY_TEXT: + default: + return njs_vm_value_string_create(vm, retval, + ctx->body_read_data, + ctx->body_read_len); + } +} + + +static void +ngx_http_js_access_body_finalize(ngx_http_request_t *r, ngx_http_js_ctx_t *ctx, + ngx_int_t rc) +{ + switch (ctx->body_read_state) { + case NGX_HTTP_JS_BODY_READ_IN_PROGRESS: + ctx->body_read_state = NGX_HTTP_JS_BODY_READ_IDLE; + + if (ngx_js_ctx_pending(ctx)) { + r->write_event_handler = ngx_http_js_access_write_event_handler; + return; + } + + r->write_event_handler = ngx_http_core_run_phases; + ngx_http_core_run_phases(r); + break; + + case NGX_HTTP_JS_BODY_READ_DEFERRED: + /* + * Sync body read completion from the access handler. + * The promise is resolved/rejected but the access handler + * is still on the call stack -- do not run posted requests + * or resume phases here; the access handler will do it. + */ + break; + + case NGX_HTTP_JS_BODY_READ_IDLE: + default: + ngx_http_js_event_finalize(r, rc); + } +} + + +static ngx_int_t +ngx_http_js_body_resolve(ngx_http_js_ctx_t *ctx, void *event) +{ + njs_vm_t *vm; + njs_int_t rc; + ngx_js_event_t *ev; + njs_opaque_value_t result; + + ev = event; + vm = ctx->engine->u.njs.vm; + + rc = ngx_http_js_body_to_value(vm, ctx, (uintptr_t) ev->data, + njs_value_arg(&result)); + if (rc != NJS_OK) { + njs_vm_exception_get(vm, njs_value_arg(&result)); + + rc = ngx_js_call(vm, njs_value_function(njs_value_arg(&ev->args[1])), + &result, 1); + + ngx_js_del_event(ctx, ev); + return rc; + } + + rc = ngx_js_call(vm, njs_value_function(njs_value_arg(&ev->function)), + &result, 1); + + ngx_js_del_event(ctx, ev); + + return rc; +} + + +static void +ngx_http_js_access_body_done(ngx_http_request_t *r) +{ + ngx_int_t rc; + ngx_http_js_ctx_t *ctx; + + ctx = ngx_http_get_module_ctx(r, ngx_http_js_module); + + ngx_log_debug0(NGX_LOG_DEBUG_HTTP, r->connection->log, 0, + "http js body read done"); + + /* + * ngx_http_read_client_request_body() incremented count. + * For the IN_PROGRESS (async) path, ngx_http_finalize_request(NGX_DONE) + * already consumed it; for the DEFERRED (sync) path, we consume it here. + */ + if (ctx->body_read_state == NGX_HTTP_JS_BODY_READ_DEFERRED) { + r->main->count--; + } + + if (ctx->body_read_event == NULL) { + return; + } + + if (ngx_http_js_collect_body(r, ctx) != NGX_OK) { + ngx_http_js_body_read_abort(ctx); + ngx_http_js_access_body_finalize(r, ctx, NGX_ERROR); + return; + } + +#if (NJS_HAVE_QUICKJS) + if (ctx->engine->type == NGX_ENGINE_QJS) { + rc = ngx_http_qjs_body_resolve(ctx, ctx->body_read_event); + } else +#endif + { + rc = ngx_http_js_body_resolve(ctx, ctx->body_read_event); + } + + ctx->body_read_event = NULL; + + ngx_http_js_access_body_finalize(r, ctx, rc); +} + + +static njs_int_t +ngx_http_js_ext_read_request_body(njs_vm_t *vm, njs_value_t *args, + njs_uint_t nargs, njs_index_t magic, njs_value_t *retval) +{ + ngx_int_t rc; + ngx_js_event_t *event; + ngx_http_js_ctx_t *ctx; + ngx_http_request_t *r; + + r = njs_vm_external(vm, ngx_http_js_request_proto_id, + njs_argument(args, 0)); + if (r == NULL) { + njs_vm_error(vm, "\"this\" is not an external"); + return NJS_ERROR; + } + + ctx = ngx_http_get_module_ctx(r, ngx_http_js_module); + + event = njs_mp_zalloc(njs_vm_memory_pool(vm), + sizeof(ngx_js_event_t) + + sizeof(njs_opaque_value_t) * 2); + if (njs_slow_path(event == NULL)) { njs_vm_memory_error(vm); return NJS_ERROR; } - body = p; - cl = r->request_body->bufs; + /* + * r->request_body is set by ngx_http_read_client_request_body(). + * JS only runs after body reading completes, so non-NULL means + * the body is available. + */ + if (r->request_body) { + goto resolve; + } - for ( /* void */ ; cl; cl = cl->next) { - buf = cl->buf; - p = ngx_cpymem(p, buf->pos, buf->last - buf->pos); + if (ctx->body_read_event) { + njs_vm_error(vm, "request body is already being read"); + return NJS_ERROR; } -done: + event->fd = ctx->event_id++; + event->args = (njs_opaque_value_t *) &event[1]; + event->data = (void *) (uintptr_t) magic; - ret = ngx_js_prop(vm, buffer_type, request_body, body, len); - if (ret != NJS_OK) { + rc = njs_vm_promise_create(vm, retval, njs_value_arg(event->args)); + if (rc != NJS_OK) { return NJS_ERROR; } - njs_value_assign(retval, request_body); + njs_value_assign(&event->function, njs_value_arg(event->args)); + + ngx_js_add_event(ctx, event); + + ctx->body_read_event = event; + ctx->body_read_state = NGX_HTTP_JS_BODY_READ_DEFERRED; return NJS_OK; + +resolve: + + if (ngx_http_js_collect_body(r, ctx) != NGX_OK) { + njs_vm_memory_error(vm); + return NJS_ERROR; + } + + return ngx_http_js_body_to_value(vm, ctx, (ngx_uint_t) magic, retval); } @@ -5486,14 +5879,10 @@ ngx_http_qjs_ext_response_body(JSContext *cx, JSValueConst this_val, int type) static JSValue ngx_http_qjs_ext_request_body(JSContext *cx, JSValueConst this_val, int type) { - u_char *p, *data; - size_t len; - ssize_t n; JSValue body; uint32_t buffer_type; - ngx_buf_t *buf; - ngx_chain_t *cl; ngx_http_request_t *r; + ngx_http_js_ctx_t *ctx; ngx_http_qjs_request_t *req; req = JS_GetOpaque(this_val, NGX_QJS_CLASS_ID_HTTP_REQUEST); @@ -5517,70 +5906,170 @@ ngx_http_qjs_ext_request_body(JSContext *cx, JSValueConst this_val, int type) return JS_UNDEFINED; } - cl = r->request_body->bufs; - buf = cl->buf; + ctx = ngx_http_get_module_ctx(r, ngx_http_js_module); - if (r->request_body->temp_file) { - ngx_log_error(NGX_LOG_WARN, r->connection->log, 0, - "http js reading request body from a temporary file"); + if (ngx_http_js_collect_body(r, ctx) != NGX_OK) { + return JS_ThrowInternalError(cx, "failed to read request body"); + } - if (buf == NULL || !buf->in_file) { - return JS_ThrowInternalError(cx, "cannot find body file"); - } + body = ngx_qjs_prop(cx, buffer_type, ctx->body_read_data, + ctx->body_read_len); + if (JS_IsException(body)) { + return JS_EXCEPTION; + } - len = buf->file_last - buf->file_pos; + req->request_body = body; - data = ngx_pnalloc(r->pool, len); - if (data == NULL) { - return JS_ThrowOutOfMemory(cx); + return JS_DupValue(cx, req->request_body); +} + + +static JSValue +ngx_http_qjs_body_to_value(JSContext *cx, ngx_http_js_ctx_t *ctx, + ngx_uint_t type) +{ + JSValue str; + const char *cstr; + + switch (type) { + case NGX_JS_BODY_ARRAY_BUFFER: + return JS_NewArrayBuffer(cx, ctx->body_read_data, + ctx->body_read_len, NULL, NULL, 0); + + case NGX_JS_BODY_JSON: + if (ctx->body_read_nul) { + return JS_ParseJSON(cx, (const char *) ctx->body_read_data, + ctx->body_read_len, ""); } - n = ngx_read_file(buf->file, data, len, buf->file_pos); - if (n != (ssize_t) len) { - return JS_ThrowInternalError(cx, "failed to read request body"); + str = qjs_string_create(cx, ctx->body_read_data, + ctx->body_read_len); + if (JS_IsException(str)) { + return str; + } + + cstr = JS_ToCString(cx, str); + JS_FreeValue(cx, str); + + if (cstr == NULL) { + return JS_EXCEPTION; } - goto done; + str = JS_ParseJSON(cx, cstr, ctx->body_read_len, ""); + JS_FreeCString(cx, cstr); + + return str; + + case NGX_JS_BODY_TEXT: + default: + return qjs_string_create(cx, ctx->body_read_data, + ctx->body_read_len); } +} + + +static ngx_int_t +ngx_http_qjs_body_resolve(ngx_http_js_ctx_t *ctx, void *event) +{ + JSValue result; + JSContext *cx; + ngx_int_t rc; + ngx_qjs_event_t *ev; + + ev = event; + cx = ctx->engine->u.qjs.ctx; + + result = ngx_http_qjs_body_to_value(cx, ctx, (uintptr_t) ev->data); + if (JS_IsException(result)) { + result = JS_GetException(cx); + + rc = ngx_qjs_call(cx, ev->args[1], &result, 1); + + JS_FreeValue(cx, result); + ngx_js_del_event(ctx, ev); + return rc; + } + + rc = ngx_qjs_call(cx, ev->function, &result, 1); + + JS_FreeValue(cx, result); + ngx_js_del_event(ctx, ev); + + return rc; +} - if (cl->next == NULL) { - len = buf->last - buf->pos; - data = buf->pos; - goto done; +static JSValue +ngx_http_qjs_ext_read_request_body(JSContext *cx, JSValueConst this_val, + int argc, JSValueConst *argv, int magic) +{ + JSValue retval; + ngx_qjs_event_t *event; + ngx_http_js_ctx_t *ctx; + ngx_http_request_t *r; + ngx_http_qjs_request_t *req; + + req = JS_GetOpaque(this_val, NGX_QJS_CLASS_ID_HTTP_REQUEST); + if (req == NULL) { + return JS_ThrowInternalError(cx, "\"this\" is not a request object"); } - len = buf->last - buf->pos; - cl = cl->next; + r = req->request; + ctx = ngx_http_get_module_ctx(r, ngx_http_js_module); + + if (r->request_body) { + goto resolve; + } - for ( /* void */ ; cl; cl = cl->next) { - buf = cl->buf; - len += buf->last - buf->pos; + if (ctx->body_read_event) { + return JS_ThrowInternalError(cx, "request body is already being read"); } - p = ngx_pnalloc(r->pool, len); - if (p == NULL) { + event = ngx_pcalloc(r->pool, sizeof(ngx_qjs_event_t) + sizeof(JSValue) * 2); + if (event == NULL) { return JS_ThrowOutOfMemory(cx); } - data = p; - cl = r->request_body->bufs; + event->ctx = cx; + event->fd = ctx->event_id++; + event->args = (JSValue *) &event[1]; + event->data = (void *) (uintptr_t) magic; - for ( /* void */ ; cl; cl = cl->next) { - buf = cl->buf; - p = ngx_cpymem(p, buf->pos, buf->last - buf->pos); + retval = JS_NewPromiseCapability(cx, &event->args[0]); + if (JS_IsException(retval)) { + return JS_EXCEPTION; } -done: + event->function = JS_DupValue(cx, event->args[0]); + event->destructor = ngx_http_js_read_body_event_destructor; - body = ngx_qjs_prop(cx, buffer_type, data, len); - if (JS_IsException(body)) { - return JS_EXCEPTION; + ngx_js_add_event(ctx, event); + + ctx->body_read_event = event; + ctx->body_read_state = NGX_HTTP_JS_BODY_READ_DEFERRED; + + return retval; + +resolve: + + if (ngx_http_js_collect_body(r, ctx) != NGX_OK) { + return JS_ThrowOutOfMemory(cx); } - req->request_body = body; + return ngx_http_qjs_body_to_value(cx, ctx, (ngx_uint_t) magic); +} - return JS_DupValue(cx, req->request_body); + +static void +ngx_http_js_read_body_event_destructor(ngx_qjs_event_t *event) +{ + JSContext *cx; + + cx = event->ctx; + + JS_FreeValue(cx, event->function); + JS_FreeValue(cx, event->args[0]); + JS_FreeValue(cx, event->args[1]); } diff --git a/nginx/ngx_js.h b/nginx/ngx_js.h index 6012e7fd9..63cbf00a6 100644 --- a/nginx/ngx_js.h +++ b/nginx/ngx_js.h @@ -32,6 +32,10 @@ #define NGX_JS_BOOLEAN 8 #define NGX_JS_NUMBER 16 +#define NGX_JS_BODY_ARRAY_BUFFER 0 +#define NGX_JS_BODY_JSON 1 +#define NGX_JS_BODY_TEXT 2 + #define NGX_JS_BOOL_FALSE 0 #define NGX_JS_BOOL_TRUE 1 #define NGX_JS_BOOL_UNSET 2 diff --git a/nginx/ngx_js_fetch.c b/nginx/ngx_js_fetch.c index fd40a5bad..608af25ba 100644 --- a/nginx/ngx_js_fetch.c +++ b/nginx/ngx_js_fetch.c @@ -268,9 +268,6 @@ static njs_external_t ngx_js_ext_http_request[] = { .enumerable = 1, .u.method = { .native = ngx_request_js_ext_body, -#define NGX_JS_BODY_ARRAY_BUFFER 0 -#define NGX_JS_BODY_JSON 1 -#define NGX_JS_BODY_TEXT 2 .magic8 = NGX_JS_BODY_ARRAY_BUFFER } }, diff --git a/nginx/ngx_qjs_fetch.c b/nginx/ngx_qjs_fetch.c index 8e010bec8..7c0a7a34d 100644 --- a/nginx/ngx_qjs_fetch.c +++ b/nginx/ngx_qjs_fetch.c @@ -131,22 +131,19 @@ static const JSCFunctionListEntry ngx_qjs_ext_fetch_headers_proto[] = { static const JSCFunctionListEntry ngx_qjs_ext_fetch_request_proto[] = { -#define NGX_QJS_BODY_ARRAY_BUFFER 0 -#define NGX_QJS_BODY_JSON 1 -#define NGX_QJS_BODY_TEXT 2 JS_CFUNC_MAGIC_DEF("arrayBuffer", 0, ngx_qjs_ext_fetch_request_body, - NGX_QJS_BODY_ARRAY_BUFFER), + NGX_JS_BODY_ARRAY_BUFFER), JS_CGETSET_DEF("bodyUsed", ngx_qjs_ext_fetch_request_body_used, NULL), JS_CGETSET_DEF("cache", ngx_qjs_ext_fetch_request_cache, NULL), JS_CGETSET_DEF("credentials", ngx_qjs_ext_fetch_request_credentials, NULL), JS_CFUNC_MAGIC_DEF("json", 0, ngx_qjs_ext_fetch_request_body, - NGX_QJS_BODY_JSON), + NGX_JS_BODY_JSON), JS_CGETSET_DEF("headers", ngx_qjs_ext_fetch_request_headers, NULL ), JS_CGETSET_MAGIC_DEF("method", ngx_qjs_ext_fetch_request_field, NULL, offsetof(ngx_js_request_t, method) ), JS_CGETSET_DEF("mode", ngx_qjs_ext_fetch_request_mode, NULL), JS_CFUNC_MAGIC_DEF("text", 0, ngx_qjs_ext_fetch_request_body, - NGX_QJS_BODY_TEXT), + NGX_JS_BODY_TEXT), JS_CGETSET_MAGIC_DEF("url", ngx_qjs_ext_fetch_request_field, NULL, offsetof(ngx_js_request_t, url) ), }; @@ -154,17 +151,17 @@ static const JSCFunctionListEntry ngx_qjs_ext_fetch_request_proto[] = { static const JSCFunctionListEntry ngx_qjs_ext_fetch_response_proto[] = { JS_CFUNC_MAGIC_DEF("arrayBuffer", 0, ngx_qjs_ext_fetch_response_body, - NGX_QJS_BODY_ARRAY_BUFFER), + NGX_JS_BODY_ARRAY_BUFFER), JS_CGETSET_DEF("bodyUsed", ngx_qjs_ext_fetch_response_body_used, NULL), JS_CGETSET_DEF("headers", ngx_qjs_ext_fetch_response_headers, NULL ), JS_CFUNC_MAGIC_DEF("json", 0, ngx_qjs_ext_fetch_response_body, - NGX_QJS_BODY_JSON), + NGX_JS_BODY_JSON), JS_CGETSET_DEF("ok", ngx_qjs_ext_fetch_response_ok, NULL), JS_CGETSET_DEF("redirected", ngx_qjs_ext_fetch_response_redirected, NULL), JS_CGETSET_DEF("status", ngx_qjs_ext_fetch_response_status, NULL), JS_CGETSET_DEF("statusText", ngx_qjs_ext_fetch_response_status_text, NULL), JS_CFUNC_MAGIC_DEF("text", 0, ngx_qjs_ext_fetch_response_body, - NGX_QJS_BODY_TEXT), + NGX_JS_BODY_TEXT), JS_CGETSET_DEF("type", ngx_qjs_ext_fetch_response_type, NULL), JS_CGETSET_MAGIC_DEF("url", ngx_qjs_ext_fetch_response_field, NULL, offsetof(ngx_js_response_t, url) ), @@ -2027,7 +2024,7 @@ ngx_qjs_ext_fetch_request_body(JSContext *cx, JSValueConst this_val, request->body_used = 1; switch (magic) { - case NGX_QJS_BODY_ARRAY_BUFFER: + case NGX_JS_BODY_ARRAY_BUFFER: /* * no free_func for JS_NewArrayBuffer() * because request->body is allocated from e->pool @@ -2041,15 +2038,15 @@ ngx_qjs_ext_fetch_request_body(JSContext *cx, JSValueConst this_val, break; - case NGX_QJS_BODY_JSON: - case NGX_QJS_BODY_TEXT: + case NGX_JS_BODY_JSON: + case NGX_JS_BODY_TEXT: default: result = qjs_string_create(cx, request->body.data, request->body.len); if (JS_IsException(result)) { return JS_ThrowOutOfMemory(cx); } - if (magic == NGX_QJS_BODY_JSON) { + if (magic == NGX_JS_BODY_JSON) { string = js_malloc(cx, request->body.len + 1); JS_FreeValue(cx, result); @@ -2309,14 +2306,14 @@ ngx_qjs_ext_fetch_response_body(JSContext *cx, JSValueConst this_val, response->body_used = 1; switch (magic) { - case NGX_QJS_BODY_ARRAY_BUFFER: - case NGX_QJS_BODY_TEXT: + case NGX_JS_BODY_ARRAY_BUFFER: + case NGX_JS_BODY_TEXT: ret = njs_chb_join(&response->chain, &string); if (ret != NJS_OK) { return JS_ThrowOutOfMemory(cx); } - if (magic == NGX_QJS_BODY_TEXT) { + if (magic == NGX_JS_BODY_TEXT) { result = qjs_string_create(cx, string.start, string.length); if (JS_IsException(result)) { return JS_ThrowOutOfMemory(cx); @@ -2338,7 +2335,7 @@ ngx_qjs_ext_fetch_response_body(JSContext *cx, JSValueConst this_val, break; - case NGX_QJS_BODY_JSON: + case NGX_JS_BODY_JSON: default: /* 'string.start' must be zero terminated. */ njs_chb_append_literal(&response->chain, "\0"); diff --git a/nginx/t/js_access_body.t b/nginx/t/js_access_body.t new file mode 100644 index 000000000..abe53b398 --- /dev/null +++ b/nginx/t/js_access_body.t @@ -0,0 +1,459 @@ +#!/usr/bin/perl + +# (C) Dmitry Volyntsev +# (C) F5, Inc. + +# Tests for http njs module, js_access body reading methods. + +############################################################################### + +use warnings; +use strict; + +use Test::More; + +use Socket qw/ CRLF /; + +BEGIN { use FindBin; chdir($FindBin::Bin); } + +use lib 'lib'; +use Test::Nginx qw/ :DEFAULT http_end /; + +############################################################################### + +select STDERR; $| = 1; +select STDOUT; $| = 1; + +my $t = Test::Nginx->new()->has(qw/http proxy/) + ->write_file_expand('nginx.conf', <<'EOF'); + +%%TEST_GLOBALS%% + +daemon off; + +events { +} + +http { + %%TEST_GLOBALS_HTTP%% + + js_import test.js; + + js_var $foo; + + server { + listen 127.0.0.1:8080; + server_name localhost; + + location /text { + js_access test.read_text; + js_content test.content; + } + + location /buffer { + js_access test.read_buffer; + js_content test.content; + } + + location /text_twice { + js_access test.read_text_twice; + js_content test.content; + } + + location /buffer_twice { + js_access test.read_buffer_twice; + js_content test.content; + } + + location /concurrent_text_buffer { + js_access test.read_concurrent_text_buffer; + js_content test.content; + } + + location /text_then_buffer { + js_access test.read_text_then_buffer; + js_content test.content; + } + + location /json { + js_access test.read_json; + js_content test.content; + } + + location /json_invalid { + js_access test.read_json_invalid; + js_content test.content; + } + + location /empty { + js_access test.read_text; + js_content test.content; + } + + location /big { + client_body_buffer_size 64k; + js_access test.read_text_length; + js_content test.content; + } + + location /big_4k { + client_body_buffer_size 4k; + js_access test.read_text_length; + js_content test.content; + } + + location /slow { + js_access test.read_text; + js_content test.content; + } + + location /chunked { + js_access test.read_text; + js_content test.content; + } + + location /text_timeout { + js_access test.read_text_timeout; + js_content test.content; + } + + location /access_content_async { + js_access test.read_text_timeout; + js_content test.content_async; + } + + location /content_text { + js_content test.content_text; + } + + location /proxy { + js_access test.read_text; + proxy_pass http://127.0.0.1:%%PORT_8081%%; + } + + location /in_file { + client_body_in_file_only on; + js_access test.read_text; + js_content test.content; + } + + location /too_large { + client_max_body_size 4; + js_access test.read_text; + js_content test.content; + } + + location /too_large_chunked { + client_max_body_size 4; + client_body_timeout 2s; + js_access test.read_text; + js_content test.content; + } + + + } + + server { + listen 127.0.0.1:8081; + + location / { + js_content test.echo_body; + } + } +} + +EOF + +$t->write_file('test.js', < setTimeout(resolve, 5)); + r.return(200, `var:\${r.variables.foo}:content-async`); + } + + async function content_text(r) { + let body = await r.readRequestText(); + r.return(200, `content:\${body}`); + } + + async function read_text(r) { + let body = await r.readRequestText(); + r.variables.foo = body; + } + + async function read_text_timeout(r) { + let body = await r.readRequestText(); + await new Promise(resolve => setTimeout(resolve, 5)); + r.variables.foo = body + ':after-timeout'; + } + + async function read_buffer(r) { + let buf = await r.readRequestArrayBuffer(); + r.variables.foo = String.fromCharCode.apply(null, new Uint8Array(buf)); + } + + async function read_text_twice(r) { + let first = await r.readRequestText(); + let second = await r.readRequestText(); + r.variables.foo = (first === second) ? 'same' : 'different'; + } + + async function read_buffer_twice(r) { + let a = new Uint8Array(await r.readRequestArrayBuffer()); + let b = new Uint8Array(await r.readRequestArrayBuffer()); + let eq = a.length === b.length + && a.every((v, i) => v === b[i]); + r.variables.foo = eq ? 'same' : 'different'; + } + + async function read_concurrent_text_buffer(r) { + try { + await Promise.all([ + r.readRequestText(), + r.readRequestArrayBuffer() + ]); + + r.variables.foo = 'no_error'; + + } catch (e) { + r.variables.foo = e.message; + } + } + + async function read_text_then_buffer(r) { + let text = await r.readRequestText(); + let buf = await r.readRequestArrayBuffer(); + let text2 = String.fromCharCode.apply(null, new Uint8Array(buf)); + r.variables.foo = (text === text2) ? 'same' : 'different'; + } + + async function read_json(r) { + let obj = await r.readRequestJSON(); + r.variables.foo = obj.method + ':' + obj.name; + } + + async function read_json_invalid(r) { + try { + await r.readRequestJSON(); + r.variables.foo = 'no_error'; + } catch (e) { + r.variables.foo = e.constructor.name; + } + } + + async function read_text_length(r) { + let body = await r.readRequestText(); + r.variables.foo = body.length; + } + + function echo_body(r) { + r.return(200, 'echo:' + r.requestText); + } + + export default { content, content_async, content_text, read_text, + read_text_timeout, read_buffer, read_text_twice, + read_buffer_twice, read_concurrent_text_buffer, + read_text_then_buffer, read_json, read_json_invalid, + read_text_length, echo_body }; + +EOF + +$t->try_run('no js_access')->plan(23); + +############################################################################### + +like(http_post('/text'), qr/var:REQ-BODY/, 'readRequestText'); +like(http_post('/buffer'), qr/var:REQ-BODY/, 'readRequestArrayBuffer'); +like(http_post_json('/json', '{"method":"GET","name":"test"}'), + qr/var:GET:test/, 'readRequestJSON'); +like(http_post_json('/json_invalid', 'not-json'), qr/var:SyntaxError/, + 'readRequestJSON invalid rejects with SyntaxError'); +like(http_get('/empty'), qr/var:/, 'readRequestText empty body'); + +like(http_post('/text_twice'), qr/var:same/, + 'readRequestText twice returns same value'); +like(http_post('/buffer_twice'), qr/var:same/, + 'readRequestArrayBuffer twice returns same value'); +like(http_post('/text_then_buffer'), qr/var:same/, + 'readRequestText then readRequestArrayBuffer same content'); +like(http_post('/concurrent_text_buffer'), + qr/var:request body is already being read/, + 'concurrent body read throws error'); + +like(http_post_big('/big'), qr/var:10240/, + 'readRequestText large body'); +like(http_post_big('/big_4k'), qr/var:10240/, + 'readRequestText large body with small buffer'); + +like(http_post('/proxy'), qr/echo:REQ-BODY/, + 'body preserved for proxy_pass'); +like(http_post('/in_file'), qr/var:REQ-BODY/, + 'readRequestText from temp file'); + +like(http_post_slow('/slow'), qr/var:SLOW-BODY/, + 'readRequestText with slow client'); +like(http_post_chunked('/chunked'), qr/var:CHUNKED-BODY/, + 'readRequestText chunked transfer encoding'); +like(http_post('/text_timeout'), qr/var:REQ-BODY:after-timeout/, + 'readRequestText before async action in js_access'); +like(http_post('/access_content_async'), + qr/var:REQ-BODY:after-timeout:content-async/, + 'async js_content after async js_access body read'); +like(http_post('/content_text'), qr/content:REQ-BODY/, + 'readRequestText in js_content'); + +http_post_disconnect('/text'); +like(http_post('/text'), qr/var:REQ-BODY/, + 'readRequestText after client disconnect'); + +like(http_post('/too_large'), qr/413 Request Entity Too Large/, + 'readRequestText client_max_body_size exceeded'); + +like(http_post_slow_chunked('/too_large_chunked'), + qr/413 Request Entity Too Large/, + 'readRequestText chunked body exceeds client_max_body_size'); +like(http_post_chunked_too_large('/too_large_chunked'), + qr/413 Request Entity Too Large/, + 'readRequestText chunked body rejected in preread'); +like(http_post('/text'), qr/var:REQ-BODY/, + 'readRequestText works after chunked 413'); + +############################################################################### + +sub http_post { + my ($url, %extra) = @_; + + my $p = "POST $url HTTP/1.0" . CRLF . + "Host: localhost" . CRLF . + "Content-Length: 8" . CRLF . + CRLF . + "REQ-BODY"; + + return http($p, %extra); +} + +sub http_post_json { + my ($url, $body, %extra) = @_; + + my $p = "POST $url HTTP/1.0" . CRLF . + "Host: localhost" . CRLF . + "Content-Type: application/json" . CRLF . + "Content-Length: " . length($body) . CRLF . + CRLF . + $body; + + return http($p, %extra); +} + +sub http_post_big { + my ($url, %extra) = @_; + + my $p = "POST $url HTTP/1.0" . CRLF . + "Host: localhost" . CRLF . + "Content-Length: 10240" . CRLF . + CRLF . + ("1234567890" x 1024); + + return http($p, %extra); +} + +sub http_post_slow { + my ($url, %extra) = @_; + + my $header = "POST $url HTTP/1.1" . CRLF . + "Host: localhost" . CRLF . + "Content-Length: 9" . CRLF . + "Connection: close" . CRLF . + CRLF; + + my $s = http($header, start => 1); + + select undef, undef, undef, 0.1; + print $s "SLOW"; + + select undef, undef, undef, 0.1; + print $s "-BODY"; + + return http_end($s); +} + +sub http_post_disconnect { + my ($url) = @_; + + my $header = "POST $url HTTP/1.1" . CRLF . + "Host: localhost" . CRLF . + "Content-Length: 1024" . CRLF . + "Connection: close" . CRLF . + CRLF; + + my $s = http($header, start => 1); + + select undef, undef, undef, 0.1; + print $s "PARTIAL"; + + select undef, undef, undef, 0.1; + close($s); + + select undef, undef, undef, 0.3; +} + +sub http_post_slow_chunked { + my ($url, %extra) = @_; + + my $header = "POST $url HTTP/1.1" . CRLF . + "Host: localhost" . CRLF . + "Transfer-Encoding: chunked" . CRLF . + "Connection: close" . CRLF . + CRLF; + + my $s = http($header, start => 1); + + select undef, undef, undef, 0.1; + print $s "8" . CRLF . "TOOLARGE" . CRLF; + + my $resp = http_end($s); + + # wait for nginx to finish lingering close and cleanup + select undef, undef, undef, 0.5; + + return $resp; +} + +sub http_post_chunked { + my ($url, %extra) = @_; + + my $p = "POST $url HTTP/1.1" . CRLF . + "Host: localhost" . CRLF . + "Transfer-Encoding: chunked" . CRLF . + "Connection: close" . CRLF . + CRLF . + "8" . CRLF . + "CHUNKED-" . CRLF . + "4" . CRLF . + "BODY" . CRLF . + "0" . CRLF . + CRLF; + + return http($p, %extra); +} + +sub http_post_chunked_too_large { + my ($url, %extra) = @_; + + my $p = "POST $url HTTP/1.1" . CRLF . + "Host: localhost" . CRLF . + "Transfer-Encoding: chunked" . CRLF . + "Connection: close" . CRLF . + CRLF . + "8" . CRLF . + "TOOLARGE" . CRLF . + "0" . CRLF . + CRLF; + + return http($p, %extra); +} + +############################################################################### diff --git a/test/ts/test.ts b/test/ts/test.ts index a30e02fe8..f810bedc6 100644 --- a/test/ts/test.ts +++ b/test/ts/test.ts @@ -69,6 +69,15 @@ async function http_module(r: NginxHTTPRequest) { // r.requestBuffer r.requestBuffer?.equals(Buffer.from([1])); + // r.readRequestText + let text: string = await r.readRequestText(); + + // r.readRequestArrayBuffer + let buf: ArrayBuffer = await r.readRequestArrayBuffer(); + + // r.readRequestJSON + let json: any = await r.readRequestJSON(); + // r.responseText r.responseText == 'a'; r.responseText?.startsWith('a'); diff --git a/ts/ngx_http_js_module.d.ts b/ts/ngx_http_js_module.d.ts index b0af0c5ab..6f9a07c78 100644 --- a/ts/ngx_http_js_module.d.ts +++ b/ts/ngx_http_js_module.d.ts @@ -381,6 +381,47 @@ interface NginxHTTPRequest { * @deprecated Use `requestText` or `requestBuffer` instead. */ readonly requestBody?: string; + /** + * Reads the client request body and returns a Promise resolving + * with the body as a string. + * + * Available in js_access and js_content directives. The request body + * size is limited by client_max_body_size. + * + * The body is read once and cached on the request: subsequent + * `readRequestText`, `readRequestArrayBuffer`, and `readRequestJSON` + * calls resolve synchronously from the cache and do not re-read the + * wire. This deliberately differs from the WHATWG Fetch Body mixin + * (which makes the body unusable after the first call) and matches + * the server-side caching pattern used by Express, Flask, and similar + * frameworks. + * + * A second call issued while a previous `readRequest*` promise has + * not yet resolved throws `"request body is already being read"`. + * + * @returns A Promise that resolves with the request body as a string. + * @since 0.9.9 + */ + readRequestText(): Promise; + /** + * Reads the client request body and returns a Promise resolving + * with the body as an ArrayBuffer. See {@link readRequestText} for + * caching, concurrency, and availability semantics. + * + * @returns A Promise that resolves with the request body + * as an ArrayBuffer. + * @since 0.9.9 + */ + readRequestArrayBuffer(): Promise; + /** + * Reads the client request body and returns a Promise resolving + * with the body parsed as JSON. See {@link readRequestText} for + * caching, concurrency, and availability semantics. + * + * @returns A Promise that resolves with the parsed JSON value. + * @since 0.9.9 + */ + readRequestJSON(): Promise; /** * Subrequest response body. The size of response body is limited by * the subrequest_output_buffer_size directive. From 0ab0daf61dd6a2ae9b02840fcebc725c7ed53a68 Mon Sep 17 00:00:00 2001 From: Dmitry Volyntsev Date: Thu, 9 Apr 2026 22:56:01 -0700 Subject: [PATCH 4/4] 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. --- nginx/config | 2 + nginx/ngx_http_js_module.c | 997 ++++++++++++++++++++++++++++++++++++- nginx/ngx_js.h | 1 + nginx/ngx_js_form.c | 860 ++++++++++++++++++++++++++++++++ nginx/ngx_js_form.h | 40 ++ nginx/t/js_request_form.t | 677 +++++++++++++++++++++++++ ts/ngx_http_js_module.d.ts | 53 +- 7 files changed, 2608 insertions(+), 22 deletions(-) create mode 100644 nginx/ngx_js_form.c create mode 100644 nginx/ngx_js_form.h create mode 100644 nginx/t/js_request_form.t diff --git a/nginx/config b/nginx/config index 5bc93a3ab..7b34163d1 100644 --- a/nginx/config +++ b/nginx/config @@ -6,11 +6,13 @@ NJS_ZLIB=${NJS_ZLIB:-YES} NJS_QUICKJS=${NJS_QUICKJS:-YES} NJS_DEPS="$ngx_addon_dir/ngx_js.h \ + $ngx_addon_dir/ngx_js_form.h \ $ngx_addon_dir/ngx_js_http.h \ $ngx_addon_dir/ngx_js_fetch.h \ $ngx_addon_dir/ngx_js_modules.h \ $ngx_addon_dir/ngx_js_shared_dict.h" NJS_SRCS="$ngx_addon_dir/ngx_js.c \ + $ngx_addon_dir/ngx_js_form.c \ $ngx_addon_dir/ngx_js_http.c \ $ngx_addon_dir/ngx_js_fetch.c \ $ngx_addon_dir/ngx_js_regex.c \ diff --git a/nginx/ngx_http_js_module.c b/nginx/ngx_http_js_module.c index a448ce579..12c6081fd 100644 --- a/nginx/ngx_http_js_module.c +++ b/nginx/ngx_http_js_module.c @@ -11,6 +11,7 @@ #include #include "ngx_js.h" #include "ngx_js_modules.h" +#include "ngx_js_form.h" typedef struct { @@ -108,7 +109,18 @@ struct ngx_http_js_ctx_s { #define NGX_HTTP_JS_BODY_READ_IDLE 0 #define NGX_HTTP_JS_BODY_READ_DEFERRED 1 #define NGX_HTTP_JS_BODY_READ_IN_PROGRESS 2 - unsigned body_read_state:2; +#define NGX_HTTP_JS_BODY_READ_FORM 4 +#define ngx_http_js_body_read_phase(state) ((state) & 3) +#define ngx_http_js_body_read_is_form(state) \ + (((state) & NGX_HTTP_JS_BODY_READ_FORM) != 0) +#define ngx_http_js_body_read_is_deferred(state) \ + (ngx_http_js_body_read_phase(state) == NGX_HTTP_JS_BODY_READ_DEFERRED) +#define ngx_http_js_body_read_is_in_progress(state) \ + (ngx_http_js_body_read_phase(state) == NGX_HTTP_JS_BODY_READ_IN_PROGRESS) +#define ngx_http_js_body_read_to_in_progress(state) \ + (((state) & NGX_HTTP_JS_BODY_READ_FORM) \ + | NGX_HTTP_JS_BODY_READ_IN_PROGRESS) + unsigned body_read_state:3; /* * Collected request body as a contiguous buffer. @@ -121,6 +133,8 @@ struct ngx_http_js_ctx_s { /* Pending promise/event for deferred body reads. */ void *body_read_event; + + ngx_js_form_t *request_form; }; @@ -181,6 +195,32 @@ static void ngx_http_js_access_body_finalize(ngx_http_request_t *r, static void ngx_http_js_access_body_done(ngx_http_request_t *r); static ngx_int_t ngx_http_js_body_resolve(ngx_http_js_ctx_t *ctx, void *event); +static ngx_int_t ngx_http_js_request_form(ngx_http_request_t *r, + ngx_http_js_ctx_t *ctx, ngx_uint_t max_keys, ngx_js_form_t **form, + ngx_str_t *error); +static njs_int_t ngx_http_js_form_to_value(njs_vm_t *vm, ngx_http_request_t *r, + ngx_http_js_ctx_t *ctx, ngx_uint_t max_keys, njs_value_t *retval); +static njs_int_t ngx_http_js_request_form_entry_value(njs_vm_t *vm, + ngx_js_form_entry_t *entry, njs_value_t *retval); +static njs_int_t ngx_http_js_request_form_max_keys(njs_vm_t *vm, + njs_value_t *options, ngx_uint_t *max_keys); +static njs_int_t ngx_http_js_request_form_make(njs_vm_t *vm, + ngx_js_form_t *form, njs_value_t *retval); +static njs_int_t ngx_http_js_ext_read_request_form(njs_vm_t *vm, + njs_value_t *args, njs_uint_t nargs, njs_index_t unused, + njs_value_t *retval); +static njs_int_t ngx_http_js_ext_request_form_get(njs_vm_t *vm, + njs_value_t *args, njs_uint_t nargs, njs_index_t as_array, + njs_value_t *retval); +static njs_int_t ngx_http_js_ext_request_form_has(njs_vm_t *vm, + njs_value_t *args, njs_uint_t nargs, njs_index_t unused, + njs_value_t *retval); +static njs_int_t ngx_http_js_ext_request_form_for_each(njs_vm_t *vm, + njs_value_t *args, njs_uint_t nargs, njs_index_t unused, + njs_value_t *retval); +static njs_int_t ngx_http_js_ext_request_form_has_files(njs_vm_t *vm, + njs_value_t *args, njs_uint_t nargs, njs_index_t unused, + njs_value_t *retval); static njs_int_t ngx_http_js_ext_read_request_body(njs_vm_t *vm, njs_value_t *args, njs_uint_t nargs, njs_index_t magic, @@ -190,6 +230,24 @@ static JSValue ngx_http_qjs_ext_read_request_body(JSContext *cx, JSValueConst this_val, int argc, JSValueConst *argv, int magic); static ngx_int_t ngx_http_qjs_body_resolve(ngx_http_js_ctx_t *ctx, void *event); +static JSValue ngx_http_qjs_form_to_value(JSContext *cx, ngx_http_request_t *r, + ngx_http_js_ctx_t *ctx, ngx_uint_t max_keys); +static JSValue ngx_http_qjs_request_form_entry_value(JSContext *cx, + ngx_js_form_entry_t *entry); +static ngx_int_t ngx_http_qjs_request_form_max_keys(JSContext *cx, + JSValueConst options, ngx_uint_t *max_keys); +static JSValue ngx_http_qjs_request_form_make(JSContext *cx, + ngx_js_form_t *form); +static JSValue ngx_http_qjs_ext_read_request_form(JSContext *cx, + JSValueConst this_val, int argc, JSValueConst *argv); +static JSValue ngx_http_qjs_ext_request_form_get(JSContext *cx, + JSValueConst this_val, int argc, JSValueConst *argv, int as_array); +static JSValue ngx_http_qjs_ext_request_form_has(JSContext *cx, + JSValueConst this_val, int argc, JSValueConst *argv); +static JSValue ngx_http_qjs_ext_request_form_for_each(JSContext *cx, + JSValueConst this_val, int argc, JSValueConst *argv); +static JSValue ngx_http_qjs_ext_request_form_has_files(JSContext *cx, + JSValueConst this_val, int argc, JSValueConst *argv); static void ngx_http_js_read_body_event_destructor(ngx_qjs_event_t *event); #endif @@ -763,6 +821,7 @@ static ngx_http_output_body_filter_pt ngx_http_next_body_filter; static njs_int_t ngx_http_js_request_proto_id = 1; static njs_int_t ngx_http_js_periodic_session_proto_id = 2; +static njs_int_t ngx_http_js_request_form_proto_id = 3; static njs_external_t ngx_http_js_ext_request[] = { @@ -1086,6 +1145,17 @@ static njs_external_t ngx_http_js_ext_request[] = { } }, + { + .flags = NJS_EXTERN_METHOD, + .name.string = njs_str("readRequestForm"), + .writable = 1, + .configurable = 1, + .enumerable = 1, + .u.method = { + .native = ngx_http_js_ext_read_request_form, + } + }, + { .flags = NJS_EXTERN_METHOD, .name.string = njs_str("readRequestText"), @@ -1143,6 +1213,74 @@ static njs_external_t ngx_http_js_ext_request[] = { }; +static njs_external_t ngx_http_js_ext_request_form[] = { + + { + .flags = NJS_EXTERN_PROPERTY | NJS_EXTERN_SYMBOL, + .name.symbol = NJS_SYMBOL_TO_STRING_TAG, + .u.property = { + .value = "RequestForm", + } + }, + + { + .flags = NJS_EXTERN_METHOD, + .name.string = njs_str("get"), + .writable = 1, + .configurable = 1, + .enumerable = 1, + .u.method = { + .native = ngx_http_js_ext_request_form_get, + } + }, + + { + .flags = NJS_EXTERN_METHOD, + .name.string = njs_str("getAll"), + .writable = 1, + .configurable = 1, + .enumerable = 1, + .u.method = { + .native = ngx_http_js_ext_request_form_get, + .magic8 = 1, + } + }, + + { + .flags = NJS_EXTERN_METHOD, + .name.string = njs_str("has"), + .writable = 1, + .configurable = 1, + .enumerable = 1, + .u.method = { + .native = ngx_http_js_ext_request_form_has, + } + }, + + { + .flags = NJS_EXTERN_METHOD, + .name.string = njs_str("forEach"), + .writable = 1, + .configurable = 1, + .enumerable = 1, + .u.method = { + .native = ngx_http_js_ext_request_form_for_each, + } + }, + + { + .flags = NJS_EXTERN_METHOD, + .name.string = njs_str("hasFiles"), + .writable = 1, + .configurable = 1, + .enumerable = 1, + .u.method = { + .native = ngx_http_js_ext_request_form_has_files, + } + }, +}; + + static njs_external_t ngx_http_js_ext_periodic_session[] = { { @@ -1272,6 +1410,7 @@ static const JSCFunctionListEntry ngx_http_qjs_ext_request[] = { NGX_JS_BODY_ARRAY_BUFFER), JS_CFUNC_MAGIC_DEF("readRequestJSON", 0, ngx_http_qjs_ext_read_request_body, NGX_JS_BODY_JSON), + JS_CFUNC_DEF("readRequestForm", 1, ngx_http_qjs_ext_read_request_form), JS_CFUNC_MAGIC_DEF("readRequestText", 0, ngx_http_qjs_ext_read_request_body, NGX_JS_BODY_TEXT), JS_CFUNC_DEF("subrequest", 3, ngx_http_qjs_ext_subrequest), @@ -1293,6 +1432,17 @@ static const JSCFunctionListEntry ngx_http_qjs_ext_periodic[] = { }; +static const JSCFunctionListEntry ngx_http_qjs_ext_request_form[] = { + JS_PROP_STRING_DEF("[Symbol.toStringTag]", "RequestForm", + JS_PROP_CONFIGURABLE), + JS_CFUNC_MAGIC_DEF("get", 1, ngx_http_qjs_ext_request_form_get, 0), + JS_CFUNC_MAGIC_DEF("getAll", 1, ngx_http_qjs_ext_request_form_get, 1), + JS_CFUNC_DEF("has", 1, ngx_http_qjs_ext_request_form_has), + JS_CFUNC_DEF("forEach", 2, ngx_http_qjs_ext_request_form_for_each), + JS_CFUNC_DEF("hasFiles", 0, ngx_http_qjs_ext_request_form_has_files), +}; + + static JSClassDef ngx_http_qjs_request_class = { "Request", .finalizer = ngx_http_qjs_request_finalizer, @@ -1305,6 +1455,12 @@ static JSClassDef ngx_http_qjs_periodic_class = { }; +static JSClassDef ngx_http_qjs_request_form_class = { + "RequestForm", + .finalizer = NULL, +}; + + static JSClassDef ngx_http_qjs_variables_class = { "Variables", .finalizer = NULL, @@ -1401,7 +1557,7 @@ ngx_http_js_access_handler(ngx_http_request_t *r) /* JS called readRequest*(). */ - if (ctx->body_read_state == NGX_HTTP_JS_BODY_READ_DEFERRED) { + if (ngx_http_js_body_read_is_deferred(ctx->body_read_state)) { rc = ngx_http_read_client_request_body(r, ngx_http_js_access_body_done); @@ -1423,7 +1579,8 @@ ngx_http_js_access_handler(ngx_http_request_t *r) goto done; } - ctx->body_read_state = NGX_HTTP_JS_BODY_READ_IN_PROGRESS; + ctx->body_read_state = ngx_http_js_body_read_to_in_progress( + ctx->body_read_state); ctx->in_progress = 1; ngx_http_finalize_request(r, NGX_DONE); return NGX_DONE; @@ -3449,11 +3606,89 @@ ngx_http_js_body_to_value(njs_vm_t *vm, ngx_http_js_ctx_t *ctx, } +static ngx_int_t +ngx_http_js_request_form(ngx_http_request_t *r, ngx_http_js_ctx_t *ctx, + ngx_uint_t max_keys, ngx_js_form_t **form, ngx_str_t *error) +{ + ngx_int_t rc; + ngx_str_t content_type; + + if (ctx->request_form != NULL) { + *form = ctx->request_form; + return NGX_OK; + } + + if (r->headers_in.content_type != NULL) { + content_type = r->headers_in.content_type->value; + + } else { + content_type.len = 0; + content_type.data = NULL; + } + + rc = ngx_js_parse_form(r->pool, &content_type, ctx->body_read_data, + ctx->body_read_len, max_keys, form, error); + if (rc != NGX_OK) { + return rc; + } + + ctx->request_form = *form; + + return NGX_OK; +} + + +static njs_int_t +ngx_http_js_request_form_make(njs_vm_t *vm, ngx_js_form_t *form, + njs_value_t *retval) +{ + njs_int_t rc; + + rc = njs_vm_external_create(vm, retval, ngx_http_js_request_form_proto_id, + form, 0); + if (rc != NJS_OK) { + njs_vm_memory_error(vm); + return NJS_ERROR; + } + + return NJS_OK; +} + + +static njs_int_t +ngx_http_js_form_to_value(njs_vm_t *vm, ngx_http_request_t *r, + ngx_http_js_ctx_t *ctx, ngx_uint_t max_keys, njs_value_t *retval) +{ + ngx_int_t rc; + ngx_str_t error; + ngx_js_form_t *form; + + rc = ngx_http_js_request_form(r, ctx, max_keys, &form, &error); + if (rc == NGX_OK) { + return ngx_http_js_request_form_make(vm, form, retval); + } + + if (rc == NGX_JS_FORM_TYPE_ERROR) { + njs_vm_type_error(vm, "%V", &error); + return NJS_ERROR; + } + + if (rc == NGX_JS_FORM_PARSE_ERROR) { + njs_vm_error(vm, "%V", &error); + return NJS_ERROR; + } + + njs_vm_memory_error(vm); + + return NJS_ERROR; +} + + static void ngx_http_js_access_body_finalize(ngx_http_request_t *r, ngx_http_js_ctx_t *ctx, ngx_int_t rc) { - switch (ctx->body_read_state) { + switch (ngx_http_js_body_read_phase(ctx->body_read_state)) { case NGX_HTTP_JS_BODY_READ_IN_PROGRESS: ctx->body_read_state = NGX_HTTP_JS_BODY_READ_IDLE; @@ -3493,8 +3728,18 @@ ngx_http_js_body_resolve(ngx_http_js_ctx_t *ctx, void *event) ev = event; vm = ctx->engine->u.njs.vm; - rc = ngx_http_js_body_to_value(vm, ctx, (uintptr_t) ev->data, - njs_value_arg(&result)); + if (ngx_http_js_body_read_is_form(ctx->body_read_state)) { + rc = ngx_http_js_form_to_value(vm, njs_vm_external(vm, + ngx_http_js_request_proto_id, + njs_value_arg(&ctx->args[0])), ctx, + (uintptr_t) ev->data, + njs_value_arg(&result)); + + } else { + rc = ngx_http_js_body_to_value(vm, ctx, (uintptr_t) ev->data, + njs_value_arg(&result)); + } + if (rc != NJS_OK) { njs_vm_exception_get(vm, njs_value_arg(&result)); @@ -3530,7 +3775,7 @@ ngx_http_js_access_body_done(ngx_http_request_t *r) * For the IN_PROGRESS (async) path, ngx_http_finalize_request(NGX_DONE) * already consumed it; for the DEFERRED (sync) path, we consume it here. */ - if (ctx->body_read_state == NGX_HTTP_JS_BODY_READ_DEFERRED) { + if (ngx_http_js_body_read_is_deferred(ctx->body_read_state)) { r->main->count--; } @@ -3612,19 +3857,348 @@ ngx_http_js_ext_read_request_body(njs_vm_t *vm, njs_value_t *args, ngx_js_add_event(ctx, event); - ctx->body_read_event = event; - ctx->body_read_state = NGX_HTTP_JS_BODY_READ_DEFERRED; + ctx->body_read_event = event; + ctx->body_read_state = NGX_HTTP_JS_BODY_READ_DEFERRED; + + return NJS_OK; + +resolve: + + if (ngx_http_js_collect_body(r, ctx) != NGX_OK) { + njs_vm_memory_error(vm); + return NJS_ERROR; + } + + return ngx_http_js_body_to_value(vm, ctx, (ngx_uint_t) magic, retval); +} + + +static njs_int_t +ngx_http_js_request_form_max_keys(njs_vm_t *vm, njs_value_t *options, + ngx_uint_t *max_keys) +{ + ngx_int_t n; + njs_value_t *value; + njs_opaque_value_t lvalue; + + static const njs_str_t max_keys_name = njs_str("maxKeys"); + + *max_keys = NGX_JS_FORM_DEFAULT_MAX_KEYS; + + if (njs_value_is_undefined(options)) { + return NJS_OK; + } + + if (!njs_value_is_object(options)) { + njs_vm_type_error(vm, "\"options\" must be an object"); + return NJS_ERROR; + } + + value = njs_vm_object_prop(vm, options, &max_keys_name, &lvalue); + if (value == NULL || njs_value_is_undefined(value)) { + return NJS_OK; + } + + if (ngx_js_integer(vm, value, &n) != NGX_OK) { + return NJS_ERROR; + } + + if (n < 1) { + njs_vm_type_error(vm, "\"maxKeys\" must be a positive integer"); + return NJS_ERROR; + } + + *max_keys = n; + + return NJS_OK; +} + + +static njs_int_t +ngx_http_js_ext_read_request_form(njs_vm_t *vm, njs_value_t *args, + njs_uint_t nargs, njs_index_t unused, njs_value_t *retval) +{ + ngx_int_t rc; + ngx_uint_t max_keys; + ngx_js_event_t *event; + ngx_http_js_ctx_t *ctx; + ngx_http_request_t *r; + + r = njs_vm_external(vm, ngx_http_js_request_proto_id, + njs_argument(args, 0)); + if (r == NULL) { + njs_vm_error(vm, "\"this\" is not an external"); + return NJS_ERROR; + } + + if (ngx_http_js_request_form_max_keys(vm, njs_arg(args, nargs, 1), + &max_keys) + != NJS_OK) + { + return NJS_ERROR; + } + + ctx = ngx_http_get_module_ctx(r, ngx_http_js_module); + + if (r->request_body) { + goto resolve; + } + + if (ctx->body_read_event) { + njs_vm_error(vm, "request body is already being read"); + return NJS_ERROR; + } + + event = njs_mp_zalloc(njs_vm_memory_pool(vm), + sizeof(ngx_js_event_t) + + sizeof(njs_opaque_value_t) * 2); + if (event == NULL) { + njs_vm_memory_error(vm); + return NJS_ERROR; + } + + event->fd = ctx->event_id++; + event->args = (njs_opaque_value_t *) &event[1]; + event->data = (void *) (uintptr_t) max_keys; + + rc = njs_vm_promise_create(vm, retval, njs_value_arg(event->args)); + if (rc != NJS_OK) { + return NJS_ERROR; + } + + njs_value_assign(&event->function, njs_value_arg(event->args)); + + ngx_js_add_event(ctx, event); + + ctx->body_read_event = event; + ctx->body_read_state = NGX_HTTP_JS_BODY_READ_DEFERRED + | NGX_HTTP_JS_BODY_READ_FORM; + + return NJS_OK; + +resolve: + + if (ngx_http_js_collect_body(r, ctx) != NGX_OK) { + njs_vm_memory_error(vm); + return NJS_ERROR; + } + + return ngx_http_js_form_to_value(vm, r, ctx, max_keys, retval); +} + + +static njs_int_t +ngx_http_js_ext_request_form_get(njs_vm_t *vm, njs_value_t *args, + njs_uint_t nargs, njs_index_t as_array, njs_value_t *retval) +{ + njs_int_t rc; + njs_str_t name; + njs_value_t *value; + ngx_uint_t i; + ngx_js_form_t *form; + ngx_js_form_entry_t *entry; + + form = njs_vm_external(vm, ngx_http_js_request_form_proto_id, + njs_argument(args, 0)); + if (form == NULL) { + njs_vm_error(vm, "\"this\" is not a RequestForm"); + return NJS_ERROR; + } + + rc = ngx_js_string(vm, njs_arg(args, nargs, 1), &name); + if (rc != NJS_OK) { + njs_vm_type_error(vm, "\"name\" must be a string"); + return NJS_ERROR; + } + + if (as_array) { + rc = njs_vm_array_alloc(vm, retval, 4); + if (rc != NJS_OK) { + return NJS_ERROR; + } + } + + entry = form->entries.elts; + + for (i = 0; i < form->entries.nelts; i++) { + if (entry[i].name.len != name.length + || ngx_memcmp(entry[i].name.data, name.start, name.length) != 0) + { + continue; + } + + if (!as_array) { + return ngx_http_js_request_form_entry_value(vm, &entry[i], retval); + } + + value = njs_vm_array_push(vm, retval); + if (value == NULL) { + return NJS_ERROR; + } + + rc = ngx_http_js_request_form_entry_value(vm, &entry[i], value); + if (rc != NJS_OK) { + return NJS_ERROR; + } + } + + if (!as_array) { + njs_value_null_set(retval); + } + + return NJS_OK; +} + + +static njs_int_t +ngx_http_js_ext_request_form_has(njs_vm_t *vm, njs_value_t *args, + njs_uint_t nargs, njs_index_t unused, njs_value_t *retval) +{ + njs_int_t rc; + njs_str_t name; + ngx_uint_t i; + ngx_js_form_t *form; + ngx_js_form_entry_t *entry; + + form = njs_vm_external(vm, ngx_http_js_request_form_proto_id, + njs_argument(args, 0)); + if (form == NULL) { + njs_vm_error(vm, "\"this\" is not a RequestForm"); + return NJS_ERROR; + } + + rc = ngx_js_string(vm, njs_arg(args, nargs, 1), &name); + if (rc != NJS_OK) { + njs_vm_type_error(vm, "\"name\" must be a string"); + return NJS_ERROR; + } + + entry = form->entries.elts; + + for (i = 0; i < form->entries.nelts; i++) { + if (entry[i].name.len == name.length + && ngx_memcmp(entry[i].name.data, name.start, name.length) == 0) + { + njs_value_boolean_set(retval, 1); + return NJS_OK; + } + } + + njs_value_boolean_set(retval, 0); + + return NJS_OK; +} + + +static njs_int_t +ngx_http_js_ext_request_form_for_each(njs_vm_t *vm, njs_value_t *args, + njs_uint_t nargs, njs_index_t unused, njs_value_t *retval) +{ + njs_int_t rc; + njs_value_t *callback, *this_arg, *this; + ngx_uint_t i; + ngx_js_form_t *form; + ngx_js_form_entry_t *entry; + njs_opaque_value_t arguments[4], result; + + this = njs_argument(args, 0); + + form = njs_vm_external(vm, ngx_http_js_request_form_proto_id, this); + if (form == NULL) { + njs_vm_error(vm, "\"this\" is not a RequestForm"); + return NJS_ERROR; + } + + callback = njs_arg(args, nargs, 1); + if (!njs_value_is_function(callback)) { + njs_vm_error(vm, "\"callback\" is not a function"); + return NJS_ERROR; + } + + this_arg = njs_arg(args, nargs, 2); + if (this_arg == NULL) { + this_arg = njs_value_arg(&njs_value_undefined); + } + + entry = form->entries.elts; + + for (i = 0; i < form->entries.nelts; i++) { + njs_value_assign(&arguments[0], this_arg); + + rc = ngx_http_js_request_form_entry_value(vm, &entry[i], + njs_value_arg(&arguments[1])); + if (rc != NJS_OK) { + return NJS_ERROR; + } + + rc = njs_vm_value_string_create(vm, njs_value_arg(&arguments[2]), + entry[i].name.data, + entry[i].name.len); + if (rc != NJS_OK) { + return NJS_ERROR; + } + + njs_value_assign(&arguments[3], this); + + rc = njs_vm_invoke(vm, njs_value_function(callback), + njs_value_arg(&arguments[1]), 3, + njs_value_arg(&result)); + if (rc != NJS_OK) { + return NJS_ERROR; + } + } + + njs_value_undefined_set(retval); + + return NJS_OK; +} + + +static njs_int_t +ngx_http_js_request_form_entry_value(njs_vm_t *vm, ngx_js_form_entry_t *entry, + njs_value_t *retval) +{ + njs_int_t rc; + njs_opaque_value_t value; + + static const njs_str_t name_key = njs_str("name"); + + if (!entry->is_file) { + return njs_vm_value_string_create(vm, retval, entry->value.data, + entry->value.len); + } + + rc = njs_vm_object_alloc(vm, retval, NULL); + if (rc != NJS_OK) { + return NJS_ERROR; + } + + rc = njs_vm_value_string_create(vm, njs_value_arg(&value), + entry->filename.data, entry->filename.len); + if (rc != NJS_OK) { + return NJS_ERROR; + } + + return njs_vm_object_prop_set(vm, retval, &name_key, &value); +} - return NJS_OK; -resolve: +static njs_int_t +ngx_http_js_ext_request_form_has_files(njs_vm_t *vm, njs_value_t *args, + njs_uint_t nargs, njs_index_t unused, njs_value_t *retval) +{ + ngx_js_form_t *form; - if (ngx_http_js_collect_body(r, ctx) != NGX_OK) { - njs_vm_memory_error(vm); + form = njs_vm_external(vm, ngx_http_js_request_form_proto_id, + njs_argument(args, 0)); + if (form == NULL) { + njs_vm_error(vm, "\"this\" is not a RequestForm"); return NJS_ERROR; } - return ngx_http_js_body_to_value(vm, ctx, (ngx_uint_t) magic, retval); + njs_value_boolean_set(retval, form->has_files); + + return NJS_OK; } @@ -5289,6 +5863,13 @@ ngx_js_http_init(njs_vm_t *vm) return NJS_ERROR; } + ngx_http_js_request_form_proto_id = njs_vm_external_prototype(vm, + ngx_http_js_ext_request_form, + njs_nitems(ngx_http_js_ext_request_form)); + if (ngx_http_js_request_form_proto_id < 0) { + return NJS_ERROR; + } + ngx_http_js_periodic_session_proto_id = njs_vm_external_prototype(vm, ngx_http_js_ext_periodic_session, njs_nitems(ngx_http_js_ext_periodic_session)); @@ -5979,7 +6560,15 @@ ngx_http_qjs_body_resolve(ngx_http_js_ctx_t *ctx, void *event) ev = event; cx = ctx->engine->u.qjs.ctx; - result = ngx_http_qjs_body_to_value(cx, ctx, (uintptr_t) ev->data); + if (ngx_http_js_body_read_is_form(ctx->body_read_state)) { + result = ngx_http_qjs_form_to_value(cx, + ngx_http_qjs_request(ngx_qjs_arg(ctx->args[0])), + ctx, (uintptr_t) ev->data); + + } else { + result = ngx_http_qjs_body_to_value(cx, ctx, (uintptr_t) ev->data); + } + if (JS_IsException(result)) { result = JS_GetException(cx); @@ -6060,6 +6649,368 @@ ngx_http_qjs_ext_read_request_body(JSContext *cx, JSValueConst this_val, } +static ngx_int_t +ngx_http_qjs_request_form_max_keys(JSContext *cx, JSValueConst options, + ngx_uint_t *max_keys) +{ + JSValue value; + ngx_int_t n; + + *max_keys = NGX_JS_FORM_DEFAULT_MAX_KEYS; + + if (JS_IsUndefined(options)) { + return NGX_OK; + } + + if (!JS_IsObject(options)) { + JS_ThrowTypeError(cx, "\"options\" must be an object"); + return NGX_ERROR; + } + + value = JS_GetPropertyStr(cx, options, "maxKeys"); + if (JS_IsException(value)) { + return NGX_ERROR; + } + + if (JS_IsUndefined(value)) { + JS_FreeValue(cx, value); + return NGX_OK; + } + + if (ngx_qjs_integer(cx, value, &n) != NGX_OK) { + JS_FreeValue(cx, value); + return NGX_ERROR; + } + + JS_FreeValue(cx, value); + + if (n < 1) { + JS_ThrowTypeError(cx, "\"maxKeys\" must be a positive integer"); + return NGX_ERROR; + } + + *max_keys = n; + + return NGX_OK; +} + + +static JSValue +ngx_http_qjs_request_form_make(JSContext *cx, ngx_js_form_t *form) +{ + JSValue obj; + + obj = JS_NewObjectClass(cx, NGX_QJS_CLASS_ID_HTTP_FORM); + if (JS_IsException(obj)) { + return JS_EXCEPTION; + } + + JS_SetOpaque(obj, form); + + return obj; +} + + +static JSValue +ngx_http_qjs_form_to_value(JSContext *cx, ngx_http_request_t *r, + ngx_http_js_ctx_t *ctx, ngx_uint_t max_keys) +{ + ngx_int_t rc; + ngx_str_t error; + ngx_js_form_t *form; + + rc = ngx_http_js_request_form(r, ctx, max_keys, &form, &error); + if (rc == NGX_OK) { + return ngx_http_qjs_request_form_make(cx, form); + } + + if (rc == NGX_JS_FORM_TYPE_ERROR) { + return JS_ThrowTypeError(cx, "%.*s", (int) error.len, error.data); + } + + if (rc == NGX_JS_FORM_PARSE_ERROR) { + return JS_ThrowInternalError(cx, "%.*s", (int) error.len, error.data); + } + + return JS_ThrowOutOfMemory(cx); +} + + +static JSValue +ngx_http_qjs_ext_read_request_form(JSContext *cx, JSValueConst this_val, + int argc, JSValueConst *argv) +{ + JSValue retval; + ngx_uint_t max_keys; + ngx_qjs_event_t *event; + ngx_http_js_ctx_t *ctx; + ngx_http_request_t *r; + ngx_http_qjs_request_t *req; + + req = JS_GetOpaque(this_val, NGX_QJS_CLASS_ID_HTTP_REQUEST); + if (req == NULL) { + return JS_ThrowInternalError(cx, "\"this\" is not a request object"); + } + + if (ngx_http_qjs_request_form_max_keys(cx, argv[0], &max_keys) != NGX_OK) { + return JS_EXCEPTION; + } + + r = req->request; + ctx = ngx_http_get_module_ctx(r, ngx_http_js_module); + + if (r->request_body) { + goto resolve; + } + + if (ctx->body_read_event) { + return JS_ThrowInternalError(cx, "request body is already being read"); + } + + event = ngx_pcalloc(r->pool, sizeof(ngx_qjs_event_t) + sizeof(JSValue) * 2); + if (event == NULL) { + return JS_ThrowOutOfMemory(cx); + } + + event->ctx = cx; + event->fd = ctx->event_id++; + event->args = (JSValue *) &event[1]; + event->data = (void *) (uintptr_t) max_keys; + + retval = JS_NewPromiseCapability(cx, &event->args[0]); + if (JS_IsException(retval)) { + return JS_EXCEPTION; + } + + event->function = JS_DupValue(cx, event->args[0]); + event->destructor = ngx_http_js_read_body_event_destructor; + + ngx_js_add_event(ctx, event); + + ctx->body_read_event = event; + ctx->body_read_state = NGX_HTTP_JS_BODY_READ_DEFERRED + | NGX_HTTP_JS_BODY_READ_FORM; + + return retval; + +resolve: + + if (ngx_http_js_collect_body(r, ctx) != NGX_OK) { + return JS_ThrowOutOfMemory(cx); + } + + return ngx_http_qjs_form_to_value(cx, r, ctx, max_keys); +} + + +static JSValue +ngx_http_qjs_ext_request_form_get(JSContext *cx, JSValueConst this_val, + int argc, JSValueConst *argv, int as_array) +{ + JSValue array, value; + size_t name_len; + const char *name; + ngx_uint_t i, n; + ngx_js_form_t *form; + ngx_js_form_entry_t *entry; + + form = JS_GetOpaque(this_val, NGX_QJS_CLASS_ID_HTTP_FORM); + if (form == NULL) { + return JS_ThrowInternalError(cx, "\"this\" is not a RequestForm"); + } + + name = JS_ToCStringLen(cx, &name_len, argv[0]); + if (name == NULL) { + return JS_ThrowTypeError(cx, "\"name\" must be a string"); + } + + if (as_array) { + array = JS_NewArray(cx); + if (JS_IsException(array)) { + JS_FreeCString(cx, name); + return JS_EXCEPTION; + } + + } else { + array = JS_UNDEFINED; + } + + entry = form->entries.elts; + n = 0; + + for (i = 0; i < form->entries.nelts; i++) { + if (entry[i].name.len != name_len + || ngx_memcmp(entry[i].name.data, name, name_len) != 0) + { + continue; + } + + value = ngx_http_qjs_request_form_entry_value(cx, &entry[i]); + if (JS_IsException(value)) { + JS_FreeCString(cx, name); + JS_FreeValue(cx, array); + return JS_EXCEPTION; + } + + if (!as_array) { + JS_FreeCString(cx, name); + return value; + } + + if (JS_DefinePropertyValueUint32(cx, array, n++, value, JS_PROP_C_W_E) + < 0) + { + JS_FreeValue(cx, value); + JS_FreeCString(cx, name); + JS_FreeValue(cx, array); + return JS_EXCEPTION; + } + } + + JS_FreeCString(cx, name); + + if (as_array) { + return array; + } + + return JS_NULL; +} + + +static JSValue +ngx_http_qjs_ext_request_form_has(JSContext *cx, JSValueConst this_val, + int argc, JSValueConst *argv) +{ + size_t name_len; + const char *name; + ngx_uint_t i; + ngx_js_form_t *form; + ngx_js_form_entry_t *entry; + + form = JS_GetOpaque(this_val, NGX_QJS_CLASS_ID_HTTP_FORM); + if (form == NULL) { + return JS_ThrowInternalError(cx, "\"this\" is not a RequestForm"); + } + + name = JS_ToCStringLen(cx, &name_len, argv[0]); + if (name == NULL) { + return JS_ThrowTypeError(cx, "\"name\" must be a string"); + } + + entry = form->entries.elts; + + for (i = 0; i < form->entries.nelts; i++) { + if (entry[i].name.len == name_len + && ngx_memcmp(entry[i].name.data, name, name_len) == 0) + { + JS_FreeCString(cx, name); + return JS_NewBool(cx, 1); + } + } + + JS_FreeCString(cx, name); + + return JS_NewBool(cx, 0); +} + + +static JSValue +ngx_http_qjs_ext_request_form_for_each(JSContext *cx, JSValueConst this_val, + int argc, JSValueConst *argv) +{ + JSValue args[3], ret; + ngx_uint_t i; + ngx_js_form_t *form; + ngx_js_form_entry_t *entry; + + form = JS_GetOpaque(this_val, NGX_QJS_CLASS_ID_HTTP_FORM); + if (form == NULL) { + return JS_ThrowInternalError(cx, "\"this\" is not a RequestForm"); + } + + if (!JS_IsFunction(cx, argv[0])) { + return JS_ThrowTypeError(cx, "\"callback\" is not a function"); + } + + entry = form->entries.elts; + + for (i = 0; i < form->entries.nelts; i++) { + args[0] = ngx_http_qjs_request_form_entry_value(cx, &entry[i]); + if (JS_IsException(args[0])) { + return JS_EXCEPTION; + } + + args[1] = qjs_string_create(cx, entry[i].name.data, entry[i].name.len); + if (JS_IsException(args[1])) { + JS_FreeValue(cx, args[0]); + return JS_EXCEPTION; + } + + args[2] = JS_DupValue(cx, this_val); + + ret = JS_Call(cx, argv[0], argc > 1 ? argv[1] : JS_UNDEFINED, 3, args); + + JS_FreeValue(cx, args[0]); + JS_FreeValue(cx, args[1]); + JS_FreeValue(cx, args[2]); + + if (JS_IsException(ret)) { + return JS_EXCEPTION; + } + + JS_FreeValue(cx, ret); + } + + return JS_UNDEFINED; +} + + +static JSValue +ngx_http_qjs_request_form_entry_value(JSContext *cx, + ngx_js_form_entry_t *entry) +{ + JSValue object, value; + + if (!entry->is_file) { + return qjs_string_create(cx, entry->value.data, entry->value.len); + } + + object = JS_NewObject(cx); + if (JS_IsException(object)) { + return JS_EXCEPTION; + } + + value = qjs_string_create(cx, entry->filename.data, entry->filename.len); + if (JS_IsException(value)) { + JS_FreeValue(cx, object); + return JS_EXCEPTION; + } + + if (JS_SetPropertyStr(cx, object, "name", value) < 0) { + JS_FreeValue(cx, value); + JS_FreeValue(cx, object); + return JS_EXCEPTION; + } + + return object; +} + + +static JSValue +ngx_http_qjs_ext_request_form_has_files(JSContext *cx, JSValueConst this_val, + int argc, JSValueConst *argv) +{ + ngx_js_form_t *form; + + form = JS_GetOpaque(this_val, NGX_QJS_CLASS_ID_HTTP_FORM); + if (form == NULL) { + return JS_ThrowInternalError(cx, "\"this\" is not a RequestForm"); + } + + return JS_NewBool(cx, form->has_files); +} + + static void ngx_http_js_read_body_event_destructor(ngx_qjs_event_t *event) { @@ -8321,6 +9272,22 @@ ngx_engine_qjs_clone(ngx_js_ctx_t *ctx, ngx_js_loc_conf_t *cf, JS_SetClassProto(cx, NGX_QJS_CLASS_ID_HTTP_REQUEST, proto); + if (JS_NewClass(JS_GetRuntime(cx), NGX_QJS_CLASS_ID_HTTP_FORM, + &ngx_http_qjs_request_form_class) < 0) + { + return NULL; + } + + proto = JS_NewObject(cx); + if (JS_IsException(proto)) { + return NULL; + } + + JS_SetPropertyFunctionList(cx, proto, ngx_http_qjs_ext_request_form, + njs_nitems(ngx_http_qjs_ext_request_form)); + + JS_SetClassProto(cx, NGX_QJS_CLASS_ID_HTTP_FORM, proto); + if (JS_NewClass(JS_GetRuntime(cx), NGX_QJS_CLASS_ID_HTTP_PERIODIC, &ngx_http_qjs_periodic_class) < 0) { diff --git a/nginx/ngx_js.h b/nginx/ngx_js.h index 63cbf00a6..c6cc8a3e7 100644 --- a/nginx/ngx_js.h +++ b/nginx/ngx_js.h @@ -56,6 +56,7 @@ enum { NGX_QJS_CLASS_ID_CONSOLE = QJS_CORE_CLASS_ID_LAST, NGX_QJS_CLASS_ID_HTTP_REQUEST, + NGX_QJS_CLASS_ID_HTTP_FORM, NGX_QJS_CLASS_ID_HTTP_PERIODIC, NGX_QJS_CLASS_ID_HTTP_VARS, NGX_QJS_CLASS_ID_HTTP_HEADERS_IN, diff --git a/nginx/ngx_js_form.c b/nginx/ngx_js_form.c new file mode 100644 index 000000000..a44ed2752 --- /dev/null +++ b/nginx/ngx_js_form.c @@ -0,0 +1,860 @@ +/* + * Copyright (C) Dmitry Volyntsev + * Copyright (C) F5, Inc. + */ + + +#include +#include +#include "ngx_js_form.h" + + +#define NGX_JS_FORM_URLENCODED 1 +#define NGX_JS_FORM_MULTIPART 2 + +/* + * RFC 2046, section 5.1.1 limits boundary to 70 characters; we allow up + * to 200 to tolerate non-conforming clients while bounding allocation. + */ +#define NGX_JS_FORM_MAX_BOUNDARY_LEN 200 +#define NGX_JS_FORM_MAX_PART_HEADERS 32 +#define NGX_JS_FORM_MAX_PART_HEADER_LINE 4096 +#define NGX_JS_FORM_MAX_PART_HEADER_SIZE 16384 + + +typedef struct { + ngx_str_t boundary; + ngx_uint_t type; +} ngx_js_form_content_type_t; + + +static ngx_int_t ngx_js_form_parse_content_type(ngx_pool_t *pool, + ngx_str_t *content_type, ngx_js_form_content_type_t *ct, + ngx_str_t *error); +static ngx_int_t ngx_js_form_parse_urlencoded(ngx_pool_t *pool, u_char *body, + size_t len, ngx_uint_t max_keys, ngx_js_form_t *form, ngx_str_t *error); +static ngx_int_t ngx_js_form_parse_multipart(ngx_pool_t *pool, u_char *body, + size_t len, ngx_str_t *boundary, ngx_uint_t max_keys, ngx_js_form_t *form, + ngx_str_t *error); +static ngx_int_t ngx_js_form_add_entry(ngx_js_form_t *form, + ngx_pool_t *pool, ngx_str_t *name, ngx_str_t *value, ngx_uint_t *count, + ngx_uint_t max_keys, ngx_str_t *filename, ngx_flag_t is_file, + ngx_str_t *error); +static ngx_int_t ngx_js_form_decode_urlencoded(ngx_pool_t *pool, u_char *start, + u_char *end, ngx_str_t *dst, ngx_str_t *error); +static ngx_int_t ngx_js_form_copy(ngx_pool_t *pool, u_char *start, + u_char *end, ngx_str_t *dst); +static ngx_int_t ngx_js_form_copy_quoted(ngx_pool_t *pool, u_char *start, + u_char *end, ngx_str_t *dst); +static void ngx_js_form_error(ngx_str_t *error, const char *text); +static u_char *ngx_js_form_skip_ows(u_char *p, u_char *end); +static u_char *ngx_js_form_find(u_char *start, u_char *end, u_char *pattern, + size_t len); +static ngx_uint_t ngx_js_form_is_ows(u_char ch); +static ngx_int_t ngx_js_form_parse_part_headers(ngx_pool_t *pool, + u_char *start, u_char *end, ngx_str_t *name, ngx_flag_t *is_file, + ngx_str_t *filename, ngx_str_t *error); +static ngx_int_t ngx_js_form_parse_disposition(ngx_pool_t *pool, + ngx_str_t *value, ngx_str_t *name, ngx_flag_t *is_file, + ngx_str_t *filename, ngx_str_t *error); +static ngx_int_t ngx_js_form_parse_param(ngx_pool_t *pool, u_char **pp, + u_char *end, ngx_str_t *param, ngx_str_t *value, ngx_flag_t *quoted, + ngx_str_t *error); + + +ngx_int_t +ngx_js_parse_form(ngx_pool_t *pool, ngx_str_t *content_type, u_char *body, + size_t len, ngx_uint_t max_keys, ngx_js_form_t **form, + ngx_str_t *error) +{ + ngx_int_t rc; + ngx_js_form_t *f; + ngx_js_form_content_type_t ct; + + rc = ngx_js_form_parse_content_type(pool, content_type, &ct, error); + if (rc != NGX_JS_FORM_OK) { + return rc; + } + + f = ngx_pcalloc(pool, sizeof(ngx_js_form_t)); + if (f == NULL) { + return NGX_ERROR; + } + + if (ngx_array_init(&f->entries, pool, 4, sizeof(ngx_js_form_entry_t)) + != NGX_OK) + { + return NGX_ERROR; + } + + switch (ct.type) { + case NGX_JS_FORM_URLENCODED: + rc = ngx_js_form_parse_urlencoded(pool, body, len, max_keys, f, error); + break; + + case NGX_JS_FORM_MULTIPART: + rc = ngx_js_form_parse_multipart(pool, body, len, &ct.boundary, + max_keys, f, error); + break; + + default: + ngx_js_form_error(error, "unsupported content type"); + return NGX_JS_FORM_TYPE_ERROR; + } + + if (rc != NGX_JS_FORM_OK) { + return rc; + } + + *form = f; + + return NGX_JS_FORM_OK; +} + + +static ngx_int_t +ngx_js_form_parse_content_type(ngx_pool_t *pool, ngx_str_t *content_type, + ngx_js_form_content_type_t *ct, ngx_str_t *error) +{ + u_char *p, *end, *last, *value_start; + ngx_int_t rc; + ngx_str_t param, value; + ngx_flag_t quoted; + + if (content_type == NULL || content_type->len == 0) { + ngx_js_form_error(error, "request content type is required"); + return NGX_JS_FORM_TYPE_ERROR; + } + + ct->type = 0; + ct->boundary.len = 0; + ct->boundary.data = NULL; + + p = content_type->data; + end = p + content_type->len; + + last = p; + + while (last < end && *last != ';') { + last++; + } + + value_start = ngx_js_form_skip_ows(p, last); + p = last; + + while (last > value_start && ngx_js_form_is_ows(last[-1])) { + last--; + } + + if ((size_t) (last - value_start) + == sizeof("application/x-www-form-urlencoded") - 1 + && ngx_strncasecmp(value_start, + (u_char *) "application/x-www-form-urlencoded", + last - value_start) + == 0) + { + ct->type = NGX_JS_FORM_URLENCODED; + } + + if ((size_t) (last - value_start) == sizeof("multipart/form-data") - 1 + && ngx_strncasecmp(value_start, (u_char *) "multipart/form-data", + last - value_start) + == 0) + { + ct->type = NGX_JS_FORM_MULTIPART; + } + + if (ct->type == 0) { + ngx_js_form_error(error, "unsupported content type"); + return NGX_JS_FORM_TYPE_ERROR; + } + + while (p < end) { + if (*p++ != ';') { + ngx_js_form_error(error, "malformed content type"); + return NGX_JS_FORM_PARSE_ERROR; + } + + p = ngx_js_form_skip_ows(p, end); + + rc = ngx_js_form_parse_param(pool, &p, end, ¶m, &value, "ed, + error); + if (rc != NGX_JS_FORM_OK) { + return rc; + } + + if (param.len == sizeof("boundary") - 1 + && ngx_strncasecmp(param.data, (u_char *) "boundary", param.len) + == 0) + { + if (ct->boundary.data != NULL) { + ngx_js_form_error(error, "duplicate boundary parameter"); + return NGX_JS_FORM_PARSE_ERROR; + } + + if (value.len == 0 || value.len > NGX_JS_FORM_MAX_BOUNDARY_LEN) { + ngx_js_form_error(error, "invalid multipart boundary"); + return NGX_JS_FORM_PARSE_ERROR; + } + + ct->boundary = value; + } + + p = ngx_js_form_skip_ows(p, end); + + if (p == end) { + break; + } + } + + if (ct->type == NGX_JS_FORM_MULTIPART && ct->boundary.data == NULL) { + ngx_js_form_error(error, "multipart boundary is required"); + return NGX_JS_FORM_TYPE_ERROR; + } + + return NGX_JS_FORM_OK; +} + + +static ngx_int_t +ngx_js_form_parse_urlencoded(ngx_pool_t *pool, u_char *body, size_t len, + ngx_uint_t max_keys, ngx_js_form_t *form, ngx_str_t *error) +{ + u_char *p, *end, *amp, *eq; + ngx_int_t rc; + ngx_str_t name, value; + ngx_uint_t count; + + count = 0; + p = body; + end = body + len; + + if (len == 0) { + return NGX_JS_FORM_OK; + } + + while (p < end) { + if (*p == '&') { + p++; + continue; + } + + amp = p; + + while (amp < end && *amp != '&') { + amp++; + } + + eq = p; + + while (eq < amp && *eq != '=') { + eq++; + } + + rc = ngx_js_form_decode_urlencoded(pool, p, eq, &name, error); + if (rc != NGX_JS_FORM_OK) { + return rc; + } + + if (eq < amp) { + eq++; + } + + rc = ngx_js_form_decode_urlencoded(pool, eq, amp, &value, error); + if (rc != NGX_JS_FORM_OK) { + return rc; + } + + rc = ngx_js_form_add_entry(form, pool, &name, &value, &count, max_keys, + NULL, 0, error); + if (rc != NGX_JS_FORM_OK) { + return rc; + } + + if (amp == end) { + break; + } + + p = amp + 1; + } + + return NGX_JS_FORM_OK; +} + + +static ngx_int_t +ngx_js_form_parse_multipart(ngx_pool_t *pool, u_char *body, size_t len, + ngx_str_t *boundary, ngx_uint_t max_keys, ngx_js_form_t *form, + ngx_str_t *error) +{ + size_t dlen, cdlen; + u_char *p, *end, *marker, *next, *headers_end, *part_end, *scan; + u_char *delimiter; + ngx_int_t rc; + ngx_str_t name, value, filename; + ngx_uint_t count; + ngx_flag_t is_file; + + count = 0; + end = body + len; + dlen = boundary->len + 2; + cdlen = boundary->len + 4; + + delimiter = ngx_pnalloc(pool, cdlen); + if (delimiter == NULL) { + return NGX_ERROR; + } + + delimiter[0] = '-'; + delimiter[1] = '-'; + ngx_memcpy(delimiter + 2, boundary->data, boundary->len); + delimiter[dlen] = '-'; + delimiter[dlen + 1] = '-'; + + /* + * Validate the body opening: a dash-boundary "--BOUNDARY" must + * appear, and the close-delimiter "--BOUNDARY--" must not appear + * before it. The dash-boundary is a prefix of the close-delimiter, + * so both searches use the same buffer with different lengths + * (dlen = "--BOUNDARY", cdlen = "--BOUNDARY--"). + */ + p = ngx_js_form_find(body, end, delimiter, cdlen); + marker = ngx_js_form_find(body, end, delimiter, dlen); + + if (marker == NULL || (p != NULL && p < marker)) { + ngx_js_form_error(error, "malformed multipart body"); + return NGX_JS_FORM_PARSE_ERROR; + } + + p = marker + dlen; + + if (p + 2 <= end && p[0] == '-' && p[1] == '-') { + return NGX_JS_FORM_OK; + } + + if (p + 2 > end || p[0] != '\r' || p[1] != '\n') { + ngx_js_form_error(error, "malformed multipart boundary"); + return NGX_JS_FORM_PARSE_ERROR; + } + + p += 2; + + for ( ;; ) { + headers_end = ngx_js_form_find(p, end, (u_char *) "\r\n\r\n", 4); + if (headers_end == NULL) { + ngx_js_form_error(error, "missing multipart header separator"); + return NGX_JS_FORM_PARSE_ERROR; + } + + if ((size_t) (headers_end - p) > NGX_JS_FORM_MAX_PART_HEADER_SIZE) { + ngx_js_form_error(error, "multipart headers are too large"); + return NGX_JS_FORM_PARSE_ERROR; + } + + rc = ngx_js_form_parse_part_headers(pool, p, headers_end, &name, + &is_file, &filename, error); + if (rc != NGX_JS_FORM_OK) { + return rc; + } + + p = headers_end + sizeof("\r\n\r\n") - 1; + scan = p; + + for ( ;; ) { + next = ngx_js_form_find(scan, end, (u_char *) "\r\n--", 4); + if (next == NULL) { + ngx_js_form_error(error, "truncated multipart body"); + return NGX_JS_FORM_PARSE_ERROR; + } + + if (next + (sizeof("\r\n--") - 1) + boundary->len <= end + && ngx_memcmp(next + (sizeof("\r\n--") - 1), boundary->data, + boundary->len) == 0) + { + break; + } + + scan = next + sizeof("\r\n--") - 1; + } + + part_end = next; + + if (is_file) { + value.len = 0; + value.data = (u_char *) ""; + form->has_files = 1; + + } else { + if (ngx_js_form_copy(pool, p, part_end, &value) != NGX_OK) { + return NGX_ERROR; + } + } + + rc = ngx_js_form_add_entry(form, pool, &name, &value, &count, max_keys, + &filename, is_file, error); + if (rc != NGX_JS_FORM_OK) { + return rc; + } + + p = next + (sizeof("\r\n--") - 1) + boundary->len; + + if (p + 2 <= end && p[0] == '-' && p[1] == '-') { + return NGX_JS_FORM_OK; + } + + if (p + 2 > end || p[0] != '\r' || p[1] != '\n') { + ngx_js_form_error(error, "malformed multipart boundary"); + return NGX_JS_FORM_PARSE_ERROR; + } + + p += 2; + } +} + + +static ngx_int_t +ngx_js_form_parse_part_headers(ngx_pool_t *pool, u_char *start, + u_char *end, ngx_str_t *name, ngx_flag_t *is_file, ngx_str_t *filename, + ngx_str_t *error) +{ + u_char *p, *line, *colon, *line_end; + ngx_int_t rc; + ngx_str_t key, value; + ngx_uint_t headers; + ngx_flag_t seen_disposition; + + headers = 0; + seen_disposition = 0; + + name->len = 0; + name->data = NULL; + filename->len = 0; + filename->data = (u_char *) ""; + + *is_file = 0; + + for (p = start; p < end; p = line_end + 2) { + line = p; + line_end = ngx_js_form_find(p, end, (u_char *) "\r\n", 2); + if (line_end == NULL) { + line_end = end; + } + + if ((size_t) (line_end - line) > NGX_JS_FORM_MAX_PART_HEADER_LINE) { + ngx_js_form_error(error, "multipart header line is too long"); + return NGX_JS_FORM_PARSE_ERROR; + } + + if (++headers > NGX_JS_FORM_MAX_PART_HEADERS) { + ngx_js_form_error(error, "too many multipart headers"); + return NGX_JS_FORM_PARSE_ERROR; + } + + colon = line; + + while (colon < line_end && *colon != ':') { + colon++; + } + + if (colon == line_end) { + ngx_js_form_error(error, "malformed multipart header"); + return NGX_JS_FORM_PARSE_ERROR; + } + + key.data = line; + key.len = colon - line; + + colon++; + colon = ngx_js_form_skip_ows(colon, line_end); + + value.data = colon; + value.len = line_end - colon; + + while (value.len > 0 + && ngx_js_form_is_ows(value.data[value.len - 1])) + { + value.len--; + } + + if (key.len == sizeof("Content-Disposition") - 1 + && ngx_strncasecmp(key.data, (u_char *) "Content-Disposition", + key.len) + == 0) + { + if (seen_disposition) { + ngx_js_form_error(error, + "duplicate Content-Disposition header"); + return NGX_JS_FORM_PARSE_ERROR; + } + + rc = ngx_js_form_parse_disposition(pool, &value, name, is_file, + filename, error); + if (rc != NGX_JS_FORM_OK) { + return rc; + } + + seen_disposition = 1; + } + + if (line_end == end) { + break; + } + } + + if (!seen_disposition) { + ngx_js_form_error(error, "missing Content-Disposition header"); + return NGX_JS_FORM_PARSE_ERROR; + } + + return NGX_JS_FORM_OK; +} + + +static ngx_int_t +ngx_js_form_parse_disposition(ngx_pool_t *pool, ngx_str_t *value, + ngx_str_t *name, ngx_flag_t *is_file, ngx_str_t *filename, + ngx_str_t *error) +{ + u_char *p, *end; + ngx_int_t rc; + ngx_str_t param, param_value; + ngx_flag_t quoted, seen_name, seen_file; + + p = value->data; + end = p + value->len; + + while (p < end && *p != ';') { + p++; + } + + if ((size_t) (p - value->data) != sizeof("form-data") - 1 + || ngx_strncasecmp(value->data, (u_char *) "form-data", + p - value->data) + != 0) + { + ngx_js_form_error(error, "unsupported disposition type"); + return NGX_JS_FORM_PARSE_ERROR; + } + + seen_name = 0; + seen_file = 0; + + while (p < end) { + if (*p++ != ';') { + ngx_js_form_error(error, "malformed Content-Disposition"); + return NGX_JS_FORM_PARSE_ERROR; + } + + p = ngx_js_form_skip_ows(p, end); + + rc = ngx_js_form_parse_param(pool, &p, end, ¶m, ¶m_value, + "ed, error); + if (rc != NGX_JS_FORM_OK) { + return rc; + } + + if (param.len == sizeof("name") - 1 + && ngx_strncasecmp(param.data, (u_char *) "name", param.len) == 0) + { + if (seen_name) { + ngx_js_form_error(error, "duplicate name parameter"); + return NGX_JS_FORM_PARSE_ERROR; + } + + *name = param_value; + seen_name = 1; + + } else if (param.len == sizeof("filename") - 1 + && ngx_strncasecmp(param.data, (u_char *) "filename", + param.len) + == 0) + { + if (seen_file) { + ngx_js_form_error(error, "duplicate filename parameter"); + return NGX_JS_FORM_PARSE_ERROR; + } + + *is_file = 1; + *filename = param_value; + seen_file = 1; + } + + p = ngx_js_form_skip_ows(p, end); + + if (p == end) { + break; + } + } + + if (!seen_name) { + ngx_js_form_error(error, "multipart field name is required"); + return NGX_JS_FORM_PARSE_ERROR; + } + + return NGX_JS_FORM_OK; +} + + +static ngx_int_t +ngx_js_form_parse_param(ngx_pool_t *pool, u_char **pp, u_char *end, + ngx_str_t *param, ngx_str_t *value, ngx_flag_t *quoted, ngx_str_t *error) +{ + u_char *p, *start; + + p = ngx_js_form_skip_ows(*pp, end); + start = p; + + while (p < end && *p != '=' && *p != ';' && !ngx_js_form_is_ows(*p)) { + p++; + } + + if (p == start) { + ngx_js_form_error(error, "malformed parameter"); + return NGX_JS_FORM_PARSE_ERROR; + } + + if (ngx_js_form_copy(pool, start, p, param) != NGX_OK) { + return NGX_ERROR; + } + + p = ngx_js_form_skip_ows(p, end); + + if (p == end || *p != '=') { + ngx_js_form_error(error, "malformed parameter"); + return NGX_JS_FORM_PARSE_ERROR; + } + + p++; + p = ngx_js_form_skip_ows(p, end); + + *quoted = 0; + + if (p < end && *p == '"') { + start = ++p; + + while (p < end && *p != '"') { + if (*p == '\\' && p + 1 < end) { + p += 2; + continue; + } + + p++; + } + + if (p == end) { + ngx_js_form_error(error, "unterminated quoted parameter"); + return NGX_JS_FORM_PARSE_ERROR; + } + + if (ngx_js_form_copy_quoted(pool, start, p, value) != NGX_OK) { + return NGX_ERROR; + } + + *quoted = 1; + p++; + + } else { + start = p; + + while (p < end && *p != ';' && !ngx_js_form_is_ows(*p)) { + p++; + } + + if (start == p) { + ngx_js_form_error(error, "empty parameter value"); + return NGX_JS_FORM_PARSE_ERROR; + } + + if (ngx_js_form_copy(pool, start, p, value) != NGX_OK) { + return NGX_ERROR; + } + } + + *pp = p; + + return NGX_JS_FORM_OK; +} + + +static ngx_int_t +ngx_js_form_add_entry(ngx_js_form_t *form, ngx_pool_t *pool, ngx_str_t *name, + ngx_str_t *value, ngx_uint_t *count, ngx_uint_t max_keys, + ngx_str_t *filename, ngx_flag_t is_file, ngx_str_t *error) +{ + ngx_js_form_entry_t *entry; + + if (++(*count) > max_keys) { + ngx_js_form_error(error, "maxKeys limit exceeded"); + return NGX_JS_FORM_PARSE_ERROR; + } + + entry = ngx_array_push(&form->entries); + if (entry == NULL) { + return NGX_ERROR; + } + + entry->name = *name; + entry->value = *value; + entry->is_file = is_file; + + if (filename != NULL) { + entry->filename = *filename; + + } else { + ngx_str_null(&entry->filename); + } + + return NGX_JS_FORM_OK; +} + + +static ngx_int_t +ngx_js_form_decode_urlencoded(ngx_pool_t *pool, u_char *start, u_char *end, + ngx_str_t *dst, ngx_str_t *error) +{ + u_char *p, *d, *out; + ngx_int_t n; + + out = ngx_pnalloc(pool, (end - start) + 1); + if (out == NULL) { + return NGX_ERROR; + } + + d = out; + + for (p = start; p < end; p++) { + if (*p == '+') { + *d++ = ' '; + continue; + } + + if (*p == '%') { + if (p + 2 >= end) { + ngx_js_form_error(error, "malformed percent escape"); + return NGX_JS_FORM_PARSE_ERROR; + } + + n = ngx_hextoi(p + 1, 2); + if (n == NGX_ERROR) { + ngx_js_form_error(error, "malformed percent escape"); + return NGX_JS_FORM_PARSE_ERROR; + } + + *d++ = (u_char) n; + p += 2; + continue; + } + + *d++ = *p; + } + + *d = '\0'; + dst->data = out; + dst->len = d - out; + + return NGX_JS_FORM_OK; +} + + +static ngx_int_t +ngx_js_form_copy(ngx_pool_t *pool, u_char *start, u_char *end, ngx_str_t *dst) +{ + dst->len = end - start; + + if (dst->len == 0) { + dst->data = (u_char *) ""; + return NGX_OK; + } + + dst->data = ngx_pnalloc(pool, dst->len + 1); + if (dst->data == NULL) { + return NGX_ERROR; + } + + ngx_memcpy(dst->data, start, dst->len); + dst->data[dst->len] = '\0'; + + return NGX_OK; +} + + +static ngx_int_t +ngx_js_form_copy_quoted(ngx_pool_t *pool, u_char *start, u_char *end, + ngx_str_t *dst) +{ + u_char *p, *d; + + dst->len = end - start; + + if (dst->len == 0) { + dst->data = (u_char *) ""; + return NGX_OK; + } + + dst->data = ngx_pnalloc(pool, dst->len + 1); + if (dst->data == NULL) { + return NGX_ERROR; + } + + d = dst->data; + + for (p = start; p < end; p++) { + if (*p == '\\' && p + 1 < end) { + p++; + } + + *d++ = *p; + } + + *d = '\0'; + dst->len = d - dst->data; + + return NGX_OK; +} + + +static void +ngx_js_form_error(ngx_str_t *error, const char *text) +{ + error->data = (u_char *) text; + error->len = ngx_strlen(text); +} + + +static u_char * +ngx_js_form_skip_ows(u_char *p, u_char *end) +{ + while (p < end && ngx_js_form_is_ows(*p)) { + p++; + } + + return p; +} + + +static ngx_uint_t +ngx_js_form_is_ows(u_char ch) +{ + return ch == ' ' || ch == '\t'; +} + + +static u_char * +ngx_js_form_find(u_char *start, u_char *end, u_char *pattern, size_t len) +{ + u_char *p, *last; + + if ((size_t) (end - start) < len) { + return NULL; + } + + last = end - len + 1; + + for (p = start; p < last; p++) { + if (*p == pattern[0] && ngx_memcmp(p, pattern, len) == 0) { + return p; + } + } + + return NULL; +} diff --git a/nginx/ngx_js_form.h b/nginx/ngx_js_form.h new file mode 100644 index 000000000..2375c7fb6 --- /dev/null +++ b/nginx/ngx_js_form.h @@ -0,0 +1,40 @@ +/* + * Copyright (C) Dmitry Volyntsev + * Copyright (C) F5, Inc. + */ + + +#ifndef _NGX_JS_FORM_H_INCLUDED_ +#define _NGX_JS_FORM_H_INCLUDED_ + + +#include +#include + +#define NGX_JS_FORM_DEFAULT_MAX_KEYS 128 + +#define NGX_JS_FORM_OK NGX_OK +#define NGX_JS_FORM_TYPE_ERROR NGX_DECLINED +#define NGX_JS_FORM_PARSE_ERROR NGX_DONE + + +typedef struct { + ngx_str_t name; + ngx_str_t value; + ngx_str_t filename; + unsigned is_file:1; +} ngx_js_form_entry_t; + + +typedef struct { + ngx_array_t entries; + unsigned has_files:1; +} ngx_js_form_t; + + +ngx_int_t ngx_js_parse_form(ngx_pool_t *pool, ngx_str_t *content_type, + u_char *body, size_t len, ngx_uint_t max_keys, ngx_js_form_t **form, + ngx_str_t *error); + + +#endif /* _NGX_JS_FORM_H_INCLUDED_ */ diff --git a/nginx/t/js_request_form.t b/nginx/t/js_request_form.t new file mode 100644 index 000000000..a469a8ffb --- /dev/null +++ b/nginx/t/js_request_form.t @@ -0,0 +1,677 @@ +#!/usr/bin/perl + +# (C) Dmitry Volyntsev +# (C) F5, Inc. + +# Tests for http njs module, r.readRequestForm() method. + +############################################################################### + +use warnings; +use strict; + +use Test::More; + +use Socket qw/ CRLF /; + +BEGIN { use FindBin; chdir($FindBin::Bin); } + +use lib 'lib'; +use Test::Nginx qw/ :DEFAULT /; + +############################################################################### + +select STDERR; $| = 1; +select STDOUT; $| = 1; + +my $t = Test::Nginx->new()->has(qw/http/) + ->write_file_expand('nginx.conf', <<'EOF'); + +%%TEST_GLOBALS%% + +daemon off; + +events { +} + +http { + %%TEST_GLOBALS_HTTP%% + + js_import test.js; + + js_var $foo; + + server { + listen 127.0.0.1:8080; + server_name localhost; + + location /access_form { + js_access test.access_form; + js_content test.content; + } + + location /content_form { + js_content test.content_form; + } + + location /content_form_hex { + js_content test.content_form_hex; + } + + location /content_form_cache { + js_content test.content_form_cache; + } + + location /content_text_then_form { + js_content test.content_text_then_form; + } + + location /content_form_error { + js_content test.content_form_error; + } + + location /content_form_limit { + js_content test.content_form_limit; + } + + location /content_form_no_options { + js_content test.content_form_no_options; + } + } +} + +EOF + +$t->write_file('test.js', <<'EOF'); + function hex(s) { + let out = ''; + + for (let i = 0; i < s.length; i++) { + let c = s.charCodeAt(i); + out += (c < 0x10 ? '0' : '') + c.toString(16); + } + + return out; + } + + function render(form) { + let first = form.get('a'); + let files = []; + let pairs = []; + let upload = form.get('upload'); + let uploadAll = form.getAll('upload') + .map(v => typeof v == 'string' ? v : v.name); + let uploadFirst = ''; + + if (first === null) { + first = 'null'; + + } else if (typeof first != 'string') { + first = `[file:${first.name}]`; + } + + if (upload !== null && typeof upload != 'string') { + uploadFirst = upload.name; + } + + form.forEach((value, key) => { + if (typeof value == 'string') { + pairs.push(`${key}=${value}`); + return; + } + + files.push(`${key}:${value.name}`); + pairs.push(`${key}=[file:${value.name}]`); + }); + + return [ + first, + form.getAll('a') + .map(v => typeof v == 'string' ? v : `[file:${v.name}]`) + .join(','), + form.has('a'), + form.has('upload'), + form.hasFiles(), + files.length == 0 ? '' + : `get:${uploadFirst};all:${uploadAll};` + + `each:${files.join(',')}`, + pairs.join('&') + ].join('|'); + } + + function content(r) { + r.return(200, `var:${r.variables.foo}`); + } + + async function access_form(r) { + try { + r.variables.foo = render(await r.readRequestForm({maxKeys: 8})); + + } catch (e) { + r.variables.foo = `${e.constructor.name}:${e.message}`; + } + } + + async function content_form(r) { + try { + r.return(200, render(await r.readRequestForm({maxKeys: 8}))); + + } catch (e) { + r.return(500, `${e.constructor.name}:${e.message}`); + } + } + + async function content_form_hex(r) { + try { + let form = await r.readRequestForm({maxKeys: 8}); + let value = form.get('a'); + + if (value === null) { + value = 'NULL'; + } + + r.return(200, hex(value)); + + } catch (e) { + r.return(500, `${e.constructor.name}:${e.message}`); + } + } + + async function content_form_cache(r) { + let form = await r.readRequestForm({maxKeys: 8}); + + await r.readRequestForm({maxKeys: 1}); + + r.return(200, render(form)); + } + + async function content_text_then_form(r) { + let text = await r.readRequestText(); + let form = await r.readRequestForm({maxKeys: 8}); + + r.return(200, `${text.length}|${render(form)}`); + } + + async function content_form_error(r) { + try { + await r.readRequestForm({maxKeys: 8}); + r.return(200, 'no_error'); + + } catch (e) { + r.return(500, `${e.constructor.name}:${e.message}`); + } + } + + async function content_form_limit(r) { + try { + await r.readRequestForm({maxKeys: 1}); + r.return(200, 'no_error'); + + } catch (e) { + r.return(500, e.message); + } + } + + async function content_form_no_options(r) { + try { + r.return(200, render(await r.readRequestForm({}))); + + } catch (e) { + r.return(500, `${e.constructor.name}:${e.message}`); + } + } + + export default { access_form, content, content_form, content_form_hex, + content_form_cache, content_text_then_form, + content_form_error, content_form_limit, + content_form_no_options }; +EOF + +$t->try_run('no readRequestForm')->plan(60); + +############################################################################### + +like(http_post_form('/access_form', + urlencoded_form('a=1&a=2&empty=&=blank&space=one+two')), + qr/200.*var:1\|1,2\|true\|false\|false\|\|a=1&a=2&empty=&=blank&space=one two/s, + 'readRequestForm() in js_access with urlencoded body'); + +like(http_post_form('/content_form', + urlencoded_form('a=1&a=2&empty=&=blank&space=one+two')), + qr/200.*1\|1,2\|true\|false\|false\|\|a=1&a=2&empty=&=blank&space=one two/s, + 'readRequestForm() in js_content with urlencoded body'); + +like(http_post_form('/content_form_cache', urlencoded_form('a=1&a=2')), + qr/200.*1\|1,2\|true\|false\|false\|\|a=1&a=2/s, + 'successful form parse is cached'); + +like(http_post_form('/content_text_then_form', + urlencoded_form('a=1&a=2&z=3')), + qr/200.*11\|1\|1,2\|true\|false\|false\|\|a=1&a=2&z=3$/s, + 'readRequestText() then readRequestForm() reuses cached body'); + +like(http_post_form('/content_form', urlencoded_form('')), + qr/200.*null\|\|false\|false\|false\|\|$/s, + 'empty urlencoded body returns an empty form'); + +like(http_post_form('/content_form', + urlencoded_form('&baz=fuz&&muz=tax&')), + qr/200.*null\|\|false\|false\|false\|\|baz=fuz&muz=tax/s, + 'urlencoded empty fields are skipped'); + +like(http_post_form('/content_form', + urlencoded_form('freespace&name&value=12')), + qr/200.*null\|\|false\|false\|false\|\|freespace=&name=&value=12/s, + 'urlencoded fields without equals have an empty value'); + +like(http_post_form('/content_form', + urlencoded_form('==fu=z&baz=bar')), + qr/200.*null\|\|false\|false\|false\|\|==fu=z&baz=bar/s, + 'urlencoded first equals separates name and value'); + +like(http_post_form('/content_form', + urlencoded_form('ba+z=f+uz')), + qr/200.*null\|\|false\|false\|false\|\|ba z=f uz/s, + 'urlencoded plus is decoded in names and values'); + +like(http_post_form('/content_form_hex', urlencoded_form('a=%41%42%43')), + qr/200.*414243$/s, + 'urlencoded percent-decoding of %41%42%43 returns ABC'); + +like(http_post_form('/content_form_hex', urlencoded_form('a=%2a%5F%7e')), + qr/200.*2a5f7e$/s, + 'urlencoded percent-decoding accepts mixed-case hex digits'); + +like(http_post_form('/content_form_hex', urlencoded_form('a=%00')), + qr/200.*00$/s, + 'urlencoded percent-decoding accepts NUL byte'); + +like(http_post_form('/content_form_hex', urlencoded_form('a=x%20+y')), + qr/200.*78202079$/s, + 'urlencoded percent-decoding handles %20 and + in one value'); + +like(http_post_form('/content_form', + ['application/x-www-form-urlencoded ; charset=utf-8', 'a=1']), + qr/200.*1\|1\|true\|false\|false\|\|a=1/s, + 'content type OWS before parameters is skipped'); + +like(http_post_form('/content_form', + multipart_form( + { name => 'a', value => '1' }, + { name => 'upload', filename => 'a.txt', value => 'AAA' }, + { name => 'a', value => '2' }, + { name => 'upload', filename => 'b.txt', value => 'BBB' }, + { name => 'z', value => '3' }, + )), + qr{ + 200.*1\|1,2\|true\|true\|true\| + get:a.txt;all:a.txt,b.txt;each:upload:a.txt,upload:b.txt\| + a=1&upload=\[file:a.txt\]&a=2&upload=\[file:b.txt\]&z=3 + }sx, + 'multipart text fields and file metadata'); + +like(http_post_form('/content_form', + multipart_form( + { name => 'upload', filename => 'only.txt', value => 'AAA' }, + )), + qr{ + 200.*null\|\|false\|true\|true\| + get:only.txt;all:only.txt;each:upload:only.txt\| + upload=\[file:only.txt\]$ + }sx, + 'file parts expose filename metadata'); + +like(http_post_form('/content_form', + multipart_form( + { name => 'a\\"b', value => '1' }, + )), + qr/200.*a"b=1/s, 'quoted multipart parameter escapes are unescaped'); + +like(http_post_form('/content_form', + multipart_form({ name => 'empty', value => '' })), + qr/200.*null\|\|false\|false\|false\|\|empty=$/s, + 'empty multipart text field is preserved'); + +like(http_post_form('/content_form', + ['multipart/form-data; boundary=X', '--X--']), + qr/200.*null\|\|false\|false\|false\|\|$/s, + 'empty multipart body returns an empty form'); + +like(http_post_form('/content_form', + ['multipart/form-data; boundary=X', + '--X' . CRLF + . 'Content-Disposition: form-data; name="ows" ' . CRLF . CRLF + . '1' . CRLF + . '--X--']), + qr/200.*null\|\|false\|false\|false\|\|ows=1/s, + 'multipart header value trailing OWS is skipped'); + +like(http_post_form('/content_form_no_options', urlencoded_form('a=1&b=2')), + qr/200.*1\|1\|true\|false\|false\|\|a=1&b=2/s, + 'readRequestForm({}) accepts an empty options object'); + +my $utf8_filename = "\xe6\x97\xa5\xe6\x9c\xac.txt"; +like(http_post_form('/content_form', + multipart_form( + { name => 'upload', filename => $utf8_filename, value => 'AAA' }, + )), + qr{200.*null\|\|false\|true\|true\| + get:\Q$utf8_filename\E;all:\Q$utf8_filename\E; + each:upload:\Q$utf8_filename\E\| + upload=\[file:\Q$utf8_filename\E\]$}sx, + 'multipart filename preserves raw UTF-8 bytes'); + +my $fake_boundary = multipart_form( + { name => 'x', + value => "payload\r\n--FAKE\r\n\r\nContent-Disposition: form-data; " + . 'name="injected"' . "\r\n\r\nevil" }, +); + +like(http_post_form('/content_form', $fake_boundary), + qr/200.*x=payload/s, + 'fake multipart boundary in body is treated as payload'); +unlike(http_post_form('/content_form', $fake_boundary), + qr/injected=evil/s, + 'fake multipart boundary does not restart header parsing'); + +like(http_post_form('/content_form_error', urlencoded_form('a=%')), + qr/500.*malformed percent escape/s, + 'urlencoded bare % at end is rejected'); + +like(http_post_form('/content_form_error', urlencoded_form('a=%4')), + qr/500.*malformed percent escape/s, + 'urlencoded %X with missing second digit is rejected'); + +like(http_post_form('/content_form_error', urlencoded_form('a=%gg')), + qr/500.*malformed percent escape/s, + 'urlencoded non-hex percent escape is rejected'); + +like(http_post_form('/content_form_error', urlencoded_form('%Z=1')), + qr/500.*malformed percent escape/s, + 'urlencoded malformed percent escape in name is rejected'); + +like(http_post_form('/content_form_error', ['text/plain', 'a=1']), + qr/500.*TypeError:unsupported content type/s, + 'unsupported content type is rejected'); + +like(http_post_form('/content_form_error', [';boundary=X', '']), + qr/500.*TypeError:unsupported content type/s, + 'empty content type is rejected'); + +like(http_post_raw('/content_form_error', 'a=1'), + qr/500.*TypeError:request content type is required/s, + 'missing content type is rejected'); + +like(http_post_form('/content_form_error', + ['application/x-www-form-urlencoded; =x', 'a=1']), + qr/500.*malformed parameter/s, + 'malformed content type parameter is rejected'); + +like(http_post_form('/content_form_error', ['multipart/form-data', 'a=1']), + qr/500.*TypeError:multipart boundary is required/s, + 'multipart boundary is required'); + +like(http_post_form('/content_form_error', + ['multipart/form-data; boundary=""', '']), + qr/500.*(invalid multipart boundary|empty parameter value)/s, + 'empty quoted multipart boundary is rejected'); + +like(http_post_form('/content_form_error', + ["multipart/form-data; boundary=" . 'x' x 201, '']), + qr/500.*invalid multipart boundary/s, + 'multipart boundary over 200 bytes is rejected'); + +like(http_post_form('/content_form_error', + ['multipart/form-data; boundary=X; boundary=Y', '--X--']), + qr/500.*duplicate boundary parameter/s, + 'duplicate multipart boundary parameter is rejected'); + +like(http_post_form('/content_form_error', + ['multipart/form-data; boundary=X junk', '--X--']), + qr/500.*(malformed content type|malformed parameter)/s, + 'malformed trailing content type parameter data is rejected'); + +like(http_post_form('/content_form_error', + ['multipart/form-data; boundary=XXX', '--XXXjunk']), + qr/500.*malformed multipart boundary/s, + 'multipart opening delimiter without CRLF is rejected'); + +like(http_post_form('/content_form_error', + ['multipart/form-data; boundary=XXX', '-']), + qr/500.*malformed multipart body/s, + 'short multipart body without boundary marker is rejected'); + +like(http_post_form('/content_form_error', + ['multipart/form-data; boundary=X', + '--X' . CRLF + . 'Content-Disposition: form-data; name="a"']), + qr/500.*missing multipart header separator/s, + 'multipart part without header separator is rejected'); + +like(http_post_form('/content_form_error', + ['multipart/form-data; boundary=X', + '--X' . CRLF + . 'X-Large: ' . ('a' x 17000) . CRLF . CRLF + . 'data' . CRLF + . '--X--']), + qr/500.*multipart headers are too large/s, + 'multipart header block size limit is enforced'); + +like(http_post_form('/content_form_error', + ['multipart/form-data; boundary=X', + '--X' . CRLF + . 'X-Long: ' . ('a' x 4100) . CRLF + . 'Content-Disposition: form-data; name="a"' . CRLF . CRLF + . 'data' . CRLF + . '--X--']), + qr/500.*multipart header line is too long/s, + 'multipart header line size limit is enforced'); + +my $many_headers = join('', map { "X-$_: v" . CRLF } 1 .. 33); + +like(http_post_form('/content_form_error', + ['multipart/form-data; boundary=X', + '--X' . CRLF + . $many_headers + . 'Content-Disposition: form-data; name="a"' . CRLF . CRLF + . 'data' . CRLF + . '--X--']), + qr/500.*too many multipart headers/s, + 'multipart header count limit is enforced'); + +like(http_post_form('/content_form_error', + ['multipart/form-data; boundary=X', + '--X' . CRLF + . 'X-Other: foo' . CRLF . CRLF + . 'data' . CRLF + . '--X--']), + qr/500.*missing Content-Disposition header/s, + 'multipart part without Content-Disposition is rejected'); + +like(http_post_form('/content_form_error', + ['multipart/form-data; boundary=X', + '--X' . CRLF + . 'Content-Disposition: form-data; name="a"' . CRLF + . 'Content-Disposition: form-data; name="b"' . CRLF . CRLF + . 'data' . CRLF + . '--X--']), + qr/500.*duplicate Content-Disposition header/s, + 'duplicate multipart Content-Disposition header is rejected'); + +like(http_post_form('/content_form_error', + ['multipart/form-data; boundary=X', + '--X' . CRLF + . 'Content-Disposition: attachment; name="a"' . CRLF . CRLF + . 'data' . CRLF + . '--X--']), + qr/500.*unsupported disposition type/s, + 'unsupported multipart disposition type is rejected'); + +like(http_post_form('/content_form_error', + ['multipart/form-data; boundary=X', + '--X' . CRLF + . 'Content-Disposition: form-data' . CRLF . CRLF + . 'data' . CRLF + . '--X--']), + qr/500.*multipart field name is required/s, + 'multipart Content-Disposition without name is rejected'); + +like(http_post_form('/content_form_error', + ['multipart/form-data; boundary=X', + '--X' . CRLF + . 'Content-Disposition: form-data; name="a" junk' + . CRLF . CRLF + . 'data' . CRLF + . '--X--']), + qr/500.*malformed Content-Disposition/s, + 'multipart Content-Disposition trailing data is rejected'); + +like(http_post_form('/content_form_error', + ['multipart/form-data; boundary=X', + '--X' . CRLF + . 'Content-Disposition: form-data; name="a"; name="b"' + . CRLF . CRLF + . 'data' . CRLF + . '--X--']), + qr/500.*duplicate name parameter/s, + 'duplicate multipart name parameter is rejected'); + +like(http_post_form('/content_form_error', + ['multipart/form-data; boundary=X', + '--X' . CRLF + . 'Content-Disposition: form-data; name="a"; filename="x"; ' + . 'filename="y"' . CRLF . CRLF + . 'data' . CRLF + . '--X--']), + qr/500.*duplicate filename parameter/s, + 'duplicate multipart filename parameter is rejected'); + +like(http_post_form('/content_form_error', + ['multipart/form-data; boundary=X', + '--X' . CRLF + . 'Content-Disposition: form-data; name' . CRLF . CRLF + . 'data' . CRLF + . '--X--']), + qr/500.*malformed parameter/s, + 'multipart parameter without equals is rejected'); + +like(http_post_form('/content_form_error', + ['multipart/form-data; boundary=X', + '--X' . CRLF + . 'Content-Disposition: form-data; name=' . CRLF . CRLF + . 'data' . CRLF + . '--X--']), + qr/500.*empty parameter value/s, + 'multipart parameter with empty unquoted value is rejected'); + +like(http_post_form('/content_form_error', + ['multipart/form-data; boundary=X', + '--X' . CRLF + . 'Content-Disposition: form-data; name="a"; filename="x\\"' + . CRLF . CRLF + . 'data' . CRLF + . '--X--']), + qr/500.*unterminated quoted parameter/s, + 'multipart trailing backslash in quoted parameter is rejected'); + +like(http_post_form('/content_form_error', + ['multipart/form-data; boundary=X', + '--X' . CRLF + . 'NoColonHere' . CRLF + . 'Content-Disposition: form-data; name="a"' . CRLF . CRLF + . 'data' . CRLF + . '--X--']), + qr/500.*malformed multipart header/s, + 'multipart header line without colon is rejected'); + +like(http_post_form('/content_form_error', + ['multipart/form-data; boundary=X', + '--X' . CRLF + . 'Content-Disposition: form-data; name="a"' . CRLF . CRLF + . 'data' . CRLF + . '--Xjunk']), + qr/500.*malformed multipart boundary/s, + 'malformed multipart boundary after part is rejected'); + +like(http_post_form('/content_form_error', + ['multipart/form-data; boundary=XXX', 'no boundary here at all']), + qr/500.*malformed multipart body/s, + 'multipart body without boundary marker is rejected'); + +like(http_post_form('/content_form_error', + ['multipart/form-data; boundary=X', + '--X' . CRLF + . 'Content-Disposition: form-data; name="a"' . CRLF . CRLF + . 'value with no terminating boundary']), + qr/500.*truncated multipart body/s, + 'multipart body without closing boundary is rejected'); + +like(http_post_form('/content_form_limit', urlencoded_form('a=1&b=2')), + qr/500.*maxKeys limit exceeded/s, 'maxKeys limit breach rejects'); + +like(http_post_form('/content_form_limit', urlencoded_form('&a=1&&')), + qr/200.*no_error/s, 'urlencoded empty fields do not count for maxKeys'); + +like(http_post_form('/content_form_limit', + multipart_form({ name => 'a', value => '1' }, + { name => 'b', value => '2' })), + qr/500.*maxKeys limit exceeded/s, + 'multipart maxKeys limit breach rejects'); + +############################################################################### + +sub http_post_form { + my ($url, $form, %extra) = @_; + my ($content_type, $body) = @{$form}; + + my $r = "POST $url HTTP/1.0" . CRLF + . "Host: localhost" . CRLF + . "Content-Type: $content_type" . CRLF + . "Content-Length: " . length($body) . CRLF + . CRLF + . $body; + + return http($r, %extra); +} + +sub http_post_raw { + my ($url, $body, %extra) = @_; + + my $r = "POST $url HTTP/1.0" . CRLF + . "Host: localhost" . CRLF + . "Content-Length: " . length($body) . CRLF + . CRLF + . $body; + + return http($r, %extra); +} + +sub urlencoded_form { + my ($body) = @_; + + return ['application/x-www-form-urlencoded', $body]; +} + +sub multipart_form { + my (@parts) = @_; + my $boundary = '----test-boundary'; + my $body = ''; + + for my $part (@parts) { + $body .= '--' . $boundary . CRLF; + $body .= 'Content-Disposition: form-data; name="' . $part->{name} . '"'; + + if (defined $part->{filename}) { + $body .= '; filename="' . $part->{filename} . '"'; + } + + $body .= CRLF . CRLF; + $body .= $part->{value} . CRLF; + } + + $body .= '--' . $boundary . '--'; + + return ["multipart/form-data; boundary=$boundary", $body]; +} + +############################################################################### diff --git a/ts/ngx_http_js_module.d.ts b/ts/ngx_http_js_module.d.ts index 6f9a07c78..39acbad0e 100644 --- a/ts/ngx_http_js_module.d.ts +++ b/ts/ngx_http_js_module.d.ts @@ -273,6 +273,24 @@ interface NginxHTTPSendBufferOptions { flush?: boolean } +/** + * @since 0.9.9 + */ +interface NginxHTTPRequestFormFile { + readonly name: string; +} + +type NginxHTTPRequestFormValue = string | NginxHTTPRequestFormFile; + +interface NginxHTTPRequestForm { + get(name: NjsStringOrBuffer): NginxHTTPRequestFormValue | null; + getAll(name: NjsStringOrBuffer): NginxHTTPRequestFormValue[]; + has(name: NjsStringOrBuffer): boolean; + forEach(callback: (value: NginxHTTPRequestFormValue, key: string, + form: NginxHTTPRequestForm) => void, thisArg?: any): void; + hasFiles(): boolean; +} + interface NginxHTTPRequest { /** * Request arguments object. @@ -388,13 +406,13 @@ interface NginxHTTPRequest { * Available in js_access and js_content directives. The request body * size is limited by client_max_body_size. * - * The body is read once and cached on the request: subsequent - * `readRequestText`, `readRequestArrayBuffer`, and `readRequestJSON` - * calls resolve synchronously from the cache and do not re-read the - * wire. This deliberately differs from the WHATWG Fetch Body mixin - * (which makes the body unusable after the first call) and matches - * the server-side caching pattern used by Express, Flask, and similar - * frameworks. + * The body is read once and cached on the request: subsequent reads + * across any combination of `readRequestText`, `readRequestArrayBuffer`, + * `readRequestJSON`, and `readRequestForm` resolve synchronously from + * the cache and do not re-read the wire. This deliberately differs + * from the WHATWG Fetch Body mixin (which makes the body unusable + * after the first call) and matches the server-side caching pattern + * used by Express, Flask, and similar frameworks. * * A second call issued while a previous `readRequest*` promise has * not yet resolved throws `"request body is already being read"`. @@ -422,6 +440,27 @@ interface NginxHTTPRequest { * @since 0.9.9 */ readRequestJSON(): Promise; + /** + * Reads the client request body and parses it as a supported HTML form. + * + * Supports `application/x-www-form-urlencoded` and + * `multipart/form-data`. + * + * For text fields, the value is the decoded string. For file parts, + * the value is a File-like object exposing only the client-supplied + * filename via `name`. File contents are not exposed in this release. + * + * Filename is client-supplied and not sanitized - validate it before + * using it for filesystem paths, log lines, or redirects. + * + * See {@link readRequestText} for body caching, concurrency, and + * availability semantics. In addition, the parsed form is itself + * cached: a second call returns the same parsed result and ignores + * any new options argument. + * + * @since 0.9.9 + */ + readRequestForm(options?: { maxKeys?: number }): Promise; /** * Subrequest response body. The size of response body is limited by * the subrequest_output_buffer_size directive.