From 0ffc96df7f341f6c5f6f70cb6207030b6afe0de3 Mon Sep 17 00:00:00 2001 From: Dmitry Volyntsev Date: Thu, 9 Apr 2026 22:56:01 -0700 Subject: [PATCH] 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 5bc93a3a..7b34163d 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 a448ce57..12c6081f 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 @@ resolve: } +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 63cbf00a..c6cc8a3e 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 00000000..a44ed275 --- /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 00000000..2375c7fb --- /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 00000000..a469a8ff --- /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 6f9a07c7..39acbad0 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. -- 2.47.3