Nilorea Library
C utilities for networking, threading, graphics
Loading...
Searching...
No Matches
ex_http_form.c

n_http_form: strict percent-decoding, query strings and urlencoded forms; malformed escapes, NUL bytes, ill-formed UTF-8 and field floods refused

n_http_form: strict percent-decoding, query strings and urlencoded forms; malformed escapes, NUL bytes, ill-formed UTF-8 and field floods refused

Author
Castagnier Mickael
Version
1.0
Date
23/10/2026
/*
* Nilorea Library
* Copyright (C) 2005-2026 Castagnier Mickael
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
* implied. See the License for the specific language governing
* permissions and limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include "nilorea/n_log.h"
#include "nilorea/n_str.h"
static int log_level = LOG_ERR;
static int failures = 0;
static void expect_true(const char* what, int ok) {
if (!ok) {
n_log(LOG_ERR, "FAIL: %s", what);
} else {
n_log(LOG_INFO, "ok: %s", what);
}
}
static int process_args(int argc, char** argv) {
int opt = 0;
while ((opt = getopt(argc, argv, "V:")) != -1) {
if (opt == 'V') {
log_level = atoi(optarg);
} else {
return -1;
}
}
return 0;
}
static int decodes_to(const char* in, int flags, const char* want) {
N_STR* out = NULL;
int ok = n_http_percent_decode(in, strlen(in), flags, &out);
int res;
if (!want)
res = (ok == FALSE);
else
res = ok == TRUE && out && out->written == strlen(want) && memcmp(out->data, want, out->written) == 0;
free_nstr(&out);
return res;
}
static void test_decode(void) {
N_STR* out = NULL;
expect_true("plain text unchanged", decodes_to("abc", 0, "abc"));
expect_true("escapes decoded, any hex case", decodes_to("%2Fa%2fb%20c", 0, "/a/b c"));
expect_true("UTF-8 escapes decoded", decodes_to("caf%C3%A9", 0, "caf\xc3\xa9"));
expect_true("'+' kept without PLUS_SPACE", decodes_to("a+b", 0, "a+b"));
expect_true("'+' is a space with PLUS_SPACE", decodes_to("a+b", N_HTTP_FORM_PLUS_SPACE, "a b"));
expect_true("%2B stays '+' with PLUS_SPACE", decodes_to("a%2Bb", N_HTTP_FORM_PLUS_SPACE, "a+b"));
expect_true("empty input", decodes_to("", 0, ""));
expect_true("lone '%' refused", decodes_to("a%", 0, NULL));
expect_true("one hex digit refused", decodes_to("a%4", 0, NULL));
expect_true("non-hex escape refused", decodes_to("a%zz", 0, NULL));
expect_true("decoded NUL refused", decodes_to("a%00b", 0, NULL));
expect_true("decoded NUL refused even with RAW_BYTES", decodes_to("a%00b", N_HTTP_FORM_RAW_BYTES, NULL));
expect_true("ill-formed UTF-8 refused", decodes_to("%E9t%E9", 0, NULL));
expect_true("overlong '/' refused", decodes_to("%C0%AF", 0, NULL));
expect_true("ill-formed UTF-8 kept with RAW_BYTES", decodes_to("%E9", N_HTTP_FORM_RAW_BYTES, "\xe9"));
/* appends; a failure leaves the destination as it was */
out = char_to_nstr("x=");
expect_true("append", n_http_percent_decode("%41", 3, 0, &out) == TRUE && strcmp(out->data, "x=A") == 0);
expect_true("failed append returns FALSE", n_http_percent_decode("%4", 2, 0, &out) == FALSE);
expect_true("failed append leaves the destination", out->written == 3 && strcmp(out->data, "x=A") == 0);
free_nstr(&out);
expect_true("NULL destination refused", n_http_percent_decode("a", 1, 0, NULL) == FALSE);
expect_true("NULL source with a length refused", n_http_percent_decode(NULL, 1, 0, &out) == FALSE && out == NULL);
}
static void test_form(void) {
N_HTTP_FORM* f = NULL;
size_t vlen = 0;
f = n_http_form_parse("s=hello+world&page=2&&tag%5B%5D=a&tag%5B%5D=b&flag&e=&s=last", 60, 50, 0);
expect_true("query parses", f != NULL);
if (f) {
const char* v = NULL;
expect_true("empty pair skipped: 7 fields", f->nb_fields == 7);
expect_true("fields keep their order", strcmp(f->fields[0].name, "s") == 0 && strcmp(f->fields[1].name, "page") == 0);
v = n_http_form_get(f, "s", &vlen);
expect_true("last occurrence wins, like PHP", v && strcmp(v, "last") == 0 && vlen == 4);
expect_true("'+' decoded as a space in values", strcmp(f->fields[0].value, "hello world") == 0);
expect_true("bracket names decoded and counted", n_http_form_count(f, "tag[]") == 2);
v = n_http_form_get(f, "flag", &vlen);
expect_true("pair without '=' has an empty value", v && v[0] == '\0' && vlen == 0);
v = n_http_form_get(f, "e", &vlen);
expect_true("'name=' has an empty value", v && v[0] == '\0' && vlen == 0);
expect_true("absent field is NULL", n_http_form_get(f, "nope", &vlen) == NULL && vlen == 0);
expect_true("names are case-sensitive", n_http_form_get(f, "S", NULL) == NULL);
expect_true("free sets NULL", f == NULL);
}
f = n_http_form_parse("a=1&b=%zz&c=3", 13, 10, 0);
expect_true("bad escape fails the whole form", f == NULL);
f = n_http_form_parse("a=1&b=%zz&c=%00&d=%E9&=x&e=5", 28, 10, N_HTTP_FORM_SKIP_INVALID);
expect_true("SKIP_INVALID parses", f != NULL);
if (f) {
expect_true("SKIP_INVALID drops the bad pairs", f->nb_fields == 2 && n_http_form_get(f, "a", NULL) && n_http_form_get(f, "e", NULL));
}
f = n_http_form_parse("=x", 2, 10, 0);
expect_true("empty name refused", f == NULL);
f = n_http_form_parse("a=1&b=2&c=3", 11, 2, N_HTTP_FORM_SKIP_INVALID);
expect_true("more pairs than max_fields refused, even with SKIP_INVALID", f == NULL);
f = n_http_form_parse("&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&a=1", 35, 1, 0);
expect_true("empty pairs do not count against max_fields", f != NULL && f->nb_fields == 1);
expect_true("RAW_BYTES keeps a latin-1 byte", f != NULL && f->fields[0].value_len == 1 && (unsigned char)f->fields[0].value[0] == 0xE9);
f = n_http_form_parse("", 0, 4, 0);
expect_true("empty query is an empty form", f != NULL && f->nb_fields == 0 && n_http_form_get(f, "a", NULL) == NULL);
f = n_http_form_parse(NULL, 0, 4, 0);
expect_true("NULL with length 0 is an empty form", f != NULL && f->nb_fields == 0);
expect_true("NULL with a length refused", n_http_form_parse(NULL, 3, 4, 0) == NULL);
expect_true("max_fields 0 refused", n_http_form_parse("a=1", 3, 0, 0) == NULL);
expect_true("count on NULL", n_http_form_count(NULL, "a") == 0);
}
static int part_body_is(const char* buf, const N_HTTP_FORM_PART* p, const char* want) {
return p && p->body_len == strlen(want) && memcmp(buf + p->body_off, want, p->body_len) == 0;
}
static N_HTTP_FORM_MULTIPART* mp_parse(const char* body, size_t max) {
return n_http_form_multipart_parse(body, strlen(body), "XyZ", max);
}
#define BND(ct) n_http_form_multipart_boundary((ct), strlen(ct), b)
static void test_multipart(void) {
static const char ok_body[] =
"--XyZ\r\n"
"Content-Disposition: form-data; name=\"title\"\r\n\r\n"
"Hello\r\n"
"--XyZ\r\n"
"Content-Disposition: form-data; name=\"file\"; filename=\"a \\\"q\\\".png\"\r\n"
"Content-Type: image/png\r\n\r\n"
"\x89PNG\r\n--XyZnot-a-delimiter\r\n\r\n\x00\x01\r\n"
"--XyZ\r\n"
"Content-Disposition: form-data; name=\"empty\"\r\n\r\n"
"\r\n"
"--XyZ\r\n"
"content-disposition: FORM-DATA; filename=\"\"; name=\"tag[]\"\r\n\r\n"
"x\r\n"
"--XyZ\r\n"
"Content-Disposition: form-data; name=\"title\"\r\n\r\n"
"Second\r\n"
"--XyZ--\r\nepilogue ignored";
const size_t ok_len = sizeof(ok_body) - 1; /* the file part holds a NUL */
/* the boundary from Content-Type */
expect_true("boundary token", BND("multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxk") &&
strcmp(b, "----WebKitFormBoundary7MA4YWxk") == 0);
expect_true("boundary quoted, any case", BND("Multipart/Form-Data; charset=utf-8; BOUNDARY=\"a b:c\"") &&
strcmp(b, "a b:c") == 0);
expect_true("boundary inside another quoted parameter not taken",
BND("multipart/form-data; x=\"boundary=evil\"; boundary=real") && strcmp(b, "real") == 0);
expect_true("two boundaries refused", !BND("multipart/form-data; boundary=a; boundary=b"));
expect_true("other media type refused", !BND("multipart/mixed; boundary=a"));
expect_true("no boundary refused", !BND("multipart/form-data"));
expect_true("boundary over 70 refused",
!BND("multipart/form-data; boundary=12345678901234567890123456789012345678901234567890123456789012345678901"));
expect_true("boundary with a bad character refused", !BND("multipart/form-data; boundary=\"a<b\""));
expect_true("boundary ending in a space refused", !BND("multipart/form-data; boundary=\"ab \""));
expect_true("unterminated quote refused", !BND("multipart/form-data; boundary=\"ab"));
mp = n_http_form_multipart_parse(ok_body, ok_len, "XyZ", 10);
expect_true("browser body parses", mp && mp->nb_parts == 5);
if (mp) {
expect_true("first field", strcmp(mp->parts[0].name, "title") == 0 && !mp->parts[0].has_filename && part_body_is(ok_body, &mp->parts[0], "Hello"));
expect_true("file part: escaped quotes in filename", f && f->has_filename && strcmp(f->filename, "a \"q\".png") == 0 && strcmp(f->content_type, "image/png") == 0);
expect_true("file body binary-safe, look-alike delimiter kept", f && f->body_len == 4 + 2 + 20 + 4 + 2 && memcmp(ok_body + f->body_off, "\x89PNG\r\n--XyZnot-a-delimiter", 26) == 0);
expect_true("empty body", part_body_is(ok_body, n_http_form_multipart_get(mp, "empty"), ""));
expect_true("header names and form-data any case; filename=\"\" is a file field",
strcmp(mp->parts[3].name, "tag[]") == 0 && mp->parts[3].has_filename && mp->parts[3].filename[0] == '\0');
expect_true("last occurrence wins, count", part_body_is(ok_body, n_http_form_multipart_get(mp, "title"), "Second") && n_http_form_multipart_count(mp, "title") == 2);
expect_true("absent", n_http_form_multipart_get(mp, "nope") == NULL && n_http_form_multipart_count(mp, "nope") == 0);
expect_true("free sets NULL", mp == NULL);
}
/* the parameter confusion the old parser fell for */
mp = mp_parse("--XyZ\r\nContent-Disposition: form-data; filename=\"evil\"; name=\"real\"\r\n\r\nv\r\n--XyZ--", 4);
expect_true("name= inside filename= is not the name", mp && strcmp(mp->parts[0].name, "real") == 0 && strcmp(mp->parts[0].filename, "evil") == 0);
expect_true("two name= refused", mp_parse("--XyZ\r\nContent-Disposition: form-data; name=\"a\"; name=\"b\"\r\n\r\nv\r\n--XyZ--", 4) == NULL);
expect_true("two filename= refused", mp_parse("--XyZ\r\nContent-Disposition: form-data; name=\"a\"; filename=\"x\"; filename=\"y\"\r\n\r\nv\r\n--XyZ--", 4) == NULL);
expect_true("filename* refused", mp_parse("--XyZ\r\nContent-Disposition: form-data; name=\"a\"; filename*=UTF-8''x\r\n\r\nv\r\n--XyZ--", 4) == NULL);
expect_true("two Content-Disposition refused",
mp_parse("--XyZ\r\nContent-Disposition: form-data; name=\"a\"\r\nContent-Disposition: form-data; name=\"b\"\r\n\r\nv\r\n--XyZ--", 4) == NULL);
expect_true("no Content-Disposition refused", mp_parse("--XyZ\r\nContent-Type: text/plain\r\n\r\nv\r\n--XyZ--", 4) == NULL);
expect_true("not form-data refused", mp_parse("--XyZ\r\nContent-Disposition: attachment; name=\"a\"\r\n\r\nv\r\n--XyZ--", 4) == NULL);
expect_true("empty name refused", mp_parse("--XyZ\r\nContent-Disposition: form-data; name=\"\"\r\n\r\nv\r\n--XyZ--", 4) == NULL);
expect_true("CR in a name refused", mp_parse("--XyZ\r\nContent-Disposition: form-data; name=\"a\rb\"\r\n\r\nv\r\n--XyZ--", 4) == NULL);
expect_true("ill-formed UTF-8 filename refused", mp_parse("--XyZ\r\nContent-Disposition: form-data; name=\"a\"; filename=\"\xe9.png\"\r\n\r\nv\r\n--XyZ--", 4) == NULL);
expect_true("folded header refused", mp_parse("--XyZ\r\nContent-Disposition: form-data;\r\n name=\"a\"\r\n\r\nv\r\n--XyZ--", 4) == NULL);
expect_true("header line without colon refused", mp_parse("--XyZ\r\nContent-Disposition: form-data; name=\"a\"\r\nbogus\r\n\r\nv\r\n--XyZ--", 4) == NULL);
/* truncation and limits */
expect_true("missing closing delimiter (truncated upload) refused", mp_parse("--XyZ\r\nContent-Disposition: form-data; name=\"a\"\r\n\r\nvalue", 4) == NULL);
expect_true("no blank line refused", mp_parse("--XyZ\r\nContent-Disposition: form-data; name=\"a\"\r\n", 4) == NULL);
expect_true("more parts than max refused",
mp_parse("--XyZ\r\nContent-Disposition: form-data; name=\"a\"\r\n\r\n1\r\n--XyZ\r\nContent-Disposition: form-data; name=\"b\"\r\n\r\n2\r\n--XyZ--", 1) == NULL);
{
static char big[N_HTTP_FORM_PART_HEAD_MAX + 200];
size_t o = (size_t)snprintf(big, sizeof(big), "--XyZ\r\nContent-Disposition: form-data; name=\"a\"\r\nX-Pad: ");
memset(big + o, 'p', N_HTTP_FORM_PART_HEAD_MAX);
memcpy(big + o, "\r\n\r\nv\r\n--XyZ--", 15);
expect_true("part header block over the cap refused", n_http_form_multipart_parse(big, o + 14, "XyZ", 4) == NULL);
}
{
char longname[300];
static char body[600];
memset(longname, 'n', N_HTTP_FORM_PART_NAME_MAX + 1);
longname[N_HTTP_FORM_PART_NAME_MAX + 1] = '\0';
snprintf(body, sizeof(body), "--XyZ\r\nContent-Disposition: form-data; name=\"%s\"\r\n\r\nv\r\n--XyZ--", longname);
expect_true("name over the cap refused", mp_parse(body, 4) == NULL);
}
/* framing details */
mp = mp_parse("preamble text\r\n--XyZ \r\nContent-Disposition: form-data; name=\"a\"\r\n\r\nv\r\n--XyZ\t\r\nContent-Disposition: form-data; name=b\r\n\r\nw\r\n--XyZ--", 4);
expect_true("preamble, transport padding, token name", mp && mp->nb_parts == 2 && strcmp(mp->parts[1].name, "b") == 0);
expect_true("delimiter not at the start and no CRLF before it refused", mp_parse("junk--XyZ\r\nContent-Disposition: form-data; name=\"a\"\r\n\r\nv\r\n--XyZ--", 4) == NULL);
expect_true("closing delimiter first refused", mp_parse("--XyZ--", 4) == NULL);
expect_true("wrong boundary refused", n_http_form_multipart_parse("--AbC\r\n", 7, "XyZ", 4) == NULL);
expect_true("NULL / zero max refused", n_http_form_multipart_parse(NULL, 0, "XyZ", 4) == NULL && n_http_form_multipart_parse("--XyZ--", 7, "XyZ", 0) == NULL);
}
#undef BND
int main(int argc, char** argv) {
if (process_args(argc, argv) != 0) {
fprintf(stderr, "usage: %s [-V LOG_LEVEL]\n", argv[0]);
return 1;
}
if (failures) {
n_log(LOG_ERR, "ex_http_form: %d failure(s)", failures);
return 1;
}
n_log(LOG_NOTICE, "ex_http_form: all checks passed");
return 0;
}
void process_args(int argc, char **argv)
static int failures
Definition ex_atlas.c:51
int main(void)
Definition ex_atlas.c:117
static void expect_true(const char *label, int cond)
Definition ex_atlas.c:53
static int log_level
static N_HTTP_FORM_MULTIPART * mp_parse(const char *body, size_t max)
parse a NUL-terminated multipart body
static int decodes_to(const char *in, int flags, const char *want)
decode a NUL-terminated literal and compare with the expected result
static int part_body_is(const char *buf, const N_HTTP_FORM_PART *p, const char *want)
body of a part as a string comparison
static void test_multipart(void)
multipart/form-data
static void test_form(void)
query strings and urlencoded bodies
static void test_decode(void)
percent-decoding
#define BND(ct)
boundary of a NUL-terminated Content-Type literal, into b
#define n_log(__LEVEL__,...)
Logging function wrapper to get line and func.
Definition n_log.h:113
#define LOG_ERR
error conditions
Definition n_log.h:100
void set_log_level(const int log_level)
Set the global log level value ( static int LOG_LEVEL ).
Definition n_log.c:123
#define LOG_NOTICE
normal but significant condition
Definition n_log.h:104
#define LOG_INFO
informational
Definition n_log.h:106
size_t nb_parts
number of parts
size_t body_len
body length
size_t value_len
length of value
Definition n_http_form.h:74
char content_type[127+1]
the part's Content-Type as sent, "" when absent: untrusted
char * value
decoded value, "" for "name" or "name="
Definition n_http_form.h:72
int has_filename
non-zero when the part carried a filename parameter (a file field)
char * name
decoded name
Definition n_http_form.h:68
size_t body_off
body offset in the parsed buffer
N_HTTP_FIELD * fields
the fields, nb_fields of them
Definition n_http_form.h:80
char name[200+1]
field name (non-empty, UTF-8, no control byte)
char filename[255+1]
the filename as sent (UTF-8, no control byte, may be ""): untrusted, never use it as a path
N_HTTP_FORM_PART * parts
the parts, in order
size_t nb_fields
number of fields
Definition n_http_form.h:82
#define N_HTTP_FORM_SKIP_INVALID
n_http_form_parse(): drop a pair that fails to decode instead of failing
Definition n_http_form.h:63
N_HTTP_FORM_MULTIPART * n_http_form_multipart_parse(const char *buf, size_t len, const char *boundary, size_t max_parts)
parse a multipart/form-data body strictly, without copying it
#define N_HTTP_FORM_PART_HEAD_MAX
largest header block of one part
void n_http_form_multipart_free(N_HTTP_FORM_MULTIPART **mp)
free a parsed body and set *mp to NULL
size_t n_http_form_count(const N_HTTP_FORM *form, const char *name)
number of fields named name (exact, case-sensitive)
const N_HTTP_FORM_PART * n_http_form_multipart_get(const N_HTTP_FORM_MULTIPART *mp, const char *name)
the LAST part named name (PHP semantics)
const char * n_http_form_get(const N_HTTP_FORM *form, const char *name, size_t *value_len)
value of the LAST field named name (PHP semantics)
N_HTTP_FORM * n_http_form_parse(const char *s, size_t len, size_t max_fields, int flags)
decode a query string or an urlencoded body into fields
int n_http_percent_decode(const char *s, size_t len, int flags, N_STR **out)
percent-decode [s, s+len), appending to *out; malformed escapes, a decoded NUL and (without N_HTTP_FO...
size_t n_http_form_multipart_count(const N_HTTP_FORM_MULTIPART *mp, const char *name)
number of parts named name
#define N_HTTP_FORM_PLUS_SPACE
decode '+' as a space (query strings and urlencoded forms, not paths)
Definition n_http_form.h:59
#define N_HTTP_FORM_BOUNDARY_MAX
longest boundary RFC 2046 allows
#define N_HTTP_FORM_PART_NAME_MAX
longest multipart field name accepted
#define N_HTTP_FORM_RAW_BYTES
accept decoded bytes that are not well-formed UTF-8 (NUL stays refused)
Definition n_http_form.h:61
void n_http_form_free(N_HTTP_FORM **form)
free a form and set *form to NULL
decoded fields, in the order they were sent
Definition n_http_form.h:78
a parsed multipart/form-data body
one part of a multipart/form-data body; the body is NOT copied
size_t written
number of meaningful bytes in data, excluding the null terminator; the size including the null termin...
Definition n_str.h:68
char * data
the string
Definition n_str.h:63
#define free_nstr(__ptr)
free a N_STR structure and set the pointer to NULL
Definition n_str.h:203
N_STR * char_to_nstr(const char *src)
Convert a char into a N_STR, short version.
Definition n_str.c:262
A box including a string and his lenght.
Definition n_str.h:61
Common headers and low-level functions & define.
Strict percent-decoding, query strings and urlencoded forms.
Generic log system.
N_STR and string function declaration.