commit 2ed9805b67558c4faca8edff765154d01f6e48f0 Author: kento2 Date: Wed Sep 9 04:16:40 2026 +0200 . diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..84c048a --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/build/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5742196 --- /dev/null +++ b/LICENSE @@ -0,0 +1,12 @@ +BSD Zero Clause License + +Permission to use, copy, modify, and/or distribute this software for +any purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL +WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES +OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE +FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY +DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN +AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT +OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 0000000..1a0e1ae --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Brian Khuu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README b/README new file mode 100644 index 0000000..afce661 --- /dev/null +++ b/README @@ -0,0 +1,18 @@ +parses a http body and pipes all its multipart/form-data +to a child program. set's the MIME_TYPE environment variable +before exec'ing into the child. + +field names are ignored. + +external deps: +- libowfat + +This project is licensed under the 0BSD license except for +the following third-party files: +./minimal_multipart_parser.c +./minimal_multipart_parser.h. +Copyright (c) 2024 Brian Khuu +MIT License +https://briankhuu.com/ + +https://github.com/mofosyne/minimal-multipart-form-data-parser-c?tab=MIT-1-ov-file diff --git a/htpipe.1 b/htpipe.1 new file mode 100644 index 0000000..19c222c --- /dev/null +++ b/htpipe.1 @@ -0,0 +1,23 @@ +.TH htpipe 1 +.SH NAME +htpipe \- pipe HTTP header fields +.SH SYNOPSIS +htpipe PROG ARGS +.SH DESCRIPTION +htpipe is meant to run under a superserver such +as tcpserver(1) or inetd(8). +It reads an HTTP request from stdin and pipes the content +of the first HTTP multipart/form-data field to PROG. +Specifically it exec's into PROG where the first +argument is set to PROG and the remaining to ARGS. +.PP +If the HTTP request specifies no multipart/form-data field, +htpipe responds with "400 Bad Request" and exits with 100. +.PP +On any other internal error, it responds with "500 Internal +Server Error" and exits with 111. +.SH "EXAMPLE" +An echo HTTP service: + tcpserver 0 80 htpipe nobotgate-access -n cat +.SH "SEE ALSO" +nobotgate-access(1), tcpserver(1), inetd(8) diff --git a/htpipe.c b/htpipe.c new file mode 100644 index 0000000..e7e15fa --- /dev/null +++ b/htpipe.c @@ -0,0 +1,81 @@ +#include "minimal_multipart_parser.h" +#include +#include +#include +#include + +#undef die +#undef diesys +#define die(n,...) \ + do { \ + carp(__VA_ARGS__,(char*)0); \ + dieservererr(n,__VA_ARGS__,(char*)0); \ + } while(0) +#define diesys(n,...) \ + do { \ + carpsys(__VA_ARGS__,(char*)0); \ + dieservererr(n,__VA_ARGS__,(char*)0); \ + } while(0) + +static buffer topipe; + +static void dieservererr(int n,char *args,...) +{ + char *header = "HTTP/1.0 500 Internal Server Error\r\n" + "Connection: close\r\n" + "\r\n"; + msg(header,args); + exit(n); +} + +static void dienodata(void) +{ + char *header = "HTTP/1.0 400 Bad Request\r\n" + "Connection: close\r\n" + "\r\n"; + msg(header,"HTTP multipart/form-data field expected."); + exit(100); +} + +static void run(void) { + MinimalMultipartParserContext ctx = {0}; + unsigned int ev; + int r; + char c; + + for (;;) { + r = buffer_getc(buffer_0,&c); + if (r==0) break; + if (r<0) diesys(111,"read stdin"); + + ev = minimal_multipart_parser_process(&ctx,c); + if (ev==MultipartParserEvent_DataBufferAvailable) { + if (buffer_put(&topipe, + minimal_multipart_parser_get_data_buffer(&ctx), + minimal_multipart_parser_get_data_size(&ctx))<0 + ) diesys(111,"write to pipe"); + } else if (ev==MultipartParserEvent_DataStreamCompleted) + break; + } + + if (!minimal_multipart_parser_is_file_received(&ctx)) + dienodata(); + if (buffer_flush(&topipe)<0) + diesys(111,"flush pipe write"); +} + +int main(int argc,char **argv) +{ + char buf[4096]; + int pipefds[2]; + argc--; argv++; + (void)argc; + + if (pipe(pipefds)<0) diesys(111,"pipe"); + buffer_init_write(&topipe,pipefds[1],buf,sizeof buf); + + run(); + if (dup2(pipefds[0],0)<0) diesys(111,"dup2"); + execvp(*argv,argv); + diesys(111,"exec ",*argv); +} diff --git a/meson.build b/meson.build new file mode 100644 index 0000000..bb38e25 --- /dev/null +++ b/meson.build @@ -0,0 +1,17 @@ +project( + 'htpipe', 'c', + default_options: [ + 'c_std=c99', + 'warning_level=2', + ], +) + +cc = meson.get_compiler('c') +deps = [ + cc.find_library('libowfat'), +] + +executable('htpipe', files( + './htpipe.c', + './minimal_multipart_parser.c', +), dependencies:deps, install:true) diff --git a/minimal_multipart_parser.c b/minimal_multipart_parser.c new file mode 100644 index 0000000..8f47769 --- /dev/null +++ b/minimal_multipart_parser.c @@ -0,0 +1,211 @@ +// +// minimal_multipart_parser.c +// +// Copyright (c) 2024 Brian Khuu https://briankhuu.com/ +// MIT licensed +// +// https://github.com/mofosyne/minimal-multipart-form-data-parser-c +// + +#include "minimal_multipart_parser.h" +#include + +static inline unsigned int buffer_count(MinimalMultipartParserCharBuffer *context) { return context->count; } + +static inline void buffer_reset(MinimalMultipartParserCharBuffer *context) +{ + context->buffer[0] = '\0'; + context->count = 0; +} + +static inline bool buffer_add(MinimalMultipartParserCharBuffer *context, const char c) +{ + if (context->count >= MINIMAL_MULTIPART_PARSER_FULL_BOUNDARY_BUFFER_MAX_CHAR) + { + return false; + } + + context->buffer[context->count] = c; + context->buffer[context->count + 1] = '\0'; + context->count++; + return true; +} + +MultipartParserEvent minimal_multipart_parser_process(MinimalMultipartParserContext *context, const char c) +{ + MinimalMultipartParserCharBuffer *boundaryBuffer = &(context->boundary); + MinimalMultipartParserCharBuffer *dataBuffer = &(context->data); + + if (context->data_available) + { + buffer_reset(dataBuffer); + context->data_available = false; + } + + if (context->phase == MultipartParserPhase_INIT) + { + switch (c) + { + case '\r': + context->phase = MultipartParserPhase_Preamble_CR; + return MultipartParserEvent_None; + case '-': + context->phase = MultipartParserPhase_Preamble_HYPHEN; + return MultipartParserEvent_None; + default: + context->phase = MultipartParserPhase_Preamble_SKIP_LINE; + return MultipartParserEvent_None; + } + } + + if (context->phase == MultipartParserPhase_Preamble_SKIP_LINE) + { + switch (c) + { + case '\r': + context->phase = MultipartParserPhase_Preamble_CR; + return MultipartParserEvent_None; + case '\n': + context->phase = MultipartParserPhase_Preamble_LF; + return MultipartParserEvent_None; + default: + return MultipartParserEvent_None; + } + } + + if (context->phase == MultipartParserPhase_Preamble_CR) + { + switch (c) + { + case '\n': + context->phase = MultipartParserPhase_Preamble_LF; + return MultipartParserEvent_None; + default: + context->phase = MultipartParserPhase_Preamble_SKIP_LINE; + return MultipartParserEvent_None; + } + } + + if (context->phase == MultipartParserPhase_Preamble_LF) + { + switch (c) + { + case '-': + context->phase = MultipartParserPhase_Preamble_HYPHEN; + return MultipartParserEvent_None; + default: + context->phase = MultipartParserPhase_Preamble_SKIP_LINE; + return MultipartParserEvent_None; + } + } + + if (context->phase == MultipartParserPhase_Preamble_HYPHEN) + { + // Previously got a dash in '\r\n--', seeking another dash + switch (c) + { + case '-': + context->phase = MultipartParserPhase_GetBoundary; + buffer_add(boundaryBuffer, '\r'); + buffer_add(boundaryBuffer, '\n'); + buffer_add(boundaryBuffer, '-'); + buffer_add(boundaryBuffer, '-'); + return MultipartParserEvent_None; + default: + context->phase = MultipartParserPhase_Preamble_SKIP_LINE; + return MultipartParserEvent_None; + } + } + + if (context->phase == MultipartParserPhase_GetBoundary) + { + switch (c) + { + case '\r': + context->phase = MultipartParserPhase_GetBoundary_Done; + return MultipartParserEvent_None; + default: + if ((c < ' ') || ('~' < c)) + { + context->phase = MultipartParserPhase_Preamble_SKIP_LINE; + buffer_reset(dataBuffer); + return MultipartParserEvent_None; + } + if (!buffer_add(boundaryBuffer, c)) + { + context->phase = MultipartParserPhase_Preamble_SKIP_LINE; + buffer_reset(dataBuffer); + return MultipartParserEvent_None; + } + return MultipartParserEvent_None; + } + } + + if (context->phase == MultipartParserPhase_GetBoundary_Done) + { + switch (c) + { + case '\n': + context->phase = MultipartParserPhase_SkipFileHeader; + return MultipartParserEvent_FileStreamFound; + default: + context->phase = MultipartParserPhase_Preamble_SKIP_LINE; + buffer_reset(dataBuffer); + return MultipartParserEvent_None; + } + } + + if (context->phase == MultipartParserPhase_SkipFileHeader) + { + const unsigned expected_index = buffer_count(dataBuffer); + const char expected_char = (MINIMAL_MULTIPART_PARSER_FILE_START_MARKER)[expected_index]; + if (c != expected_char) + { + buffer_reset(dataBuffer); + return MultipartParserEvent_None; + } + + buffer_add(dataBuffer, c); + if (buffer_count(dataBuffer) >= MINIMAL_MULTIPART_PARSER_FILE_START_MARKER_COUNT) + { + context->phase = MultipartParserPhase_GetFileBytes; + buffer_reset(dataBuffer); + return MultipartParserEvent_FileStreamStarting; + } + + return MultipartParserEvent_None; + } + + if (context->phase == MultipartParserPhase_GetFileBytes) + { + const unsigned char full_boundary_size = buffer_count(boundaryBuffer); + const char *full_boundary_string = context->boundary.buffer; + + const unsigned expected_index = buffer_count(dataBuffer); + const char expected_char = full_boundary_string[expected_index]; + + buffer_add(dataBuffer, c); + + if (c != expected_char) + { + context->data_available = true; + return MultipartParserEvent_DataBufferAvailable; + } + + if (buffer_count(dataBuffer) >= full_boundary_size) + { + context->phase = MultipartParserPhase_EndOfFile; + return MultipartParserEvent_DataStreamCompleted; + } + + return MultipartParserEvent_None; + } + + if (context->phase == MultipartParserPhase_EndOfFile) + { + // Do nothing... We already got the file now + return MultipartParserEvent_DataStreamCompleted; + } + + return MultipartParserEvent_None; +} diff --git a/minimal_multipart_parser.h b/minimal_multipart_parser.h new file mode 100644 index 0000000..4ceef5b --- /dev/null +++ b/minimal_multipart_parser.h @@ -0,0 +1,75 @@ +// +// minimal_multipart_parser.h +// +// Copyright (c) 2024 Brian Khuu https://briankhuu.com/ +// MIT licensed +// +// https://github.com/mofosyne/minimal-multipart-form-data-parser-c +// + +#ifndef MINIMAL_MULTIPART_PARSER_H +#define MINIMAL_MULTIPART_PARSER_H + +#include "minimal_multipart_parser.h" +#include + +// Size of the full boundary string we are searching for as a multipart file divider +// e.g. `\r\n--BOUNDARY` where BOUNDARY is a user specified 70 bytes long printable ascii string +#define MINIMAL_MULTIPART_PARSER_USER_BOUNDARY_SIZE (70) +#define MINIMAL_MULTIPART_PARSER_FULL_BOUNDARY_BUFFER_MAX_CHAR (4 + MINIMAL_MULTIPART_PARSER_USER_BOUNDARY_SIZE) + +#define MINIMAL_MULTIPART_PARSER_BOUNDARY_START_MARKER "\r\n--" +#define MINIMAL_MULTIPART_PARSER_BOUNDARY_START_MARKER_COUNT (sizeof(MINIMAL_MULTIPART_PARSER_BOUNDARY_START_MARKER) - 1) + +#define MINIMAL_MULTIPART_PARSER_FILE_START_MARKER "\r\n\r\n" +#define MINIMAL_MULTIPART_PARSER_FILE_START_MARKER_COUNT (sizeof(MINIMAL_MULTIPART_PARSER_FILE_START_MARKER) - 1) + +#define MINIMAL_MULTIPART_PARSER_MAX_CHAR (MINIMAL_MULTIPART_PARSER_FULL_BOUNDARY_BUFFER_MAX_CHAR) + +typedef enum MultipartParserEvent +{ + MultipartParserEvent_None, + MultipartParserEvent_FileStreamFound, + MultipartParserEvent_FileStreamStarting, + MultipartParserEvent_DataBufferAvailable, + MultipartParserEvent_DataStreamCompleted +} MultipartParserEvent; + +typedef enum MultipartParserPhase +{ + MultipartParserPhase_INIT, + MultipartParserPhase_Preamble_SKIP_LINE, + MultipartParserPhase_Preamble_CR, + MultipartParserPhase_Preamble_LF, + MultipartParserPhase_Preamble_HYPHEN, + MultipartParserPhase_GetBoundary, + MultipartParserPhase_GetBoundary_Done, + MultipartParserPhase_SkipFileHeader, + MultipartParserPhase_GetFileBytes, + MultipartParserPhase_EndOfFile +} MultipartParserPhase; + +typedef struct MinimalMultipartParserCharBuffer +{ + char buffer[MINIMAL_MULTIPART_PARSER_MAX_CHAR + 1]; + unsigned int count; +} MinimalMultipartParserCharBuffer; + +typedef struct MinimalMultipartParserContext +{ + MultipartParserPhase phase; + MinimalMultipartParserCharBuffer boundary; + MinimalMultipartParserCharBuffer data; + + bool data_available; +} MinimalMultipartParserContext; + +static inline const unsigned int minimal_multipart_parser_get_data_size(const MinimalMultipartParserContext *context) { return context->data.count; } + +static inline const char *minimal_multipart_parser_get_data_buffer(const MinimalMultipartParserContext *context) { return context->data.buffer; } + +static inline const bool minimal_multipart_parser_is_file_received(const MinimalMultipartParserContext *context) { return context->phase == MultipartParserPhase_EndOfFile; } + +MultipartParserEvent minimal_multipart_parser_process(MinimalMultipartParserContext *context, const char c); + +#endif