simplefiles

A lightweight C CGI web application for managing files.
git clone https://scm.kuandu.systems/git-raw/simplefiles.git
Log | Files | Refs | README | LICENSE

commit 5d57069f73989777a26d483634dfbdd86089b532
Author: Fred Großkopf <fred@kuandu.systems>
Date:   Wed, 16 Sep 2026 17:17:32 +0200

Init

Diffstat:
A.gitignore | 5+++++
ALICENSE | 13+++++++++++++
AMakefile | 374+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AREADME.md | 220+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aconf/bob | 1+
Aconf/httpd-test.conf | 19+++++++++++++++++++
Acss/style.css | 138+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahtml/delete.html.template | 22++++++++++++++++++++++
Ahtml/index.html | 10++++++++++
Ahtml/list-readonly.html.template | 37+++++++++++++++++++++++++++++++++++++
Ahtml/list.html.template | 41+++++++++++++++++++++++++++++++++++++++++
Ahtml/rename.html.template | 44++++++++++++++++++++++++++++++++++++++++++++
Ahtml/upload.html.template | 114+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascripts/dev-start.sh | 30++++++++++++++++++++++++++++++
Asrc/common.c | 237+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/common.h | 32++++++++++++++++++++++++++++++++
Asrc/config.c | 309+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/config.h | 40++++++++++++++++++++++++++++++++++++++++
Asrc/delete.c | 191+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/download.c | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/html.c | 361+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/html.h | 29+++++++++++++++++++++++++++++
Asrc/http.c | 107+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/http.h | 17+++++++++++++++++
Asrc/list.c | 217+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/multipart.c | 1006+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/multipart.h | 40++++++++++++++++++++++++++++++++++++++++
Asrc/rename.c | 247+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/request.c | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/request.h | 9+++++++++
Asrc/upload.c | 683+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/url.c | 203+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/url.h | 8++++++++
Atests/regression/delete.sh | 686+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/regression/download.sh | 471+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/regression/httpd.sh | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/regression/list.sh | 403+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/regression/rename.sh | 663+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/regression/testlib.sh | 311+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/regression/upload.sh | 1423+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/unit/common.c | 218+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/unit/config.c | 215+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/unit/html.c | 555+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/unit/http.c | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/unit/multipart.c | 1816+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/unit/url.c | 212+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/unit/valid_filename.c | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
47 files changed, 12130 insertions(+), 0 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -0,0 +1,5 @@ +testroot +build +*.BAK +simplefiles* +TODO.md diff --git a/LICENSE b/LICENSE @@ -0,0 +1,13 @@ +Copyright (c) 2026 Fred Großkopf + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +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/Makefile b/Makefile @@ -0,0 +1,374 @@ +# +# SimpleFiles Makefile +# + +CC= cc +CFLAGS= -static -Wall -Wextra -O2 -fno-pie -nopie -Isrc + +# +# Directories +# + +BUILDDIR= build +TESTROOT= testroot +TEST_CSRF_ORIGIN= http://localhost:8000 + +HTDOCS= ${TESTROOT}/htdocs +CGIBIN= ${TESTROOT}/cgi-bin +ETCDIR= ${TESTROOT}/etc +RUN= ${TESTROOT}/run +LOGS= ${TESTROOT}/logs +STORAGEROOT= ${TESTROOT}/storage +UPLOADDIR= ${TESTROOT}/run/simplefiles/uploads +LOCKSDIR= ${TESTROOT}/run/simplefiles/lock +TEMPLATE_ROOT= ${TESTROOT}/simplefiles +GLOBAL_CONFIG= ${ETCDIR}/simplefiles.conf +AUTHDIR= ${TESTROOT}/auth + + +# +# Build targets +# + +UPLOAD_CGI= ${BUILDDIR}/upload.cgi +LIST_CGI= ${BUILDDIR}/list.cgi +DELETE_CGI= ${BUILDDIR}/delete.cgi +DOWNLOAD_CGI= ${BUILDDIR}/download.cgi +RENAME_CGI= ${BUILDDIR}/rename.cgi + +CGI_TARGETS= ${UPLOAD_CGI} \ + ${LIST_CGI} \ + ${DELETE_CGI} \ + ${DOWNLOAD_CGI} \ + ${RENAME_CGI} + +# +# Common source files +# + +COMMON_SRCS= src/common.c \ + src/config.c \ + src/http.c \ + src/html.c \ + src/url.c + +COMMON_HDRS= src/common.h \ + src/config.h \ + src/http.h \ + src/html.h \ + src/url.h + +REQUSET_HDRS= src/request.h +REQUEST_SRCS= src/request.c + +# +# Unit tests +# + +VALID_FILENAME_TEST= ${BUILDDIR}/valid_filename +URL_TEST= ${BUILDDIR}/url +HTML_TEST= ${BUILDDIR}/html +HTTP_TEST= ${BUILDDIR}/http +COMMON_TEST= ${BUILDDIR}/common +CONFIG_TEST= ${BUILDDIR}/config +MULTIPART_TEST= ${BUILDDIR}/multipart + +UNIT_TESTS= ${VALID_FILENAME_TEST} \ + ${URL_TEST} \ + ${HTML_TEST} \ + ${HTTP_TEST} \ + ${COMMON_TEST} \ + ${CONFIG_TEST} \ + ${MULTIPART_TEST} +# +# Regression tests +# + +UPLOAD_REGRESSION= tests/regression/upload.sh +LIST_REGRESSION= tests/regression/list.sh +DELETE_REGRESSION= tests/regression/delete.sh +DOWNLOAD_REGRESSION= tests/regression/download.sh +RENAME_REGRESSION= tests/regression/rename.sh + +# +# Templates / static files +# + +UPLOAD_TEMPLATE= html/upload.html.template +DELETE_TEMPLATE= html/delete.html.template +LIST_TEMPLATE= html/list.html.template +LIST_READONLY_TEMPLATE= html/list-readonly.html.template +RENAME_TEMPLATE= html/rename.html.template +AUTH_FILE= conf/bob + +# +# Deployment package +# + +DIST_NAME= simplefiles +DIST_DIR= ${DIST_NAME} +DIST_ARCHIVE= ${DIST_NAME}.tar.gz + +# +# Default targets +# + +.PHONY: all build test test-unit test-regression +.PHONY: testroot prepare-testroot +.PHONY: clean cleantestroot dist clean-dist + +all: ${CGI_TARGETS} + +build: ${CGI_TARGETS} + +# +# CGI programs +# + +${UPLOAD_CGI}: src/upload.c ${COMMON_SRCS} ${COMMON_HDRS} src/multipart.h src/multipart.c + mkdir -p ${BUILDDIR} + ${CC} ${CFLAGS} -o $@ src/upload.c ${COMMON_SRCS} src/multipart.c + +${LIST_CGI}: src/list.c ${COMMON_SRCS} ${COMMON_HDRS} + mkdir -p ${BUILDDIR} + ${CC} ${CFLAGS} -o $@ src/list.c ${COMMON_SRCS} + +${DELETE_CGI}: src/delete.c ${COMMON_SRCS} ${COMMON_HDRS} ${REQUEST_HDRS} + mkdir -p ${BUILDDIR} + ${CC} ${CFLAGS} -o $@ src/delete.c ${COMMON_SRCS} ${REQUEST_SRCS} + +${DOWNLOAD_CGI}: src/download.c ${COMMON_SRCS} ${COMMON_HDRS} ${REQUEST_HDRS} + mkdir -p ${BUILDDIR} + ${CC} ${CFLAGS} -o $@ src/download.c ${COMMON_SRCS} ${REQUEST_SRCS} + +${RENAME_CGI}: src/rename.c ${COMMON_SRCS} ${COMMON_HDRS} ${REQUEST_HDRS} + mkdir -p ${BUILDDIR} + ${CC} ${CFLAGS} -o $@ src/rename.c ${COMMON_SRCS} ${REQUEST_SRCS} + +# +# Unit tests +# + +${VALID_FILENAME_TEST}: tests/unit/valid_filename.c \ + src/common.c \ + src/common.h \ + src/config.h \ + src/html.h + mkdir -p ${BUILDDIR} + ${CC} ${CFLAGS} -o $@ \ + tests/unit/valid_filename.c \ + src/common.c \ + src/config.c \ + src/html.c + +${URL_TEST}: tests/unit/url.c \ + src/url.c \ + src/url.h + mkdir -p ${BUILDDIR} + ${CC} ${CFLAGS} -o $@ \ + tests/unit/url.c \ + src/url.c + +${HTML_TEST}: tests/unit/html.c \ + src/html.c \ + src/html.h + mkdir -p ${BUILDDIR} + ${CC} ${CFLAGS} -o $@ \ + tests/unit/html.c \ + src/html.c + +${HTTP_TEST}: tests/unit/http.c src/http.c src/http.h src/html.c src/html.h + mkdir -p ${BUILDDIR} + ${CC} ${CFLAGS} -o $@ \ + tests/unit/http.c \ + src/http.c \ + src/html.c + +${COMMON_TEST}: tests/unit/common.c \ + src/common.c \ + src/common.h \ + src/config.c \ + src/config.h \ + src/http.c \ + src/http.h \ + src/html.c \ + src/html.h + mkdir -p ${BUILDDIR} + ${CC} ${CFLAGS} -o $@ \ + tests/unit/common.c \ + src/common.c \ + src/config.c \ + src/http.c \ + src/html.c + +${CONFIG_TEST}: tests/unit/config.c \ + src/config.c \ + src/config.h \ + src/common.c \ + src/common.h + mkdir -p ${BUILDDIR} + ${CC} ${CFLAGS} -o $@ \ + tests/unit/config.c \ + src/config.c \ + src/common.c + +${MULTIPART_TEST}: tests/unit/multipart.c \ + src/multipart.c \ + src/multipart.h \ + src/config.c \ + src/config.h \ + src/http.c \ + src/http.h \ + src/common.c \ + src/common.h \ + src/html.c \ + src/html.h + mkdir -p ${BUILDDIR} + ${CC} ${CFLAGS} -o $@ \ + tests/unit/multipart.c \ + src/multipart.c \ + src/config.c \ + src/http.c \ + src/common.c \ + src/html.c + +test-unit: ${UNIT_TESTS} + @for test in ${UNIT_TESTS}; do \ + echo "==> $$test"; \ + ./$$test || exit 1; \ + done + +# +# Regression tests +# + +test-regression: ${CGI_TARGETS} + sh ${UPLOAD_REGRESSION} ${UPLOAD_CGI} + sh ${LIST_REGRESSION} ${LIST_CGI} + sh ${DELETE_REGRESSION} ${DELETE_CGI} + sh ${DOWNLOAD_REGRESSION} ${DOWNLOAD_CGI} + sh ${RENAME_REGRESSION} ${RENAME_CGI} + +# +# All tests +# + +test: test-unit test-regression + +# +# Test root +# + +prepare-testroot: + mkdir -p \ + ${HTDOCS} \ + ${CGIBIN} \ + $(ETCDIR) \ + ${RUN} \ + ${LOGS} \ + ${STORAGEROOT} \ + ${UPLOADDIR} \ + ${LOCKSDIR} \ + ${TEMPLATE_ROOT} \ + ${AUTHDIR} + +testroot: ${CGI_TARGETS} prepare-testroot + install -m 755 ${UPLOAD_CGI} ${CGIBIN}/upload.cgi + install -m 755 ${LIST_CGI} ${CGIBIN}/list.cgi + install -m 755 ${DELETE_CGI} ${CGIBIN}/delete.cgi + install -m 755 ${DOWNLOAD_CGI} ${CGIBIN}/download.cgi + install -m 755 ${RENAME_CGI} ${CGIBIN}/rename.cgi + + printf '%s\n' \ + 'CSRF_ORIGIN=${TEST_CSRF_ORIGIN}' \ + > ${GLOBAL_CONFIG} + chmod 644 ${GLOBAL_CONFIG} + + chmod 755 ${TESTROOT} + chmod 755 ${HTDOCS} + chmod 755 ${CGIBIN} + chmod 755 ${RUN} + chmod 755 ${LOGS} + chmod 755 ${STORAGEROOT} + chmod 755 ${UPLOADDIR} + chmod 755 ${LOCKSDIR} + chmod 755 ${TEMPLATE_ROOT} + chmod 755 ${AUTHDIR} + + mkdir -p ${HTDOCS}/default + chmod 755 ${HTDOCS}/default + + mkdir -p ${STORAGEROOT}/default + mkdir -p ${TEMPLATE_ROOT} + + ln -sf ../../cgi-bin/list.cgi \ + ${HTDOCS}/default/list.cgi + + ln -sf ../../cgi-bin/upload.cgi \ + ${HTDOCS}/default/upload.cgi + + ln -sf ../../cgi-bin/delete.cgi \ + ${HTDOCS}/default/delete.cgi + + ln -sf ../../cgi-bin/download.cgi \ + ${HTDOCS}/default/download.cgi + + ln -sf ../../cgi-bin/rename.cgi \ + ${HTDOCS}/default/rename.cgi + + install -m 644 css/style.css \ + ${HTDOCS}/default/style.css + + install -m 644 ${UPLOAD_TEMPLATE} \ + ${TEMPLATE_ROOT}/upload.html.template + + install -m 644 ${DELETE_TEMPLATE} \ + ${TEMPLATE_ROOT}/delete.html.template + + install -m 644 ${LIST_TEMPLATE} \ + ${TEMPLATE_ROOT}/list.html.template + + install -m 644 ${RENAME_TEMPLATE} \ + ${TEMPLATE_ROOT}/rename.html.template + + install -m 644 ${LIST_READONLY_TEMPLATE} \ + ${TEMPLATE_ROOT}/list-readonly.html.template + + install -m 644 ${AUTH_FILE} ${TESTROOT}/auth/bob + printf "%s\n" "ADMIN_USER=bob" > "${TESTROOT}/storage/default/.config" +# +# Housekeeping +# + +clean: + rm -rf ${BUILDDIR} + +cleantestroot: + rm -rf ${TESTROOT} + +# +# Deployment Package +# + +dist: clean build + echo "Creating deployment package: ${DIST_ARCHIVE}" + mkdir -p ${DIST_DIR} + + cp ${UPLOAD_CGI} ${DIST_DIR}/ + cp ${LIST_CGI} ${DIST_DIR}/ + cp ${DELETE_CGI} ${DIST_DIR}/ + cp ${DOWNLOAD_CGI} ${DIST_DIR}/ + cp ${RENAME_CGI} ${DIST_DIR}/ + + cp css/style.css ${DIST_DIR}/ + + cp ${UPLOAD_TEMPLATE} ${DIST_DIR}/ + cp ${DELETE_TEMPLATE} ${DIST_DIR}/ + cp ${LIST_TEMPLATE} ${DIST_DIR}/ + cp ${RENAME_TEMPLATE} ${DIST_DIR}/ + cp ${LIST_READONLY_TEMPLATE} ${DIST_DIR}/ + + cd ${DIST_DIR} && tar -czf ../${DIST_ARCHIVE} . + +clean-dist: + rm -rf ${DIST_DIR} ${DIST_ARCHIVE} diff --git a/README.md b/README.md @@ -0,0 +1,220 @@ +# SimpleFiles + +SimpleFiles is a lightweight C CGI web application for managing files stored in +separate filesystem-backed repositories. + +It provides a browser-based interface for browsing, uploading, downloading, +renaming, and deleting files. + +The current reference deployment uses OpenBSD `httpd` with `slowcgi`. Other +CGI-capable web servers may work, but have not yet been tested. + +> **Security status:** SimpleFiles is currently pre-release software and +> is undergoing security hardening. It should not yet be considered suitable +> for production deployments where files are exposed to untrusted users or +> hostile input. +> +> Known areas still under review include concurrent upload disk-space +> accounting, strict multipart parsing, filesystem TOCTOU handling, and the +> filesystem trust model. See `SECURITY.md` for details. + +## Concepts + +A **repository** is a directory managed by SimpleFiles that contains uploaded +files and optional repository-specific configuration. + +Repositories are stored below the configured storage root: + +```text +/storage/ +└── default/ +``` + +SimpleFiles does not provide user authentication itself. Authentication is +expected to be handled by the web server, which may provide the authenticated +username through `REMOTE_USER`. + +## How to install + +SimpleFiles requires a CGI-capable web server and a POSIX-compatible +environment. + +The following installation layout describes the filesystem as seen by the CGI +application. When using a web server with a chroot environment, such as the +default OpenBSD `httpd` setup, these paths must exist inside that environment. + +### 1. Create the directories + +The default installation uses: + +| Path | Purpose | CGI user | +| --- | --- | --- | +| `/storage` | Repository files | read/write | +| `/simplefiles` | HTML templates | read | +| `/run/simplefiles/lock` | Repository locks | read/write | +| `/run/simplefiles/uploads` | Temporary uploads | read/write | +| `/etc/simplefiles.conf` | Global configuration | read | + +For example, if CGI runs as `www-data`: + +```sh +mkdir -p /storage +mkdir -p /simplefiles +mkdir -p /run/simplefiles/lock +mkdir -p /run/simplefiles/uploads + +chown www-data:www-data /storage +chown www-data:www-data /run/simplefiles/lock +chown www-data:www-data /run/simplefiles/uploads + +chmod 750 /storage +chmod 755 /simplefiles +chmod 750 /run/simplefiles/lock +chmod 750 /run/simplefiles/uploads +``` + +The upload directory must not be group- or world-writable. + +### 2. Install the templates + +Copy these files into `/simplefiles`: + +```text +upload.html.template +delete.html.template +list.html.template +list-readonly.html.template +rename.html.template +``` + +They only need to be readable by the CGI user. + +### 3. Build and install the CGI programs + +Compile the C sources and install the resulting CGI programs in a directory +configured for CGI execution by the web server. + +The source distribution does not currently include a Makefile or web-server +configuration examples. + +The CGI programs must run as an unprivileged user with the permissions described +above. + +## Reference deployment + +SimpleFiles is currently developed and tested with: + +- OpenBSD `httpd` +- OpenBSD `slowcgi` +- CGI execution within the web server's runtime filesystem environment + +OpenBSD `httpd` normally uses a chroot environment. When using this setup, all +SimpleFiles paths, including storage, templates, configuration, locks, and +temporary uploads, must be available inside the chroot environment. + +## Configuration + +Create: + +```text +/etc/simplefiles.conf +``` + +with at least: + +```text +CSRF_ORIGIN=https://files.example.com +``` + +Replace the URL with the origin used to access the application. + +### Global configuration + +`/etc/simplefiles.conf`: + +| Setting | Description | +| --- | --- | +| `CSRF_ORIGIN` | Required origin for modifying POST requests | + +The configuration file can be relocated with `SIMPLEFILES_CONFIG`. + +### Repository configuration + +`/storage/<repository>/.config`: + +| Setting | Default | Description | +| --- | ---: | --- | +| `MAX_UPLOAD` | `104857600` | Maximum upload size in bytes | +| `QUOTA` | `5368709120` | Maximum repository size in bytes | +| `READ_ONLY` | `0` | Set to `1` to disable modifications | +| `ADMIN_USER` | unset | Restrict administration to this `REMOTE_USER` | + +Example: + +```text +MAX_UPLOAD=52428800 +QUOTA=1073741824 +ADMIN_USER=alice +``` + +## Runtime settings + +The default paths can be changed with environment variables when supported by +the CGI server: + +| Variable | Default | +| --- | --- | +| `STORAGE_ROOT` | `/storage` | +| `SIMPLEFILES_ROOT` | `/simplefiles` | +| `REPOSITORY_LOCK_DIR` | `/run/simplefiles/lock` | +| `UPLOAD_TMP_DIR` | `/run/simplefiles/uploads` | +| `SIMPLEFILES_CONFIG` | `/etc/simplefiles.conf` | + +The following limits can also be overridden: + +| Variable | Default | +| --- | ---: | +| `REPO_QUOTA_BYTES` | repository `QUOTA` | +| `MIN_FREE_BYTES` | `1073741824` | + +> Note: OpenBSD `httpd` does not provide a way to configure arbitrary CGI +environment variables. On OpenBSD, use the default paths or provide a wrapper +mechanism if custom values are required. + +## Web server + +Configure the CGI programs with the web server and ensure it supplies: + +```text +REQUEST_METHOD +SCRIPT_NAME +QUERY_STRING +CONTENT_TYPE +CONTENT_LENGTH +HTTP_ORIGIN +REMOTE_USER +``` + +`REMOTE_USER` is only needed when `ADMIN_USER` is configured. + +Do not expose `/storage` as a static web directory. + +## Security + +For a production deployment: + +- Run CGI as a dedicated, unprivileged user. +- Keep `/storage` inaccessible as a static web directory. +- Keep `/run/simplefiles/uploads` non-world-writable. +- Configure `CSRF_ORIGIN`. +- Use the web server for authentication when `ADMIN_USER` is used. + +Repositories are trusted filesystem locations. SimpleFiles does not currently +protect against a malicious local user modifying repository contents outside the +application. + +## Default limits + +- Maximum upload: **100 MiB** +- Repository quota: **5 GiB** +- Minimum free space: **1 GiB** diff --git a/conf/bob b/conf/bob @@ -0,0 +1 @@ +bob:$2b$08$hs6U/we7.j7JaXgBkDn3suR7sP1d7s04lwGiQraNPZISEwy7j9OPa diff --git a/conf/httpd-test.conf b/conf/httpd-test.conf @@ -0,0 +1,19 @@ +chroot "testroot" + +server "local" { + listen on localhost port 8000 + root "/htdocs" + + authenticate with "auth/bob" + + connection max request body 524288000 + + location "/default/" { + request rewrite "/default/list.cgi" + } + + location "/*.cgi" { + fastcgi socket "/run/slowcgi.sock" + } + +} diff --git a/css/style.css b/css/style.css @@ -0,0 +1,138 @@ +:root { + --bg: #f7f7f9; + --fg: #222; + --muted: #666; + --accent: #2563eb; + --accent-hover: #1d4ed8; + --border: #ddd; + --danger: #b91c1c; + --danger-hover: #991b1b; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + padding: 1.25rem; + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: var(--fg); + background: var(--bg); + line-height: 1.5; +} + +h1 { + margin: 0 0 1rem 0; + font-size: 1.5rem; +} + +a { + color: var(--accent); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +nav { + margin-bottom: 1rem; +} + +nav a { + margin-right: 0.75rem; +} + +/* Forms */ +form { + margin: 1rem 0; +} + +input[type="file"], +input[type="text"], +input[type="password"] { + padding: 0.4rem 0.5rem; + border: 1px solid var(--border); + border-radius: 4px; + background: #fff; + color: var(--fg); +} + +input[type="submit"], +button { + padding: 0.4rem 0.8rem; + border: none; + border-radius: 4px; + background: var(--accent); + color: #fff; + cursor: pointer; +} + +input[type="submit"]:hover, +button:hover { + background: var(--accent-hover); +} + +/* Upload progress */ +#progressContainer { + margin-top: 1em; + display: none; +} + +#progressBar { + width: 100%; + height: 20px; +} + +#status { + margin-top: 0.25em; + font-size: 0.9em; + color: var(--muted); +} + +/* File table */ +table { + width: 100%; + border-collapse: collapse; + margin-top: 1rem; + background: #fff; + border: 1px solid var(--border); +} + +th, td { + padding: 0.5rem 0.6rem; + border-bottom: 1px solid var(--border); + text-align: left; + vertical-align: middle; +} + +th { + background: #f3f4f6; + font-weight: 600; +} + +tr:last-child td { + border-bottom: none; +} + +/* Actions column (Delete etc.) */ +td.actions a { + padding-right: 0.5rem; + color: var(--danger); +} + +td.actions a:hover { + color: var(--danger-hover); + text-decoration: underline; +} + +/* Small helpers */ +.muted { + color: var(--muted); +} + +.error { + color: var(--danger); + margin: 0.5rem 0; +} diff --git a/html/delete.html.template b/html/delete.html.template @@ -0,0 +1,22 @@ +<!doctype html> +<html> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <link rel="stylesheet" href="style.css"> +</head> +<body> + +<h1>SimpleFiles</h1> + +<p>Delete <strong>{{FILENAME}}</strong>?</p> + +<form method="post" action="delete.cgi"> + <input type="hidden" name="file" value="{{FILENAME}}"> + <input type="submit" value="Delete"> +</form> + +<p><a href="{{CANCEL_URI}}">Cancel</a></p> + +</body> +</html> diff --git a/html/index.html b/html/index.html @@ -0,0 +1,10 @@ +<!doctype html> +<html> +<head> + <meta charset="utf-8"> + <title>SimpleFiles</title> +</head> +<body> + <h1>It works</h1> +</body> +</html> diff --git a/html/list-readonly.html.template b/html/list-readonly.html.template @@ -0,0 +1,37 @@ +<!doctype html> + +<html> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <link rel="stylesheet" href="style.css"> +</head> +<body> + +<h1>SimpleFiles</h1> + +<table> + <tr> + <th>Name</th> + <th>Size</th> + <th>Modified</th> + <th>Actions</th> + </tr> + +{{#FILES}} + <tr> + <td> + <a href="download.cgi?file={{URL}}">{{NAME}}</a> + </td> + <td>{{SIZE}}</td> + <td>{{MODIFIED}}</td> + <td class="actions"> + <a href="download.cgi?file={{URL}}">Download</a> + </td> + </tr> +{{/FILES}} + +</table> + +</body> +</html> diff --git a/html/list.html.template b/html/list.html.template @@ -0,0 +1,41 @@ +<!doctype html> + +<html> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <link rel="stylesheet" href="style.css"> +</head> +<body> + +<h1>SimpleFiles</h1> + +<p><a href="upload.cgi">Upload file</a></p> + +<table> + <tr> + <th>Name</th> + <th>Size</th> + <th>Modified</th> + <th>Actions</th> + </tr> + +{{#FILES}} + <tr> + <td> + <a href="download.cgi?file={{URL}}">{{NAME}}</a> + </td> + <td>{{SIZE}}</td> + <td>{{MODIFIED}}</td> + <td class="actions"> + <a href="download.cgi?file={{URL}}">Download</a> + <a href="delete.cgi?file={{URL}}">Delete</a> + <a href="rename.cgi?file={{URL}}">Rename</a> + </td> + </tr> +{{/FILES}} + +</table> + +</body> +</html> diff --git a/html/rename.html.template b/html/rename.html.template @@ -0,0 +1,44 @@ +<!doctype html> + +<html> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <link rel="stylesheet" href="style.css"> +</head> +<body> + +<h1>SimpleFiles</h1> + +<p>Rename <strong>{{FILENAME}}</strong>?</p> + +<form method="post"> + + <input type="hidden" + name="file" + value="{{FILENAME}}"> + + <label for="new_filename"> + New filename: + </label> + + <input type="text" + id="new_filename" + name="new_filename" + value="{{FILENAME}}" + required> + + <button type="submit"> + Rename + </button> + +</form> + +<p> + <a href="{{CANCEL_URI}}"> + Cancel + </a> +</p> + +</body> +</html> diff --git a/html/upload.html.template b/html/upload.html.template @@ -0,0 +1,114 @@ +<!doctype html> +<html> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>SimpleFiles</title> + <link rel="stylesheet" href="style.css"> +</head> +<body> + <h1>SimpleFiles</h1> + + <p>Upload a file (max {{MAX_UPLOAD}}, total limit {{REPO_QUOTA}})</p> + + <nav> + <a href="./">Back to files</a> + </nav> + + <form id="uploadForm" + method="post" + action="upload.cgi" + enctype="multipart/form-data"> + + <p> + <input type="file" name="file" id="fileInput" required> + </p> + + <p> + <input type="submit" value="Upload"> + </p> + + </form> + + <div id="progressContainer" style="display: none"> + <progress id="progressBar" value="0" max="100"></progress> + <div id="status">0%</div> + </div> + + <script> + const MAX_UPLOAD = {{MAX_UPLOAD_BYTES}}; + + document.getElementById('uploadForm').addEventListener( + 'submit', + function (e) { + e.preventDefault(); + + const fileInput = + document.getElementById('fileInput'); + + if (!fileInput.files.length) + return; + + const file = fileInput.files[0]; + + const progressContainer = + document.getElementById('progressContainer'); + + const progressBar = + document.getElementById('progressBar'); + + const status = + document.getElementById('status'); + + if (file.size > MAX_UPLOAD) { + progressContainer.style.display = 'block'; + status.textContent = + 'File exceeds {{MAX_UPLOAD}} limit'; + return; + } + + const formData = new FormData(); + + formData.append('file', file); + + progressContainer.style.display = 'block'; + progressBar.value = 0; + status.textContent = '0%'; + + const xhr = new XMLHttpRequest(); + + xhr.open('POST', 'upload.cgi', true); + + xhr.upload.onprogress = function (event) { + if (event.lengthComputable) { + const percent = + Math.round( + (event.loaded / event.total) * 100 + ); + + progressBar.value = percent; + status.textContent = percent + '%'; + } + }; + + xhr.onload = function () { + if (xhr.status >= 200 && xhr.status < 300) { + status.textContent = 'Upload complete'; + window.location.href = './'; + } else { + status.textContent = + 'Upload failed (HTTP ' + xhr.status + ')'; + } + }; + + xhr.onerror = function () { + status.textContent = + 'Upload failed (network error)'; + }; + + xhr.send(formData); + } + ); + </script> +</body> +</html> diff --git a/scripts/dev-start.sh b/scripts/dev-start.sh @@ -0,0 +1,30 @@ +#!/bin/sh + +set -e + +ROOT="$(pwd)" + +SOCK="$ROOT/testroot/run/slowcgi.sock" +CHROOT="$ROOT/testroot" + +if [ ! -d "$CHROOT" ]; then + echo "Missing testroot. Run make testroot first." + exit 1 +fi + +tmux new-session -d -s simpleupload + +tmux rename-window -t simpleupload:0 slowcgi +tmux send-keys -t simpleupload:0 \ + "doas /usr/sbin/slowcgi -u dev -dvvv -s $SOCK -p $CHROOT/" C-m + +tmux split-window -h -t simpleupload:0 + +tmux send-keys -t simpleupload:0.1 \ + "doas /usr/sbin/httpd -dvvv -f conf/httpd-test.conf" C-m + +if [ -n "$TMUX" ]; then + tmux switch-client -t simpleupload +else + tmux attach-session -t simpleupload +fi diff --git a/src/common.c b/src/common.c @@ -0,0 +1,237 @@ +#include <ctype.h> +#include <errno.h> +#include <fcntl.h> +#include <limits.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/file.h> +#include <unistd.h> + +#include "common.h" +#include "config.h" +#include "html.h" + +#define REPOSITORY_MAXLEN 255 + +static int +repository_from_script_name(const char *script_name, + char *repository, + size_t len); + +static int valid_repository(const char *); + +int +build_repository_path(char *path, + size_t len, + const char *storage_root, + const char *repository) +{ + if (snprintf(path, + len, + "%s/%s", + storage_root, + repository) >= (int) len) + return -1; + + return 0; +} + +int +build_storage_path(char *path, + size_t len, + const char *storage_root, + const char *repository, + const char *filename) +{ + if (snprintf(path, len, + "%s/%s/%s", + storage_root, + repository, + filename) >= (int) len) + return -1; + + return 0; +} + +const char * +storage_root(void) +{ + const char *root; + + root = getenv("STORAGE_ROOT"); + if (root != NULL) + return root; + + return DEFAULT_STORAGE_ROOT; +} + +const char * +repository_lock_dir(void) +{ + const char *dir; + + dir = getenv("REPOSITORY_LOCK_DIR"); + if (dir != NULL && *dir != '\0') + return dir; + + return DEFAULT_REPOSITORY_LOCK_DIR; +} + +int +repository_lock(const char *repo) +{ + char path[PATH_MAX]; + int fd; + + if (repo == NULL || !valid_repository(repo)) + return -1; + + if (snprintf(path, sizeof(path), "%s/%s", + repository_lock_dir(), repo) >= (int) sizeof(path)) + return -1; + + fd = open(path, O_RDWR | O_CREAT | O_CLOEXEC | O_NOFOLLOW, 0600); + if (fd == -1) + return -1; + + if (flock(fd, LOCK_EX) == -1) { + close(fd); + return -1; + } + return fd; +} + +void +repository_unlock(int fd) +{ + if (fd != -1) { + flock(fd, LOCK_UN); + close(fd); + } +} + +const char * +repository(void) +{ + static char repository[REPOSITORY_MAXLEN + 1]; + + if (repository_from_script_name( + getenv("SCRIPT_NAME"), + repository, + sizeof(repository)) == -1) + return DEFAULT_REPOSITORY; + + if (!valid_repository(repository)) + return DEFAULT_REPOSITORY; + + return repository; +} + +int +valid_repository(const char *repository) +{ + const unsigned char *p; + + if (repository == NULL || *repository == '\0') + return 0; + + for (p = (const unsigned char *) repository; *p != '\0'; p++) { + if (!isalnum(*p) && + *p != '_' && + *p != '-') + return 0; + } + + return 1; +} + +static int +repository_from_script_name(const char *script_name, + char *repository, + size_t len) +{ + const char *start; + const char *end; + size_t namelen; + + if (script_name == NULL) + return -1; + + if (script_name[0] != '/') + return -1; + + start = script_name + 1; + if (*start == '\0') + return -1; + + end = strchr(start, '/'); + if (end == NULL) + return -1; + + namelen = (size_t) (end - start); + if (namelen == 0) + return -1; + + if (namelen >= len) + return -1; + + memcpy(repository, start, namelen); + repository[namelen] = '\0'; + + return 0; +} + +int +valid_filename(const char *filename) +{ + const unsigned char *p; + + if (filename == NULL || *filename == '\0') + return 0; + + if (strcmp(filename, CONFIG_FILENAME) == 0) + return 0; + + if (strncmp(filename, ".upload-", 8) == 0) + return 0; + + if (strcmp(filename, ".") == 0 || + strcmp(filename, "..") == 0) + return 0; + + for (p = (const unsigned char *) filename; *p != '\0'; p++) { + if (*p == '/' || + *p == '\\' || + *p == '"' || + *p == '\r' || + *p == '\n') + return 0; + + if (iscntrl(*p)) + return 0; + } + + return 1; +} + +int +check_csrf_origin(void) +{ + const char *origin; + char expected[CSRF_ORIGIN_MAX]; + + if (global_config_csrf_origin( + expected, sizeof(expected)) == -1) + return -2; + + origin = getenv("HTTP_ORIGIN"); + + if (origin == NULL) + return -1; + + if (strcmp(origin, expected) != 0) + return -1; + + return 0; +} diff --git a/src/common.h b/src/common.h @@ -0,0 +1,32 @@ +#ifndef COMMON_H +#define COMMON_H + +#include <stdio.h> + +#include "config.h" + +#if DEBUG +#define DPRINTF(fmt, ...) \ + fprintf(stderr, "DEBUG: " fmt, ##__VA_ARGS__) +#else +#define DPRINTF(fmt, ...) \ + do { } while (0) +#endif + +void reply(const char *, const char *); +void redirect(const char *); + +int build_repository_path(char *path, size_t len, const char *storage_root, const char *repository); +int build_storage_path(char *path, size_t len, const char *storage_root, const char *repository, const char *filename); + +const char *storage_root(void); +const char *repository(void); +const char *repository_lock_dir(void); +int valid_filename(const char *); + +int repository_lock(const char *repository); +void repository_unlock(int); + +int check_csrf_origin(void); + +#endif diff --git a/src/config.c b/src/config.c @@ -0,0 +1,309 @@ +#include <ctype.h> +#include <errno.h> +#include <limits.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +#include "common.h" +#include "config.h" + +static int +build_config_path(char *path, size_t len) +{ + char repository_path[PATH_MAX]; + + if (build_repository_path(repository_path, + sizeof(repository_path), + storage_root(), + repository()) == -1) + return -1; + + if (snprintf(path, len, "%s/%s", repository_path, CONFIG_FILENAME) >= + (int) len) + return -1; + + return 0; +} + +static off_t +config_value(const char *name, off_t default_value) +{ + char path[PATH_MAX]; + char line[256]; + FILE *fp; + size_t namelen; + + if (build_config_path(path, sizeof(path)) == -1) + return default_value; + + fp = fopen(path, "r"); + if (fp == NULL) + return default_value; + + namelen = strlen(name); + + while (fgets(line, sizeof(line), fp) != NULL) { + char *p; + char *end; + char *value; + char *ep; + long long parsed; + + /* Strip newline. */ + line[strcspn(line, "\r\n")] = '\0'; + + /* Skip leading whitespace. */ + p = line; + while (isspace((unsigned char) *p)) + p++; + + /* Ignore blank lines and comments. */ + if (*p == '\0' || *p == '#') + continue; + + /* + * Match: + * + * NAME=value + * + * and allow whitespace around the '='. + */ + if (strncmp(p, name, namelen) != 0) + continue; + + end = p + namelen; + + if (*end != '=' && !isspace((unsigned char) *end)) + continue; + + while (isspace((unsigned char) *end)) + end++; + + if (*end != '=') + continue; + + end++; + while (isspace((unsigned char) *end)) + end++; + + value = end; + + /* Trim trailing whitespace. */ + end = value + strlen(value); + while (end > value && + isspace((unsigned char) end[-1])) + end--; + *end = '\0'; + + if (*value == '\0') + continue; + + errno = 0; + parsed = strtoll(value, &ep, 10); + + if (errno != 0 || ep == value || *ep != '\0') + continue; + + if (parsed <= 0) + continue; + + fclose(fp); + return (off_t) parsed; + } + + fclose(fp); + return default_value; +} + +static int +config_string_file(const char *path, + const char *name, + char *value, + size_t value_len) +{ + char line[256]; + FILE *fp; + size_t namelen; + + if (path == NULL || name == NULL || + value == NULL || value_len == 0) + return -1; + + fp = fopen(path, "r"); + if (fp == NULL) + return -1; + + namelen = strlen(name); + + while (fgets(line, sizeof(line), fp) != NULL) { + char *p; + char *end; + char *v; + size_t len; + + line[strcspn(line, "\r\n")] = '\0'; + + p = line; + while (isspace((unsigned char) *p)) + p++; + + if (*p == '\0' || *p == '#') + continue; + + if (strncmp(p, name, namelen) != 0) + continue; + + end = p + namelen; + + if (*end != '=' && !isspace((unsigned char) *end)) + continue; + while (isspace((unsigned char) *end)) + end++; + + if (*end != '=') + continue; + + end++; + while (isspace((unsigned char) *end)) + end++; + + v = end; + + end = v + strlen(v); + while (end > v && + isspace((unsigned char) end[-1])) + end--; + + *end = '\0'; + + if (*v == '\0') + continue; + + len = strlen(v); + + if (len >= value_len) { + fclose(fp); + return -1; + } + memcpy(value, v, len + 1); + + fclose(fp); + return 0; + } + + fclose(fp); + return -1; +} + +static const char * +global_config_filename(void) +{ + const char *path; + + path = getenv("SIMPLEFILES_CONFIG"); + + if (path != NULL && *path != '\0') + return path; + + return GLOBAL_CONFIG_FILENAME; +} + +int +global_config_string(const char *name, + char *value, + size_t value_len) +{ + return config_string_file( + global_config_filename(), + name, + value, + value_len); +} + +int +global_config_csrf_origin(char *origin, + size_t origin_len) +{ + return global_config_string( + "CSRF_ORIGIN", + origin, + origin_len); +} + +const char * +simplefiles_root(void) +{ + const char *root; + + root = getenv("SIMPLEFILES_ROOT"); + if (root != NULL && *root != '\0') + return root; + + return SIMPLEFILES_ROOT_DEFAULT; +} +off_t +config_max_upload(void) +{ + return config_value("MAX_UPLOAD", DEFAULT_MAX_UPLOAD); +} +off_t +config_repo_quota(void) +{ + return config_value("QUOTA", DEFAULT_REPO_QUOTA); +} + +int +config_write_allowed(void) +{ + char path[PATH_MAX]; + char value[16]; + char admin[64]; + const char *remote_user; + + if (build_config_path(path, sizeof(path)) == -1) + return 0; + + if (config_string_file(path, + "READ_ONLY", + value, + sizeof(value)) == 0 && + strcmp(value, "1") == 0) + return 0; + + if (config_string_file(path, + "ADMIN_USER", + admin, + sizeof(admin)) == -1) + return 0; + + remote_user = getenv("REMOTE_USER"); + + if (remote_user == NULL || *remote_user == '\0') + return 0; + + return strcmp(remote_user, admin) == 0; +} + +int +config_access_allowed(void) +{ + char value[16]; + const char *user; + + user = getenv("REMOTE_USER"); + + /* + * Authenticated users may read. + */ + if (user != NULL && *user != '\0') + return 1; + + /* Anonymous read must be explicitly enabled. */ + if (global_config_string("ANONYMOUS_READ", + value, sizeof(value)) == 0 && + strcmp(value, "1") == 0) + return 1; + + return 0; +} diff --git a/src/config.h b/src/config.h @@ -0,0 +1,40 @@ +#ifndef CONFIG_H +#define CONFIG_H + +#include <stddef.h> +#include <sys/types.h> + +#define DEFAULT_REDIRECT_URI "./" +#define DEFAULT_STORAGE_ROOT "/storage" +#define DEFAULT_REPOSITORY "default" +#define DEFAULT_REPOSITORY_LOCK_DIR "/run/simplefiles/lock" + +#define SIMPLEFILES_ROOT_DEFAULT "/simplefiles" +#define GLOBAL_CONFIG_FILENAME "/etc/simplefiles.conf" + +#define UPLOAD_TEMPLATE_NAME "upload.html.template" +#define DELETE_TEMPLATE_NAME "delete.html.template" +#define LIST_TEMPLATE_NAME "list.html.template" +#define LIST_READONLY_TEMPLATE_NAME "list-readonly.html.template" +#define RENAME_TEMPLATE_NAME "rename.html.template" + +#define DEFAULT_MAX_UPLOAD (100LL * 1024 * 1024) +#define DEFAULT_REPO_QUOTA (5LL * 1024 * 1024 * 1024) +#define DEFAULT_MIN_FREE (1LL * 1024 * 1024 * 1024) + +#define DEBUG 0 + +#define CONFIG_FILENAME ".config" +#define CSRF_ORIGIN_MAX 256 + +const char *simplefiles_root(void); +off_t config_max_upload(void); +off_t config_repo_quota(void); + +int global_config_string(const char *, char *, size_t); +int global_config_csrf_origin(char *, size_t); + +int config_write_allowed(void); +int config_access_allowed(void); + +#endif diff --git a/src/delete.c b/src/delete.c @@ -0,0 +1,191 @@ +#include <errno.h> +#include <limits.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/stat.h> +#include <unistd.h> + +#include "common.h" +#include "config.h" +#include "html.h" +#include "http.h" +#include "request.h" +#include "url.h" + +static int do_get(void); +static int do_post(void); + +static int +do_get(void) +{ + char filename[NAME_MAX + 1]; + char path[PATH_MAX]; + struct stat st; + char template[PATH_MAX]; + + if (!config_write_allowed()) { + reply("403 Forbidden", "Repository is read-only"); + return 0; + } + if (get_filename(getenv("QUERY_STRING"), filename, sizeof(filename)) == -1) + return 0; + + if (build_storage_path(path, + sizeof(path), + storage_root(), + repository(), + filename) == -1) { + reply("500 Internal Server Error", + "Path too long"); + return 1; + } + if (stat(path, &st) == -1 || !S_ISREG(st.st_mode)) { + reply("404 Not Found", "File not found"); + return 0; + } + struct html_var vars[] = { + {"FILENAME", filename}, + {"CANCEL_URI", DEFAULT_REDIRECT_URI}, + }; + + if (snprintf(template, sizeof(template), + "%s/%s", + simplefiles_root(), + DELETE_TEMPLATE_NAME) >= (int) sizeof(template)) { + reply("500 Internal Server Error", "Template path too long"); + return 0; + } + if (render_template_response(template, + vars, sizeof(vars) / sizeof(vars[0]), + NULL, 0) == -1) { + fprintf(stderr, "render_template failed: %s\n", template); + reply("500 Internal Server Error", "Template error"); + return 0; + } + return 0; +} + +static int +do_post(void) +{ + const char *p; + char *body; + char filename[NAME_MAX + 1]; + char path[PATH_MAX]; + char *end; + long len; + int rc; + int lock_fd; + + if (!config_write_allowed()) { + reply("403 Forbidden", "Repository is read-only"); + return 0; + } + rc = check_csrf_origin(); + + if (rc == -2) { + reply("500 Internal Server Error", + "CSRF origin is not configured"); + return 0; + } + if (rc == -1) { + reply("403 Forbidden", "Forbidden"); + return 0; + } + p = getenv("CONTENT_LENGTH"); + if (p == NULL) { + reply("400 Bad Request", + "Missing content length"); + return 0; + } + errno = 0; + len = strtol(p, &end, 10); + + if (errno != 0 || + end == p || + *end != '\0' || + len <= 0 || + len > 1024) { + reply("400 Bad Request", + "Invalid content length"); + return 0; + } + body = malloc((size_t) len + 1); + if (body == NULL) { + reply("500 Internal Server Error", + "Out of memory"); + return 0; + } + if (fread(body, 1, (size_t) len, stdin) != (size_t) len) { + free(body); + reply("400 Bad Request", + "Could not read request"); + return 0; + } + body[len] = '\0'; + + if (get_filename(body, filename, sizeof(filename)) == -1) { + free(body); + return 0; + } + free(body); + body = NULL; + + if (build_storage_path(path, + sizeof(path), + storage_root(), + repository(), + filename) == -1) { + + reply("500 Internal Server Error", + "Path too long"); + + return 0; + } + lock_fd = repository_lock(repository()); + + if (lock_fd == -1) { + reply("500 Internal Server Error", + "Could not lock repository"); + return 0; + } + if (unlink(path) == -1) { + if (errno == ENOENT) { + reply("404 Not Found", + "File not found"); + } else { + reply("500 Internal Server Error", + "Could not delete file"); + } + repository_unlock(lock_fd); + return 0; + } + repository_unlock(lock_fd); + redirect(DEFAULT_REDIRECT_URI); + + return 0; +} + +int +main(void) +{ + const char *method; + + method = getenv("REQUEST_METHOD"); + + if (method == NULL) { + reply("400 Bad Request", "Missing request method"); + return 0; + } + if (strcmp(method, "GET") == 0) + return do_get(); + + if (strcmp(method, "POST") == 0) + return do_post(); + + reply("405 Method Not Allowed", + "GET or POST required"); + + return 0; +} diff --git a/src/download.c b/src/download.c @@ -0,0 +1,68 @@ +#include <limits.h> +#include <sys/stat.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <unistd.h> + +#include "common.h" +#include "config.h" +#include "http.h" +#include "request.h" +#include "url.h" + +int +main(void) +{ + const char *method; + char filename[NAME_MAX + 1]; + char path[PATH_MAX]; + struct stat st; + FILE *fp; + char buffer[8192]; + size_t n; + + method = getenv("REQUEST_METHOD"); + if (method == NULL || strcmp(method, "GET") != 0) { + reply("405 Method Not Allowed", "GET required"); + return 0; + } + + if (!config_access_allowed()) { + reply("403 Forbidden", "Forbidden"); + return 0; + } + + if (get_filename(getenv("QUERY_STRING"), filename, sizeof(filename)) == -1) + return 0; + if (build_storage_path(path, + sizeof(path), + storage_root(), + repository(), + filename) == -1) { + reply("500 Internal Server Error", "Path too long"); + return 0; + } + if (lstat(path, &st) == -1 || !S_ISREG(st.st_mode)) { + reply("404 Not Found", "File not found"); + return 0; + } + if (download_headers(filename, st.st_size) == -1) { + reply("400 Bad Request", "Invalid filename"); + return 0; + } + + fp = fopen(path, "rb"); + if (fp == NULL) { + reply("500 Internal Server Error", "Could not open file"); + return 0; + } + while ((n = fread(buffer, 1, sizeof(buffer), fp)) > 0) { + if (fwrite(buffer, 1, n, stdout) != n) { + break; + } + } + + fclose(fp); + return 0; +} diff --git a/src/html.c b/src/html.c @@ -0,0 +1,361 @@ +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <unistd.h> + +#include "html.h" + +static const char * +find_variable(const char *, + const struct html_var *, size_t, + const struct html_var *, size_t); + +static int +render_text(FILE *, + const char *, + size_t, + const struct html_var *, + size_t, + const struct html_var *, + size_t); + +static const struct html_block * +find_block(const char *, const struct html_block *, size_t); +static const char * +find_marker(const char *, size_t, const char[2]); + +void +html_escape(FILE * fp, const char *s) +{ + unsigned char c; + + while ((c = (unsigned char) *s++) != '\0') { + switch (c) { + case '&': + fputs("&amp;", fp); + break; + + case '<': + fputs("&lt;", fp); + break; + + case '>': + fputs("&gt;", fp); + break; + + case '"': + fputs("&quot;", fp); + break; + + case '\'': + fputs("&#39;", fp); + break; + + default: + fputc(c, fp); + break; + } + } +} + +/* + * NOTE: Templates may contain at most one block. + */ +int +render_template(FILE *fp, const char *filename, + const struct html_var *vars, size_t nvars, + const struct html_block *blocks, size_t nblocks) +{ + FILE *template; + char *text; + long size; + size_t len; + const char *block_start; + const char *block_end; + const char *p; + char block_name[128]; + size_t block_name_len; + const struct html_block *block; + size_t i; + + template = fopen(filename, "r"); + if (template == NULL) + return -1; + + if (fseek(template, 0, SEEK_END) == -1) { + fclose(template); + return -1; + } + + size = ftell(template); + if (size < 0) { + fclose(template); + return -1; + } + + if (fseek(template, 0, SEEK_SET) == -1) { + fclose(template); + return -1; + } + + len = (size_t)size; + + text = malloc(len + 1); + if (text == NULL) { + fclose(template); + return -1; + } + + if (fread(text, 1, len, template) != len) { + free(text); + fclose(template); + return -1; + } + + text[len] = '\0'; + + fclose(template); + + /* + * Find the first block. + */ + block_start = strstr(text, "{{#"); + + if (block_start == NULL) { + /* + * No blocks: render the whole template normally. + */ + if (render_text(fp, text, len, + vars, nvars, + NULL, 0) == -1) { + free(text); + return -1; + } + + free(text); + return 0; + } + + /* + * Render text before the block. + */ + if (render_text(fp, + text, + (size_t)(block_start - text), + vars, nvars, + NULL, 0) == -1) { + free(text); + return -1; + } + + /* + * Find the end of the block name. + */ + block_end = strstr(block_start + 3, "}}"); + if (block_end == NULL) { + free(text); + return -1; + } + + block_name_len = + (size_t)(block_end - (block_start + 3)); + + if (block_name_len == 0 || + block_name_len >= sizeof(block_name)) { + free(text); + return -1; + } + + memcpy(block_name, + block_start + 3, + block_name_len); + block_name[block_name_len] = '\0'; + + block = find_block(block_name, blocks, nblocks); + if (block == NULL) { + free(text); + return -1; + } + + /* + * Find the closing block marker. + */ + p = block_end + 2; + + { + char closing[128]; + + if (snprintf(closing, sizeof(closing), + "{{/%s}}", block_name) >= + (int)sizeof(closing)) { + free(text); + return -1; + } + + block_start = strstr(p, closing); + if (block_start == NULL) { + free(text); + return -1; + } + + block_end = block_start; + } + + /* + * Render the block once for every row. + */ + for (i = 0; i < block->nrows; i++) { + const char *row_start = p; + size_t row_len = + (size_t)(block_end - row_start); + + if (render_text(fp, + row_start, + row_len, + vars, nvars, + block->rows[i].vars, + block->rows[i].nvars) == -1) { + free(text); + return -1; + } + } + + /* + * Render anything after the block. + */ + p = block_end + strlen( + "{{/"); + + p = strstr(p, "}}"); + if (p == NULL) { + free(text); + return -1; + } + + p += 2; + + if (render_text(fp, + p, + strlen(p), + vars, nvars, + NULL, 0) == -1) { + free(text); + return -1; + } + + free(text); + return 0; +} + +static const char * +find_variable(const char *name, + const struct html_var * vars, size_t nvars, + const struct html_var * row_vars, size_t nrowvars) +{ + size_t i; + + for (i = 0; i < nrowvars; i++) { + if (strcmp(name, row_vars[i].name) == 0) + return row_vars[i].value; + } + + for (i = 0; i < nvars; i++) { + if (strcmp(name, vars[i].name) == 0) + return vars[i].value; + } + + return NULL; +} + +static int +render_text(FILE * fp, + const char *text, + size_t len, + const struct html_var * vars, + size_t nvars, + const struct html_var * row_vars, + size_t nrowvars) +{ + const char *p; + const char *end; + + p = text; + end = text + len; + + while (p < end) { + const char *start; + const char *close; + char name[128]; + size_t namelen; + const char *value; + + start = find_marker(p, (size_t)(end - p), "{{"); + + if (start == NULL) { + if (fwrite(p, 1, (size_t) (end - p), fp) != + (size_t) (end - p)) + return -1; + break; + } + if (fwrite(p, 1, (size_t) (start - p), fp) != + (size_t) (start - p)) + return -1; + + close = find_marker(start + 2, (size_t)(end - (start + 2)), "}}"); + + if (close == NULL) + return -1; + + namelen = (size_t) (close - (start + 2)); + + if (namelen == 0 || namelen >= sizeof(name)) + return -1; + + memcpy(name, start + 2, namelen); + name[namelen] = '\0'; + + value = find_variable(name, + vars, nvars, + row_vars, nrowvars); + + if (value == NULL) + return -1; + + html_escape(fp, value); + + p = close + 2; + } + + return 0; +} + +static const struct html_block * +find_block(const char *name, + const struct html_block *blocks, size_t nblocks) +{ + size_t i; + + for (i = 0; i < nblocks; i++) { + if (strcmp(name, blocks[i].name) == 0) + return &blocks[i]; + } + + return NULL; +} + +static const char * +find_marker(const char *text, size_t len, const char marker[2]) +{ + size_t i; + + if (len < 2) + return NULL; + + for (i = 0; i + 1 < len; i++) { + if (text[i] == marker[0] && + text[i + 1] == marker[1]) + return text + i; + } + + return NULL; +} diff --git a/src/html.h b/src/html.h @@ -0,0 +1,29 @@ +#ifndef HTML_H +#define HTML_H + +#include <stdio.h> +#include <stddef.h> + +struct html_var { + const char *name; + const char *value; +}; + +struct html_row { + const struct html_var *vars; + size_t nvars; +}; + +struct html_block { + const char *name; + const struct html_row *rows; + size_t nrows; +}; + +void html_escape(FILE *fp, const char *s); + +int render_template(FILE *, const char *, + const struct html_var *, size_t, + const struct html_block *, size_t); + +#endif diff --git a/src/http.c b/src/http.c @@ -0,0 +1,107 @@ +#include <stdio.h> + +#include "html.h" +#include "http.h" + +void +html_headers(const char *status) +{ + printf("Status: %s\r\n", status); + printf("Content-Type: text/html; charset=utf-8\r\n"); + printf("X-Content-Type-Options: nosniff\r\n"); + printf("X-Frame-Options: DENY\r\n"); + printf("\r\n"); +} + +int +download_headers(const char *filename, off_t size) +{ + const unsigned char *p; + + if (filename == NULL) + return -1; + + for (p = (const unsigned char *) filename; *p != '\0'; p++) { + if (*p == '\r' || *p == '\n') + return -1; + } + + printf("Status: 200 OK\r\n"); + printf("Content-Type: application/octet-stream\r\n"); + printf("Content-Disposition: attachment; filename=\"%s\"\r\n", + filename); + printf("Content-Length: %lld\r\n", (long long) size); + printf("X-Content-Type-Options: nosniff\r\n"); + printf("X-Frame-Options: DENY\r\n"); + printf("\r\n"); + + return 0; +} + +void +reply(const char *status, const char *msg) +{ + html_headers(status); + + printf("<!doctype html>\n"); + printf("<html>\n"); + printf("<body>\n"); + printf("<h1>"); + html_escape(stdout, msg); + printf("</h1>\n"); + printf("</body>\n"); + printf("</html>\n"); +} + +void +redirect(const char *uri) +{ + printf("Status: 303 See Other\r\n"); + printf("Location: %s\r\n", uri); + printf("\r\n"); +} + +int +render_template_response(const char *template, + const struct html_var *vars, size_t nvars, + const struct html_block *blocks, size_t nblocks) +{ + FILE *tmp; + char buffer[8192]; + size_t n; + + tmp = tmpfile(); + if (tmp == NULL) + return -1; + + if (render_template(tmp, + template, + vars, nvars, + blocks, nblocks) == -1) { + fclose(tmp); + return -1; + } + + if (fflush(tmp) == EOF || + fseek(tmp, 0, SEEK_SET) == -1) { + fclose(tmp); + return -1; + } + + html_headers("200 OK"); + + while ((n = fread(buffer, 1, sizeof(buffer), tmp)) > 0) { + if (fwrite(buffer, 1, n, stdout) != n) { + fclose(tmp); + return -1; + } + } + + if (ferror(tmp)) { + fclose(tmp); + return -1; + } + + fclose(tmp); + return 0; +} diff --git a/src/http.h b/src/http.h @@ -0,0 +1,17 @@ +#ifndef HTTP_H +#define HTTP_H + +#include <sys/types.h> + +struct html_var; +struct html_block; + +void html_headers(const char *status); +int download_headers(const char *, off_t); +void reply(const char *, const char *); +void redirect(const char *); +int render_template_response(const char *, + const struct html_var *, size_t, + const struct html_block *, size_t); + +#endif diff --git a/src/list.c b/src/list.c @@ -0,0 +1,217 @@ +#include <sys/stat.h> +#include <dirent.h> +#include <errno.h> +#include <limits.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <time.h> +#include <unistd.h> + +#include "config.h" +#include "common.h" +#include "url.h" +#include "html.h" +#include "http.h" + +static int list_files(const char *repository_path, struct html_row ** rows, size_t * nrows); +static void format_size(char *buf, size_t len, off_t size); +static void free_rows(struct html_row *rows, size_t nrows); + +int +main(void) +{ + char path[PATH_MAX]; + char template[PATH_MAX]; + struct html_row *rows; + size_t nrows; + struct html_block blocks[1]; + const char *template_name; + + if (!config_access_allowed()) { + reply("403 Forbidden", "Forbidden"); + return 0; + } + + if (build_repository_path(path, + sizeof(path), + storage_root(), + repository()) == -1) { + reply("500 Internal Server Error", + "Path too long"); + return 0; + } + if (list_files(path, &rows, &nrows) == -1) { + reply("500 Internal Server Error", + "Unable to list files"); + return 0; + } + + blocks[0].name = "FILES"; + blocks[0].rows = rows; + blocks[0].nrows = nrows; + + + if (!config_write_allowed()) + template_name = LIST_READONLY_TEMPLATE_NAME; + else + template_name = LIST_TEMPLATE_NAME; + + if (snprintf(template, sizeof(template), + "%s/%s", + simplefiles_root(), + template_name) >= (int) sizeof(template)) { + reply("500 Internal Server Error", + "Template path too long"); + free_rows(rows, nrows); + return 0; + } + + if (render_template_response(template, + NULL, 0, + blocks, 1) == -1) { + free_rows(rows, nrows); + reply("500 Internal Server Error", "Unable to render template"); + return 0; + } + free_rows(rows, nrows); + return 0; +} + +static void +format_size(char *buf, size_t len, off_t size) +{ + if (size >= 1073741824LL) + snprintf(buf, len, "%.1f GB", size / 1073741824.0); + else if (size >= 1048576LL) + snprintf(buf, len, "%.1f MB", size / 1048576.0); + else if (size >= 1024LL) + snprintf(buf, len, "%.1f KB", size / 1024.0); + else + snprintf(buf, len, "%lld B", (long long) size); +} + +static int +list_files(const char *repository_path, + struct html_row ** rows, size_t * nrows) +{ + DIR *dir; + struct dirent *de; + struct stat st; + char path[PATH_MAX]; + char timebuf[32]; + struct tm *tm; + char sizebuf[32]; + char *encoded; + struct html_row *new_rows; + struct html_var *vars; + size_t count = 0; + + *rows = NULL; + *nrows = 0; + + dir = opendir(repository_path); + if (dir == NULL) + return 0; + + while ((de = readdir(dir)) != NULL) { + if (strcmp(de->d_name, ".") == 0 || + strcmp(de->d_name, "..") == 0) + continue; + + if (strcmp(de->d_name, CONFIG_FILENAME) == 0) + continue; + + if (snprintf(path, sizeof(path), + "%s/%s", + repository_path, + de->d_name) >= (int) sizeof(path)) + continue; + + if (stat(path, &st) == -1) + continue; + + if (!S_ISREG(st.st_mode)) + continue; + + tm = localtime(&st.st_mtime); + if (tm == NULL) + continue; + + if (strftime(timebuf, sizeof(timebuf), + "%Y-%m-%d %H:%M", tm) == 0) + continue; + + encoded = url_encode(de->d_name); + if (encoded == NULL) + continue; + + + new_rows = realloc(*rows, (count + 1) * sizeof(**rows)); + if (new_rows == NULL) { + free_rows(*rows, count); + free(encoded); + closedir(dir); + return -1; + } + *rows = new_rows; + + vars = malloc(4 * sizeof(*vars)); + if (vars == NULL) { + free_rows(*rows, count); + free(encoded); + closedir(dir); + return -1; + } + vars[0].name = "NAME"; + vars[0].value = strdup(de->d_name); + + vars[1].name = "URL"; + vars[1].value = encoded; + + format_size(sizebuf, sizeof(sizebuf), st.st_size); + vars[2].name = "SIZE"; + vars[2].value = strdup(sizebuf); + + vars[3].name = "MODIFIED"; + vars[3].value = strdup(timebuf); + + if (vars[0].value == NULL || + vars[2].value == NULL || + vars[3].value == NULL) { + free((char *) vars[0].value); + free((char *) vars[1].value); + free((char *) vars[2].value); + free((char *) vars[3].value); + free(vars); + free_rows(*rows, count); + *rows = NULL; + closedir(dir); + return -1; + } + (*rows)[count].vars = vars; + (*rows)[count].nvars = 4; + count++; + } + + closedir(dir); + + *nrows = count; + return 0; +} + +static void +free_rows(struct html_row *rows, size_t nrows) +{ + size_t i; + size_t j; + + for (i = 0; i < nrows; i++) { + for (j = 0; j < rows[i].nvars; j++) + free((char *) rows[i].vars[j].value); + + free((struct html_var *) rows[i].vars); + } + + free(rows); +} diff --git a/src/multipart.c b/src/multipart.c @@ -0,0 +1,1006 @@ +#include <ctype.h> +#include <errno.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <strings.h> +#include <sys/stat.h> +#include <unistd.h> + +#include "config.h" +#include "multipart.h" + +#define BOUNDARY_MAX 128 +#define MULTIPART_BOUNDARY_MAX 70 +#define MIME_TYPE "multipart/form-data" + +static int +parse_content_disposition(char *value, char *filename, size_t size) +{ + char *p; + char *start; + char *end; + size_t len; + int seen_filename; + int seen_name; + + if (value == NULL || filename == NULL || size == 0) + return MULTIPART_ERROR; + + p = value; + seen_filename = 0; + seen_name = 0; + + while (*p == ' ' || *p == '\t') + p++; + + if (strncasecmp(p, "form-data", 9) != 0) + return MULTIPART_MALFORMED; + + p += 9; + + if (*p != ';' && *p != '\0') + return MULTIPART_MALFORMED; + + while (*p != '\0') { + /* + * Parameter separator. + */ + if (*p != ';') + return MULTIPART_MALFORMED; + + p++; + + while (*p == ' ' || *p == '\t') + p++; + + /* + * Parameter name. + */ + start = p; + + while (*p != '\0' && + *p != '=' && + *p != ';' && + *p != ' ' && + *p != '\t') + p++; + + if (p == start) + return MULTIPART_MALFORMED; + + end = p; + + while (*p == ' ' || *p == '\t') + p++; + + if (*p != '=') + return MULTIPART_MALFORMED; + + p++; + + while (*p == ' ' || *p == '\t') + p++; + + if ((size_t) (end - start) == 4 && + strncasecmp(start, "name", 4) == 0) { + if (seen_name) + return MULTIPART_MALFORMED; + + seen_name = 1; + + if (*p != '"') + return MULTIPART_MALFORMED; + + p++; + start = p; + + while (*p != '\0' && *p != '"') + p++; + + if (*p != '"' || p == start) + return MULTIPART_MALFORMED; + + len = (size_t) (p - start); + + if (len != 4 || strncasecmp(start, "file", 4) != 0) + return MULTIPART_MALFORMED; + + p++; + + while (*p == ' ' || *p == '\t') + p++; + + if (*p != '\0' && *p != ';') + return MULTIPART_MALFORMED; + + } else if ((size_t) (end - start) == 8 && + strncasecmp(start, "filename", 8) == 0) { + if (seen_filename) + return MULTIPART_MALFORMED; + + seen_filename = 1; + + if (*p != '"') + return MULTIPART_MALFORMED; + + p++; + start = p; + + while (*p != '\0' && *p != '"') + p++; + + if (*p != '"' || p == start) + return MULTIPART_MALFORMED; + + len = (size_t) (p - start); + + if (len >= size) + return MULTIPART_MALFORMED; + + memcpy(filename, start, len); + filename[len] = '\0'; + + p++; + + /* + * After a quoted filename, only whitespace, + * another parameter, or end-of-line is allowed. + */ + while (*p == ' ' || *p == '\t') + p++; + + if (*p != '\0' && *p != ';') + return MULTIPART_MALFORMED; + } else { + /* + * Ignore unrelated parameters, but require a + * syntactically valid value. + */ + if (*p == '"') { + p++; + + while (*p != '\0' && *p != '"') + p++; + + if (*p != '"') + return MULTIPART_MALFORMED; + + p++; + } else { + start = p; + + while (*p != '\0' && + *p != ';' && + *p != ' ' && + *p != '\t') + p++; + + if (p == start) + return MULTIPART_MALFORMED; + } + + while (*p == ' ' || *p == '\t') + p++; + + if (*p != '\0' && *p != ';') + return MULTIPART_MALFORMED; + } + } + + if (!seen_name) + return MULTIPART_MALFORMED; + + if (!seen_filename) + return MULTIPART_MISSING_FILENAME; + + return MULTIPART_OK; +} + +static int +next_part_is_file(int fd, off_t offset, + const struct multipart_request *req) +{ + char headers[8192]; + char filename[NAME_MAX + 1]; + char *line; + char *next; + char *disposition; + size_t used; + ssize_t n; + int disposition_found; + + if (req == NULL) + return MULTIPART_ERROR; + + if (lseek(fd, offset, SEEK_SET) == (off_t)-1) + return MULTIPART_ERROR; + + used = 0; + + while (used < sizeof(headers) - 1) { + n = read(fd, headers + used, 1); + + if (n == 0) + return MULTIPART_MALFORMED; + + if (n < 0) + return MULTIPART_ERROR; + + used++; + + if (used >= 4 && + headers[used - 4] == '\r' && + headers[used - 3] == '\n' && + headers[used - 2] == '\r' && + headers[used - 1] == '\n') + break; + } + + if (used >= sizeof(headers) - 1) + return MULTIPART_MALFORMED; + + headers[used] = '\0'; + + line = headers; + disposition_found = 0; + + while (*line != '\0') { + next = strstr(line, "\r\n"); + + if (next == NULL) + return MULTIPART_MALFORMED; + + /* + * Empty line here would mean the header terminator was + * encountered. Since we already read CRLFCRLF, this is + * the end of the header section. + */ + if (next == line) + break; + + *next = '\0'; + + if (strncasecmp(line, "Content-Disposition:", 20) == 0) { + if (disposition_found) + return MULTIPART_MALFORMED; + + disposition_found = 1; + + disposition = line + 20; + + while (*disposition == ' ' || + *disposition == '\t') + disposition++; + + n = parse_content_disposition( + disposition, + filename, + sizeof(filename)); + + if (n != MULTIPART_OK) + return (int)n; + } + + line = next + 2; + } + + if (!disposition_found) + return MULTIPART_MISSING_FILENAME; + + return MULTIPART_OK; +} + +static int +write_all(int fd, const void *buf, size_t len) +{ + const unsigned char *p = buf; + size_t remaining = len; + + while (remaining > 0) { + ssize_t n; + + n = write(fd, p, remaining); + + if (n <= 0) + return -1; + + p += n; + remaining -= (size_t) n; + } + + return 0; +} + +static int +is_boundary_char(unsigned char c) +{ + if (isalnum(c)) + return 1; + + switch (c) { + case '\'': + case '(': + case ')': + case '+': + case '_': + case ',': + case '-': + case '.': + case '/': + case ':': + case '=': + case '?': + return 1; + default: + return 0; + } +} +/* + * Parse the Content-Type boundary. + * + * This intentionally preserves the existing parser behavior for now. + * Boundary parsing can be hardened in a later step. + */ +static int +parse_boundary(struct multipart_request * req, const char *ctype) +{ + const char *p; + const char *start; + size_t len; + int seen_boundary; + + /* + * Content-Type must begin with multipart/form-data. + * The media type comparison is case-insensitive. + */ + if (strncasecmp(ctype, "multipart/form-data", 19) != 0) + return MULTIPART_INVALID_CONTENT_TYPE; + + p = ctype + 19; + seen_boundary = 0; + + /* + * Parse MIME parameters. + * + * We accept optional whitespace around ';', parameter names, + * and '='. The boundary value may be quoted. + */ + for (;;) { + while (*p == ' ' || *p == '\t') + p++; + + if (*p == '\0') { + if (!seen_boundary) + return MULTIPART_MISSING_BOUNDARY; + return MULTIPART_OK; + } + if (*p != ';') + return MULTIPART_INVALID_CONTENT_TYPE; + + p++; + + while (*p == ' ' || *p == '\t') + p++; + + /* Parameter name. */ + start = p; + + while (*p != '\0' && + *p != '=' && + *p != ';' && + *p != ' ' && + *p != '\t') + p++; + + len = (size_t) (p - start); + + while (*p == ' ' || *p == '\t') + p++; + + if (*p != '=') + return MULTIPART_INVALID_CONTENT_TYPE; + + p++; + + while (*p == ' ' || *p == '\t') + p++; + + /* + * We only care about the boundary parameter. + * Other parameters are syntactically allowed. + */ + if (len == 8 && + strncasecmp(start, "boundary", 8) == 0) { + if (seen_boundary) + return MULTIPART_INVALID_BOUNDARY; + + seen_boundary = 1; + + /* + * Quoted boundary. + */ + if (*p == '"') { + p++; + start = p; + + while (*p != '\0' && *p != '"') { + if (!is_boundary_char( + (unsigned char) *p)) + return MULTIPART_INVALID_BOUNDARY; + p++; + } + + if (*p != '"') + return MULTIPART_INVALID_BOUNDARY; + + len = (size_t) (p - start); + + if (len == 0 || len >= MULTIPART_BOUNDARY_MAX) + return MULTIPART_INVALID_BOUNDARY; + + memcpy(req->boundary, start, len); + req->boundary[len] = '\0'; + + p++; + + while (*p == ' ' || *p == '\t') + p++; + + if (*p != '\0' && *p != ';') + return MULTIPART_INVALID_CONTENT_TYPE; + } else { + /* + * Unquoted boundary. + */ + start = p; + + while (*p != '\0' && *p != ';' && + *p != ' ' && *p != '\t') { + if (!is_boundary_char( + (unsigned char) *p)) + return MULTIPART_INVALID_BOUNDARY; + p++; + } + + len = (size_t) (p - start); + + if (len == 0 || len >= MULTIPART_BOUNDARY_MAX) + return MULTIPART_INVALID_BOUNDARY; + + memcpy(req->boundary, start, len); + req->boundary[len] = '\0'; + + while (*p == ' ' || *p == '\t') + p++; + + if (*p != '\0' && *p != ';') + return MULTIPART_INVALID_CONTENT_TYPE; + } + } else { + /* + * Skip the value of an unrelated parameter. + * Handle quoted values so semicolons inside quotes + * aren't mistaken for parameter separators. + */ + if (*p == '"') { + p++; + + while (*p != '\0' && *p != '"') + p++; + + if (*p != '"') + return MULTIPART_INVALID_CONTENT_TYPE; + + p++; + } else { + while (*p != '\0' && *p != ';') + p++; + } + } + } +} + +int +multipart_parse_request(struct multipart_request * req) +{ + const char *ctype; + const char *clen; + char *ep; + unsigned long long len; + off_t max_upload; + int ret; + const char *p; + + if (req == NULL) + return MULTIPART_ERROR; + + memset(req, 0, sizeof(*req)); + + ctype = getenv("CONTENT_TYPE"); + + if (ctype == NULL) + return MULTIPART_MISSING_CONTENT_TYPE; + + ret = parse_boundary(req, ctype); + + if (ret != MULTIPART_OK) + return ret; + + clen = getenv("CONTENT_LENGTH"); + + if (clen == NULL) + return MULTIPART_MISSING_LENGTH; + + for (p = clen; *p != '\0'; p++) { + if (*p < '0' || *p > '9') + return MULTIPART_INVALID_LENGTH; + } + + errno = 0; + + len = strtoull(clen, &ep, 10); + + if (errno != 0 || + ep == clen || + *ep != '\0') + return MULTIPART_INVALID_LENGTH; + + if (errno == ERANGE || ep == clen || *ep != '\0') + return MULTIPART_INVALID_LENGTH; + + max_upload = config_max_upload(); + + if (len == 0 || + len > (unsigned long long) max_upload) + return MULTIPART_TOO_LARGE; + + req->length = (off_t) len; + + return MULTIPART_OK; +} + +int +multipart_spool_request(int fd, off_t length) +{ + unsigned char buffer[8192]; + off_t remaining = length; + + while (remaining > 0) { + size_t want; + ssize_t n; + + want = remaining > (off_t) sizeof(buffer) + ? sizeof(buffer) + : (size_t) remaining; + + n = read(STDIN_FILENO, buffer, want); + + if (n <= 0) + return MULTIPART_ERROR; + + /* + * Keep the write loop local to the multipart/spooling + * implementation for now. + */ + { + const unsigned char *p = buffer; + size_t left = (size_t) n; + + while (left > 0) { + ssize_t written; + + written = write(fd, p, left); + + if (written <= 0) + return MULTIPART_ERROR; + + p += written; + left -= (size_t) written; + } + } + + remaining -= n; + } + + if (lseek(fd, 0, SEEK_SET) == (off_t) - 1) + return MULTIPART_ERROR; + + return MULTIPART_OK; +} + +static int +extract_filename(int fd, const struct multipart_request * req, + char *filename, size_t size) +{ + char headers[8192]; + size_t used = 0; + size_t header_end = 0; + char *line; + char *next; + char *disposition; + ssize_t n; + + if (req == NULL || filename == NULL || size == 0) + return MULTIPART_ERROR; + + if (lseek(fd, 0, SEEK_SET) == (off_t) - 1) + return MULTIPART_ERROR; + + /* + * Read only the first multipart part's headers. + * + * The header terminator is part of the multipart structure, so + * anything after it belongs to the payload and must not be used + * when identifying the filename. + */ + while (used < sizeof(headers) - 1) { + n = read(fd, headers + used, 1); + + if (n == 0) + return MULTIPART_MALFORMED; + + if (n < 0) + return MULTIPART_ERROR; + + used++; + + if (used >= 4 && + headers[used - 4] == '\r' && + headers[used - 3] == '\n' && + headers[used - 2] == '\r' && + headers[used - 1] == '\n') { + header_end = used - 4; + break; + } + } + + if (header_end == 0) + return MULTIPART_MALFORMED; + + headers[header_end + 2] = '\0'; + + /* + * The first line must be the opening multipart boundary. + */ + { + size_t boundary_len = strlen(req->boundary); + size_t opening_len; + + opening_len = boundary_len + 4; /* "--" + boundary + "\r\n" */ + + if (header_end < opening_len || + headers[0] != '-' || + headers[1] != '-' || + memcmp(headers + 2, + req->boundary, boundary_len) != 0 || + headers[2 + boundary_len] != '\r' || + headers[3 + boundary_len] != '\n') + return MULTIPART_MALFORMED; + } + + /* + * Examine each header line. Header names are case-insensitive. + */ + line = strchr(headers, '\n'); + if (line == NULL) + return MULTIPART_MALFORMED; + + line++; + + while (*line != '\0') { + next = strstr(line, "\r\n"); + + if (next == NULL) + break; + + *next = '\0'; + + if (strncasecmp(line, "Content-Disposition:", 20) == 0) { + disposition = line + 20; + while (*disposition == ' ' || *disposition == '\t') + disposition++; + + return parse_content_disposition(disposition, filename, size); + + } + line = next + 2; + } + + return MULTIPART_MISSING_FILENAME; +} + +static int +find_payload(int fd, + const struct multipart_request * req, + off_t * payload_offset, + off_t * payload_length) +{ + unsigned char buffer[8192]; + char delimiter[BOUNDARY_MAX + 3]; + size_t delimiter_len; + size_t match; + off_t payload_start; + off_t pos; + off_t remaining; + + if (req == NULL || + payload_offset == NULL || + payload_length == NULL) + return MULTIPART_ERROR; + + { + struct stat st; + + if (req->length < 0) + return MULTIPART_MALFORMED; + + if (fstat(fd, &st) == -1) + return MULTIPART_ERROR; + + if (st.st_size < req->length) + return MULTIPART_MALFORMED; + } + + if (snprintf(delimiter, sizeof(delimiter), + "\r\n--%s", req->boundary) >= + (int) sizeof(delimiter)) + return MULTIPART_ERROR; + + delimiter_len = strlen(delimiter); + + /* + * The payload starts after the first CRLFCRLF + * terminating the part headers. + */ + if (lseek(fd, 0, SEEK_SET) == (off_t) - 1) + return MULTIPART_ERROR; + + pos = 0; + remaining = req->length; + + { + const char header_end[] = "\r\n\r\n"; + size_t header_match = 0; + + while (remaining > 0) { + size_t want; + ssize_t n; + size_t i; + + want = remaining > (off_t) sizeof(buffer) + ? sizeof(buffer) + : (size_t) remaining; + + n = read(fd, buffer, want); + if (n < 0) + return MULTIPART_ERROR; + if (n == 0) + return MULTIPART_MALFORMED; + + for (i = 0; i < (size_t) n; i++) { + if (buffer[i] == + (unsigned char) header_end[header_match]) { + header_match++; + + if (header_match == + sizeof(header_end) - 1) { + payload_start = + pos + (off_t) i + 1; + goto header_found; + } + } else { + header_match = 0; + } + } + + pos += n; + remaining -= n; + } + } + + /* + * No CRLFCRLF means the part headers were never + * properly terminated. + */ + return MULTIPART_MALFORMED; + +header_found: + if (payload_start > req->length) + return MULTIPART_MALFORMED; + + if (lseek(fd, payload_start, SEEK_SET) == (off_t) - 1) + return MULTIPART_ERROR; + + pos = payload_start; + remaining = req->length - payload_start; + match = 0; + + while (remaining > 0) { + size_t want; + ssize_t n; + size_t i; + + want = remaining > (off_t) sizeof(buffer) + ? sizeof(buffer) + : (size_t) remaining; + + n = read(fd, buffer, want); + if (n < 0) + return MULTIPART_ERROR; + if (n == 0) + return MULTIPART_MALFORMED; + + for (i = 0; i < (size_t) n; i++) { + unsigned char c; + + c = buffer[i]; + + if (c == (unsigned char) delimiter[match]) { + match++; + + if (match == delimiter_len) { + off_t delimiter_end; + unsigned char suffix[2]; + ssize_t suffix_len; + + delimiter_end = + pos + (off_t) i + 1; + + /* + * The boundary delimiter must be + * followed by exactly one of: + * + * "--" final boundary + * "\r\n" next part + */ + suffix_len = pread(fd, + suffix, sizeof(suffix), + delimiter_end); + + if (suffix_len == -1) + return MULTIPART_ERROR; + + /* + * We found the beginning of a + * boundary, but the request ends + * before its required suffix. + */ + if (suffix_len != 2) + return MULTIPART_MALFORMED; + + if (suffix[0] == '-' && + suffix[1] == '-') { + /* + * payload ends immediately before + * the CRLF that introduced this + * delimiter. + */ + *payload_offset = + payload_start; + + *payload_length = + delimiter_end - + delimiter_len - + payload_start; + + if (*payload_length < 0) + return MULTIPART_MALFORMED; + + return MULTIPART_OK; + } + if (suffix[0] == '\r' && + suffix[1] == '\n') { + int ret; + + ret = next_part_is_file(fd, delimiter_end + 2, req); + + if (ret == MULTIPART_OK) + return MULTIPART_MULTIPLE_FILES; + + if (ret == MULTIPART_MISSING_FILENAME) + return MULTIPART_MALFORMED; + + return ret; + } + /* + * This only looked like a boundary. + * It is ordinary payload data. + */ + match = 0; + continue; + } + } else { + /* + * If this byte can begin a new delimiter, + * retain it as the first match. + */ + if (c == (unsigned char) delimiter[0]) + match = 1; + else + match = 0; + } + } + + pos += n; + remaining -= n; + } + + /* + * Reaching the end of CONTENT_LENGTH without a valid + * closing boundary means the multipart body is malformed. + */ + return MULTIPART_MALFORMED; +} + +int +multipart_extract_part(int fd, + const struct multipart_request * req, + struct multipart_part * part) +{ + int ret; + + if (req == NULL || part == NULL) + return MULTIPART_ERROR; + + memset(part, 0, sizeof(*part)); + + ret = extract_filename(fd, + req, + part->filename, + sizeof(part->filename)); + + if (ret != MULTIPART_OK) + return ret; + + ret = find_payload(fd, + req, + &part->offset, + &part->length); + + if (ret != MULTIPART_OK) + return ret; + + return MULTIPART_OK; +} + +int +multipart_copy_part(int spool_fd, + const struct multipart_part * part, + int output_fd) +{ + unsigned char buffer[8192]; + off_t remaining; + + if (part == NULL) + return MULTIPART_ERROR; + + if (part->offset < 0 || part->length < 0) + return MULTIPART_ERROR; + + if (lseek(spool_fd, part->offset, SEEK_SET) == (off_t) - 1) + return MULTIPART_ERROR; + + remaining = part->length; + + while (remaining > 0) { + size_t want; + ssize_t n; + + want = remaining > (off_t) sizeof(buffer) + ? sizeof(buffer) + : (size_t) remaining; + + n = read(spool_fd, buffer, want); + + if (n <= 0) + return MULTIPART_ERROR; + + if (write_all(output_fd, buffer, (size_t) n) == -1) + return MULTIPART_ERROR; + + remaining -= n; + } + + return MULTIPART_OK; +} diff --git a/src/multipart.h b/src/multipart.h @@ -0,0 +1,40 @@ +#ifndef MULTIPART_H +#define MULTIPART_H + +#include <limits.h> +#include <sys/types.h> + +#define MULTIPART_OK 0 +#define MULTIPART_ERROR -1 +#define MULTIPART_MISSING_CONTENT_TYPE -2 +#define MULTIPART_INVALID_CONTENT_TYPE -3 +#define MULTIPART_MISSING_BOUNDARY -4 +#define MULTIPART_INVALID_BOUNDARY -5 +#define MULTIPART_MISSING_LENGTH -6 +#define MULTIPART_INVALID_LENGTH -7 +#define MULTIPART_TOO_LARGE -8 +#define MULTIPART_MALFORMED -9 +#define MULTIPART_MISSING_FILENAME -10 +#define MULTIPART_MULTIPLE_FILES -11 + +struct multipart_request { + off_t length; + char boundary[128]; +}; + +struct multipart_part { + char filename[NAME_MAX + 1]; + off_t offset; + off_t length; +}; + +int multipart_parse_request(struct multipart_request *); +int multipart_spool_request(int, off_t); +int multipart_extract_part(int, + const struct multipart_request *, + struct multipart_part *); +int multipart_copy_part(int, + const struct multipart_part *, + int); + +#endif diff --git a/src/rename.c b/src/rename.c @@ -0,0 +1,247 @@ +#include <errno.h> +#include <limits.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/stat.h> +#include <unistd.h> + +#include "common.h" +#include "config.h" +#include "html.h" +#include "http.h" +#include "request.h" + +static int do_get(void); +static int do_post(void); + + +static int +do_get(void) +{ + char filename[NAME_MAX + 1]; + char path[PATH_MAX]; + char template[PATH_MAX]; + struct stat st; + + if (!config_write_allowed()) { + reply("403 Forbidden", "Repository is read-only"); + return 0; + } + if (get_filename(getenv("QUERY_STRING"), + filename, + sizeof(filename)) == -1) + return 0; + + if (build_storage_path(path, + sizeof(path), + storage_root(), + repository(), + filename) == -1) { + reply("500 Internal Server Error", + "Path too long"); + return 0; + } + if (stat(path, &st) == -1 || !S_ISREG(st.st_mode)) { + reply("404 Not Found", + "File not found"); + return 0; + } + struct html_var vars[] = { + {"FILENAME", filename}, + {"CANCEL_URI", DEFAULT_REDIRECT_URI}, + }; + + if (snprintf(template, + sizeof(template), + "%s/%s", + simplefiles_root(), RENAME_TEMPLATE_NAME) >= (int) sizeof(template)) { + reply("500 Internal Server Error", + "Template path too long"); + return 0; + } + if (render_template_response( + template, + vars, + sizeof(vars) / sizeof(vars[0]), + NULL, + 0) == -1) { + reply("500 Internal Server Error", "Template error"); + return 0; + } + return 0; +} + + +static int +do_post(void) +{ + char *body; + const char *p; + char oldname[NAME_MAX + 1]; + char newname[NAME_MAX + 1]; + char oldpath[PATH_MAX]; + char newpath[PATH_MAX]; + long len; + char *end; + int lock_fd = -1; + struct stat st; + int rc; + + if (!config_write_allowed()) { + reply("403 Forbidden", "Repository is read-only"); + return 0; + } + rc = check_csrf_origin(); + + if (rc == -2) { + reply("500 Internal Server Error", + "CSRF origin is not configured"); + return 0; + } + if (rc == -1) { + reply("403 Forbidden", + "Forbidden"); + return 0; + } + p = getenv("CONTENT_LENGTH"); + + if (p == NULL) { + reply("400 Bad Request", + "Missing content length"); + return 0; + } + errno = 0; + + len = strtol(p, &end, 10); + + if (errno != 0 || + end == p || + *end != '\0' || + len <= 0 || + len > 1024) { + reply("400 Bad Request", + "Invalid content length"); + return 0; + } + body = malloc((size_t) len + 1); + + if (body == NULL) { + reply("500 Internal Server Error", + "Out of memory"); + return 0; + } + if (fread(body, 1, (size_t) len, stdin) + != (size_t) len) { + free(body); + reply("400 Bad Request", + "Could not read request"); + return 0; + } + body[len] = '\0'; + + + if (get_filename(body, + oldname, + sizeof(oldname)) == -1) { + free(body); + return 0; + } + if (get_new_filename(body, + newname, + sizeof(newname)) == -1) { + free(body); + return 0; + } + free(body); + + + if (strcmp(oldname, newname) == 0) { + redirect(DEFAULT_REDIRECT_URI); + return 0; + } + if (build_storage_path(oldpath, + sizeof(oldpath), + storage_root(), + repository(), + oldname) == -1) { + reply("500 Internal Server Error", + "Path too long"); + return 0; + } + if (build_storage_path(newpath, + sizeof(newpath), + storage_root(), + repository(), + newname) == -1) { + reply("500 Internal Server Error", + "Path too long"); + return 0; + } + lock_fd = repository_lock(repository()); + + if (lock_fd == -1) { + reply("500 Internal Server Error", + "Could not lock repository"); + return 0; + } + if (stat(oldpath, &st) == -1 || + !S_ISREG(st.st_mode)) { + reply("404 Not Found", + "File not found"); + goto out; + } + if (stat(newpath, &st) == 0) { + reply("409 Conflict", + "Destination already exists"); + goto out; + } + if (errno != ENOENT) { + reply("500 Internal Server Error", + "Could not check destination"); + goto out; + } + if (rename(oldpath, newpath) == -1) { + reply("500 Internal Server Error", + "Could not rename file"); + goto out; + } + repository_unlock(lock_fd); + lock_fd = -1; + + redirect(DEFAULT_REDIRECT_URI); + + +out: + if (lock_fd != -1) + repository_unlock(lock_fd); + + return 0; +} + + +int +main(void) +{ + const char *method; + + method = getenv("REQUEST_METHOD"); + + if (method == NULL) { + reply("400 Bad Request", + "Missing request method"); + return 0; + } + if (strcmp(method, "GET") == 0) + return do_get(); + + + if (strcmp(method, "POST") == 0) + return do_post(); + + + reply("405 Method Not Allowed", + "GET or POST required"); + + return 0; +} diff --git a/src/request.c b/src/request.c @@ -0,0 +1,54 @@ +#include <stddef.h> +#include <limits.h> + +#include "common.h" +#include "http.h" +#include "url.h" +#include "request.h" + +int +get_filename(const char *query, char *filename, size_t len) +{ + int ret; + + ret = query_param(query, "file", filename, len); + + if (ret == 1) { + reply("400 Bad Request", "Missing filename"); + return -1; + } + + if (ret == -1 || !valid_filename(filename)) { + reply("400 Bad Request", "Invalid filename"); + return -1; + } + + return 0; +} + +int +get_new_filename(const char *body, + char *filename, + size_t len) +{ + int ret; + + ret = query_param(body, + "new_filename", + filename, + len); + + if (ret == 1) { + reply("400 Bad Request", + "Missing new filename"); + return -1; + } + + if (ret == -1 || !valid_filename(filename)) { + reply("400 Bad Request", + "Invalid filename"); + return -1; + } + + return 0; +} diff --git a/src/request.h b/src/request.h @@ -0,0 +1,9 @@ +#ifndef REQUEST_H +#define REQUEST_H + +#include <stddef.h> + +int get_filename(const char *, char *, size_t); +int get_new_filename(const char *, char *, size_t); + +#endif diff --git a/src/upload.c b/src/upload.c @@ -0,0 +1,683 @@ +#include <ctype.h> +#include <dirent.h> +#include <errno.h> +#include <fcntl.h> +#include <limits.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/stat.h> +#include <sys/statvfs.h> +#include <sys/types.h> +#include <unistd.h> + +#include "common.h" +#include "config.h" +#include "http.h" +#include "html.h" +#include "multipart.h" + +#define MAX_DUPLICATES 1000 +#define DEFAULT_UPLOAD_TMP_DIR "/run/simplefiles/uploads" + +/* Helper */ +static int do_get(void); +static int do_post(void); +static const char *upload_tmp_dir(void); + +/* Utility */ +static off_t repo_quota_bytes(void); +static off_t min_free_bytes(void); + +/* Multipart parsing */ +static int open_spool(const char *, char *, size_t); + +/* File handling */ +static int open_upload_temp(const char *, char *, size_t); +static int finalize_upload(const char *, const char *, const char *, char *, size_t); +static int check_free_space(const char *dir, off_t required); +static int check_repo_quota(const char *dir, off_t incoming_size); + +/* Utility */ +static off_t +repo_quota_bytes(void) +{ + const char *val; + char *end; + long long parsed; + + val = getenv("REPO_QUOTA_BYTES"); + + if (val == NULL || *val == '\0') + return config_repo_quota(); + + errno = 0; + parsed = strtoll(val, &end, 10); + + if (errno != 0 || + end == val || + *end != '\0' || + parsed <= 0) + return config_repo_quota(); + + return (off_t) parsed; +} + +static off_t +min_free_bytes(void) +{ + const char *val; + char *ep; + long long parsed; + + val = getenv("MIN_FREE_BYTES"); + + if (val == NULL || *val == '\0') + return DEFAULT_MIN_FREE; + + errno = 0; + parsed = strtoll(val, &ep, 10); + + if (errno != 0 || + ep == val || + *ep != '\0' || + parsed <= 0) + return DEFAULT_MIN_FREE; + + return (off_t) parsed; +} +/* File handling. */ +static int +open_upload_temp(const char *dir, char *path, size_t len) +{ + int fd; + struct stat st; + + if (mkdir(dir, 0700) == -1 && errno != EEXIST) { + reply("500 Internal Server Error", + "Could not create upload directory"); + return -1; + } + if (stat(dir, &st) == -1 || !S_ISDIR(st.st_mode)) { + reply("500 Internal Server Error", + "Upload directory is not a directory"); + return -1; + } + if (st.st_mode & (S_IWGRP | S_IWOTH)) { + reply("500 Internal Server Error", + "Unsafe upload directory permissions"); + return -1; + } + if (snprintf(path, len, "%s/.upload-XXXXXX", dir) >= (int) len) { + reply("500 Internal Server Error", "Path too long"); + return -1; + } + fd = mkstemp(path); + if (fd == -1) { + reply("500 Internal Server Error", + "Could not create temporary upload"); + return -1; + } + return fd; +} + +static int +open_spool(const char *dir, char *path, size_t len) +{ + int fd; + struct stat st; + + if (mkdir(dir, 0700) == -1 && errno != EEXIST) { + reply("500 Internal Server Error", + "Could not create upload directory"); + return -1; + } + if (stat(dir, &st) == -1 || !S_ISDIR(st.st_mode)) { + reply("500 Internal Server Error", + "Upload directory is not a directory"); + return -1; + } + if (st.st_mode & (S_IWGRP | S_IWOTH)) { + reply("500 Internal Server Error", + "Unsafe upload directory permissions"); + return -1; + } + if (snprintf(path, len, "%s/.spool-XXXXXX", dir) >= (int) len) { + reply("500 Internal Server Error", + "Path too long"); + return -1; + } + fd = mkstemp(path); + if (fd == -1) { + reply("500 Internal Server Error", + "Could not create request spool"); + return -1; + } + if (fchmod(fd, 0600) == -1) { + close(fd); + unlink(path); + reply("500 Internal Server Error", + "Could not set spool permissions"); + return -1; + } + return fd; +} + +static int +finalize_upload(const char *dir, + const char *filename, + const char *temp_path, + char *path, + size_t len) +{ + const char *ext; + char base[NAME_MAX + 1]; + size_t baselen; + int i; + + ext = strrchr(filename, '.'); + if (ext == filename) + ext = NULL; + + if (ext == NULL) { + if (strlcpy(base, filename, sizeof(base)) >= sizeof(base)) { + reply("500 Internal Server Error", "Filename too long"); + return -1; + } + ext = ""; + } else { + baselen = (size_t) (ext - filename); + + if (baselen >= sizeof(base)) { + reply("500 Internal Server Error", "Filename too long"); + return -1; + } + memcpy(base, filename, baselen); + base[baselen] = '\0'; + } + + /* Try the original filename first. */ + if (snprintf(path, len, "%s/%s", dir, filename) >= (int) len) { + reply("500 Internal Server Error", "Path too long"); + return -1; + } + if (link(temp_path, path) == 0) + return 0; + + if (errno != EEXIST) { + reply("500 Internal Server Error", + "Could not finalize upload"); + return -1; + } + /* Original filename exists; try .1, .2, ... */ + for (i = 1; i < MAX_DUPLICATES; i++) { + if (snprintf(path, len, "%s/%s.%d%s", + dir, base, i, ext) >= (int) len) { + reply("500 Internal Server Error", "Path too long"); + return -1; + } + if (link(temp_path, path) == 0) + return 0; + + if (errno != EEXIST) { + reply("500 Internal Server Error", + "Could not finalize upload"); + return -1; + } + } + + reply("500 Internal Server Error", "Too many duplicate filenames"); + return -1; +} + +static int +check_free_space(const char *path, off_t required) +{ + struct statvfs vfs; + off_t min_free = min_free_bytes(); + off_t avail; + char buf[PATH_MAX]; + char *p; + char *slash; + + if (required < 0) + return -1; + + if (statvfs(path, &vfs) == 0) { + avail = (off_t) vfs.f_bavail * (off_t) vfs.f_frsize; + + if (avail < min_free || + required > avail - min_free) + return -1; + + return 0; + } + if (errno != ENOENT) + return -1; + + if (strlcpy(buf, path, sizeof(buf)) >= sizeof(buf)) + return -1; + + p = buf; + for (;;) { + if (statvfs(p, &vfs) == 0) { + avail = (off_t) vfs.f_bavail * (off_t) vfs.f_frsize; + + if (avail < min_free || + required > avail - min_free) + return -1; + + return 0; + } + if (errno != ENOENT) + return -1; + + slash = strrchr(p, '/'); + if (slash == NULL || slash == p) { + if (statvfs("/", &vfs) == 0) { + avail = + (off_t) vfs.f_bavail * + (off_t) vfs.f_frsize; + + if (avail < min_free || + required > avail - min_free) + return -1; + + return 0; + } + return -1; + } + *slash = '\0'; + } +} +static int +check_repo_quota(const char *dir, off_t incoming_size) +{ + DIR *d; + struct dirent *de; + char path[PATH_MAX]; + struct stat st; + off_t used = 0; + off_t repo_quota = repo_quota_bytes(); + + /* Calculate current repository usage */ + d = opendir(dir); + if (d == NULL) { + if (errno == ENOENT) { + /* + * Directory doesn't exist yet → no files, + * so usage is 0. + */ + used = 0; + } else { + /* Real error (permissions, etc.) */ + return -1; + } + } else { + while ((de = readdir(d)) != NULL) { + if (strcmp(de->d_name, ".") == 0 || + strcmp(de->d_name, "..") == 0) + continue; + + if (snprintf(path, sizeof(path), "%s/%s", + dir, de->d_name) >= (int) sizeof(path)) + continue; + + if (stat(path, &st) == 0 && S_ISREG(st.st_mode)) { + if (st.st_size > repo_quota || used > repo_quota - st.st_size) + return -1; + used += st.st_size; + } + } + closedir(d); + } + + /* Check if incoming file fits within quota */ + if (incoming_size > repo_quota || used > repo_quota - incoming_size) + return -1; + + return 0; +} + +static int +do_get(void) +{ + char max_upload[32]; + char max_upload_bytes[32]; + char quota[32]; + char template[PATH_MAX]; + off_t max_upload_value; + off_t quota_value; + + if (!config_write_allowed()) { + reply("403 Forbidden", "Repository is read-only"); + return 0; + } + max_upload_value = config_max_upload(); + quota_value = config_repo_quota(); + + struct html_var vars[] = { + {"MAX_UPLOAD", max_upload}, + {"MAX_UPLOAD_BYTES", max_upload_bytes}, + {"REPO_QUOTA", quota}, + }; + + snprintf(max_upload, sizeof(max_upload), + "%lld MB", + (long long) (max_upload_value / (1024LL * 1024))); + + snprintf(max_upload_bytes, sizeof(max_upload_bytes), + "%lld", + (long long) max_upload_value); + + snprintf(quota, sizeof(quota), + "%lld GB", + (long long) (quota_value / (1024LL * 1024 * 1024))); + + if (snprintf(template, sizeof(template), + "%s/%s", + simplefiles_root(), + UPLOAD_TEMPLATE_NAME) >= (int) sizeof(template)) { + reply("500 Internal Server Error", "Template path too long"); + return 0; + } + if (render_template_response( + template, + vars, + sizeof(vars) / sizeof(vars[0]), + NULL, + 0) == -1) { + reply("500 Internal Server Error", "Template error"); + return 0; + } + return 0; +} + +static int +do_post(void) +{ + struct multipart_request mreq = {0}; + struct multipart_part part; + + char path[PATH_MAX]; + char repodir[PATH_MAX]; + char temppath[PATH_MAX]; + char spoolpath[PATH_MAX]; + + int spool_fd = -1; + int spool_created = 0; + int fd = -1; + int temp_created = 0; + int lock_fd = -1; + int rc; + int ret; + + if (!config_write_allowed()) { + reply("403 Forbidden", "Repository is read-only"); + return 0; + } + rc = check_csrf_origin(); + + if (rc == -2) { + reply("500 Internal Server Error", + "CSRF origin is not configured"); + return 0; + } + if (rc == -1) { + reply("403 Forbidden", "Forbidden"); + return 0; + } + /* + * Parse the multipart request metadata: + * + * - Content-Type + * - boundary + * - Content-Length + * - maximum upload size + */ + ret = multipart_parse_request(&mreq); + + switch (ret) { + case MULTIPART_OK: + break; + + case MULTIPART_MISSING_CONTENT_TYPE: + reply("400 Bad Request", "Missing CONTENT_TYPE"); + goto cleanup; + + case MULTIPART_INVALID_CONTENT_TYPE: + reply("400 Bad Request", + "Expected multipart/form-data"); + goto cleanup; + + case MULTIPART_MISSING_BOUNDARY: + reply("400 Bad Request", "Missing boundary"); + goto cleanup; + + case MULTIPART_INVALID_BOUNDARY: + reply("400 Bad Request", "Invalid boundary"); + goto cleanup; + + case MULTIPART_MISSING_LENGTH: + reply("411 Length Required", "Missing CONTENT_LENGTH"); + goto cleanup; + + case MULTIPART_INVALID_LENGTH: + reply("400 Bad Request", "Invalid CONTENT_LENGTH"); + goto cleanup; + + case MULTIPART_TOO_LARGE: + reply("413 Payload Too Large", "Upload too large"); + goto cleanup; + + default: + reply("400 Bad Request", "Invalid multipart request"); + goto cleanup; + } + + if (build_repository_path(repodir, sizeof(repodir), + storage_root(), repository()) == -1) { + reply("500 Internal Server Error", + "Path too long"); + return 0; + } + if (check_free_space(repodir, mreq.length) == -1) { + reply("507 Insufficient Storage", + "Server running low on disk space"); + return 0; + } + /* + * Spool the complete multipart request to disk. + * + * multipart.c owns the request-body reading logic. + */ + spool_fd = open_spool(upload_tmp_dir(), + spoolpath, sizeof(spoolpath)); + if (spool_fd == -1) + return 0; + + spool_created = 1; + + if (multipart_spool_request(spool_fd, mreq.length) != MULTIPART_OK) { + reply("400 Bad Request", + "Could not read request body"); + goto cleanup; + } + /* + * Extract the uploaded part from the spooled multipart body. + * + * multipart.c now owns: + * - filename extraction + * - multipart header parsing + * - boundary detection + * - payload offset calculation + * - payload length calculation + */ + if (multipart_extract_part(spool_fd, &mreq, &part) != MULTIPART_OK) { + reply("400 Bad Request", + "Malformed multipart body"); + goto cleanup; + } + if (!valid_filename(part.filename)) { + reply("400 Bad Request", + "Invalid filename"); + goto cleanup; + } + /* + * Check that creating the temporary upload will still leave + * MIN_FREE_BYTES available. The request spool already exists, + * so its disk usage is already reflected in statvfs(). + */ + if (check_free_space(repodir, part.length) == -1) { + reply("507 Insufficient Storage", + "Server running low on disk space"); + goto cleanup; + } + /* + * Write the uploaded file to a temporary file first. + * + * This is deliberately done without holding the repository + * lock because this operation may take a long time. + */ + fd = open_upload_temp(upload_tmp_dir(), + temppath, sizeof(temppath)); + if (fd == -1) + goto cleanup; + + temp_created = 1; + + if (multipart_copy_part(spool_fd, &part, fd) != MULTIPART_OK) { + reply("500 Internal Server Error", "Write failed"); + goto cleanup; + } + if (close(fd) == -1) { + reply("500 Internal Server Error", + "Close failed"); + fd = -1; + goto cleanup; + } + fd = -1; + + /* + * Serialize repository quota checking and finalization. + * + * All uploads to the same repository use the same lock. + */ + lock_fd = repository_lock(repository()); + if (lock_fd == -1) { + reply("500 Internal Server Error", + "Could not lock repository"); + goto cleanup; + } + /* + * The quota check must happen while holding the repository + * lock. Otherwise two concurrent uploads could both observe + * the same available quota and both proceed. + */ + if (check_repo_quota(repodir, part.length) == -1) { + reply("507 Insufficient Storage", + "Repository quota exceeded"); + goto cleanup; + } + /* + * Finalization must also happen while holding the lock, + * because it modifies the repository contents. + */ + if (finalize_upload(repodir, + part.filename, + temppath, + path, + sizeof(path)) == -1) + goto cleanup; + + /* + * The repository has now been modified successfully. + * No more repository operation needs to be protected. + */ + repository_unlock(lock_fd); + lock_fd = -1; + + /* + * The temporary file is now no longer needed. + */ + if (unlink(temppath) == -1) { + fprintf(stderr, "warning: could not remove temporary upload %s: %s\n", temppath, strerror(errno)); + } else { + temp_created = 0; + } + + if (close(spool_fd) == -1) { + spool_fd = -1; + reply("500 Internal Server Error", + "Could not close request spool"); + unlink(spoolpath); + return 0; + } + spool_fd = -1; + + if (unlink(spoolpath) == -1) { + reply("500 Internal Server Error", + "Could not remove request spool"); + return 0; + } + spool_created = 0; + + redirect(DEFAULT_REDIRECT_URI); + + return 0; + +cleanup: + if (lock_fd != -1) + repository_unlock(lock_fd); + + if (fd != -1) + close(fd); + + if (temp_created) + unlink(temppath); + + if (spool_fd != -1) + close(spool_fd); + + if (spool_created) + unlink(spoolpath); + + return 0; +} + + +static const char * +upload_tmp_dir(void) +{ + const char *dir; + + dir = getenv("UPLOAD_TMP_DIR"); + if (dir != NULL && *dir != '\0') + return dir; + + return DEFAULT_UPLOAD_TMP_DIR; +} + +int +main(void) +{ + const char *method; + + method = getenv("REQUEST_METHOD"); + + if (method == NULL) { + reply("400 Bad Request", + "Missing request method"); + return 0; + } + if (strcmp(method, "GET") == 0) + return do_get(); + + if (strcmp(method, "POST") == 0) + return do_post(); + + reply("405 Method Not Allowed", + "GET or POST required"); + + return 0; +} diff --git a/src/url.c b/src/url.c @@ -0,0 +1,203 @@ +#include <ctype.h> +#include <string.h> +#include <stdlib.h> + +#include "url.h" +#include "common.h" +#include "http.h" + +static int hex_value(unsigned char c); + +char * +url_encode(const char *s) +{ + const unsigned char *p; + char *out; + char *q; + size_t len = 0; + + if (s == NULL) + return NULL; + + for (p = (const unsigned char *) s; *p != '\0'; p++) { + if (isalnum(*p) || + *p == '-' || + *p == '_' || + *p == '.' || + *p == '~') { + len++; + } else { + len += 3; + } + } + + out = malloc(len + 1); + if (out == NULL) + return NULL; + + q = out; + + for (p = (const unsigned char *) s; *p != '\0'; p++) { + if (isalnum(*p) || + *p == '-' || + *p == '_' || + *p == '.' || + *p == '~') { + *q++ = (char) *p; + } else { + static const char hex[] = "0123456789ABCDEF"; + + *q++ = '%'; + *q++ = hex[*p >> 4]; + *q++ = hex[*p & 0x0f]; + } + } + + *q = '\0'; + + return out; +} + +int +url_decode(char *s) +{ + char *src; + char *dst; + + src = s; + dst = s; + + while (*src != '\0') { + unsigned char c; + + if (*src == '%') { + int hi; + int lo; + + if (src[1] == '\0' || src[2] == '\0') + return -1; + + hi = hex_value((unsigned char) src[1]); + lo = hex_value((unsigned char) src[2]); + + if (hi == -1 || lo == -1) + return -1; + + c = (unsigned char) ((hi << 4) | lo); + src += 3; + } else if (*src == '+') { + c = ' '; + src++; + } else { + c = (unsigned char) *src++; + } + + if (c == '\0') + return -1; + + *dst++ = (char) c; + } + + *dst = '\0'; + + return 0; +} + +static int +hex_value(unsigned char c) +{ + if (c >= '0' && c <= '9') + return c - '0'; + + if (c >= 'a' && c <= 'f') + return c - 'a' + 10; + + if (c >= 'A' && c <= 'F') + return c - 'A' + 10; + + return -1; +} + +int +query_param(const char *query, + const char *name, + char *value, + size_t value_len) +{ + const char *p; + const char *eq; + const char *field_end; + size_t name_len; + size_t encoded_len; + char *encoded; + int found = 0; + + if (query == NULL || name == NULL || + value == NULL || value_len == 0) + return -1; + + name_len = strlen(name); + + p = query; + + while (*p != '\0') { + const char *field_start = p; + size_t field_len; + size_t key_len; + + field_end = strchr(p, '&'); + if (field_end == NULL) + field_end = p + strlen(p); + + field_len = (size_t)(field_end - field_start); + + eq = memchr(field_start, '=', field_len); + + if (eq != NULL) { + key_len = (size_t)(eq - field_start); + + if (key_len == name_len && + memcmp(field_start, name, name_len) == 0) { + + /* Reject duplicate parameters. */ + if (found) + return -1; + + encoded_len = field_len - key_len - 1; + + /* + * Need a writable, NUL-terminated copy because + * url_decode() works in-place. + */ + encoded = malloc(encoded_len + 1); + if (encoded == NULL) + return -1; + + memcpy(encoded, eq + 1, encoded_len); + encoded[encoded_len] = '\0'; + + if (url_decode(encoded) == -1) { + free(encoded); + return -1; + } + + if (strlen(encoded) >= value_len) { + free(encoded); + return -1; + } + + memcpy(value, encoded, strlen(encoded) + 1); + free(encoded); + + found = 1; + } + } + + if (*field_end == '\0') + break; + + p = field_end + 1; + } + + return found ? 0 : 1; +} diff --git a/src/url.h b/src/url.h @@ -0,0 +1,8 @@ +#ifndef URL_H +#define URL_H + +int query_param(const char *query, const char *name, char *value, size_t value_len); +char *url_encode(const char *); +int url_decode(char *); + +#endif diff --git a/tests/regression/delete.sh b/tests/regression/delete.sh @@ -0,0 +1,686 @@ +#!/bin/sh + +set -eu + +. "$(dirname "$0")/testlib.sh" + +CGI=${1:-build/delete.cgi} + +delete() { + env \ + SCRIPT_NAME=/default/delete.cgi \ + STORAGE_ROOT="$TESTROOT/storage" \ + SIMPLEFILES_ROOT=./html \ + SIMPLEFILES_CONFIG="$TEST_CONFIG" \ + REQUEST_METHOD=GET \ + QUERY_STRING="${1:-}" \ + REMOTE_USER=bob \ + "$CGI" +} + +delete_user() { + user=$1 + query=${2:-} + + env \ + SCRIPT_NAME=/default/delete.cgi \ + STORAGE_ROOT="$TESTROOT/storage" \ + SIMPLEFILES_ROOT=./html \ + SIMPLEFILES_CONFIG="$TEST_CONFIG" \ + REQUEST_METHOD=GET \ + QUERY_STRING="$query" \ + REMOTE_USER="$user" \ + "$CGI" +} + +delete_no_user() { + query=${1:-} + + ( + unset REMOTE_USER + + export SCRIPT_NAME=/default/delete.cgi + export STORAGE_ROOT="$TESTROOT/storage" + export SIMPLEFILES_ROOT=./html + export SIMPLEFILES_CONFIG="$TEST_CONFIG" + export REQUEST_METHOD=GET + export QUERY_STRING="$query" + + "$CGI" + ) +} + +delete_post() { + body=${1:-} + + printf '%s' "$body" | + env \ + SCRIPT_NAME=/default/delete.cgi \ + STORAGE_ROOT="$TESTROOT/storage" \ + REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \ + SIMPLEFILES_CONFIG="$TEST_CONFIG" \ + SIMPLEFILES_ROOT=./html \ + HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \ + REMOTE_USER=bob \ + REQUEST_METHOD=POST \ + CONTENT_LENGTH="$(printf '%s' "$body" | wc -c | tr -d ' ')" \ + "$CGI" +} + +delete_post_user() { + user=$1 + body=${2:-} + + printf '%s' "$body" | + env \ + SCRIPT_NAME=/default/delete.cgi \ + STORAGE_ROOT="$TESTROOT/storage" \ + REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \ + SIMPLEFILES_CONFIG="$TEST_CONFIG" \ + SIMPLEFILES_ROOT=./html \ + HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \ + REMOTE_USER="$user" \ + REQUEST_METHOD=POST \ + CONTENT_LENGTH="$(printf '%s' "$body" | wc -c | tr -d ' ')" \ + "$CGI" +} + +delete_post_no_user() { + body=${1:-} + + printf '%s' "$body" | + env \ + SCRIPT_NAME=/default/delete.cgi \ + STORAGE_ROOT="$TESTROOT/storage" \ + REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \ + SIMPLEFILES_CONFIG="$TEST_CONFIG" \ + SIMPLEFILES_ROOT=./html \ + HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \ + REQUEST_METHOD=POST \ + CONTENT_LENGTH="$(printf '%s' "$body" | wc -c | tr -d ' ')" \ + "$CGI" +} + +delete_post_origin() { + origin=$1 + body=${2:-} + + printf '%s' "$body" | + env \ + SCRIPT_NAME=/default/delete.cgi \ + STORAGE_ROOT="$TESTROOT/storage" \ + REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \ + SIMPLEFILES_CONFIG="$TEST_CONFIG" \ + SIMPLEFILES_ROOT=./html \ + HTTP_ORIGIN="$origin" \ + REMOTE_USER=bob \ + REQUEST_METHOD=POST \ + CONTENT_LENGTH="$(printf '%s' "$body" | wc -c | tr -d ' ')" \ + "$CGI" +} + +delete_post_no_origin() { + body=${1:-} + + printf '%s' "$body" | + env \ + SCRIPT_NAME=/default/delete.cgi \ + STORAGE_ROOT="$TESTROOT/storage" \ + REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \ + SIMPLEFILES_CONFIG="$TEST_CONFIG" \ + SIMPLEFILES_ROOT=./html \ + REMOTE_USER=bob \ + REQUEST_METHOD=POST \ + CONTENT_LENGTH="$(printf '%s' "$body" | wc -c | tr -d ' ')" \ + "$CGI" +} + +#################################################### + +# +# Confirmation page +# + +reset_testroot + +write_default_repo_config "ADMIN_USER=bob" + +printf 'hello\n' > "$TESTROOT/storage/default/file1.txt" +printf 'second\n' > "$TESTROOT/storage/default/file2.txt" + +check_status \ + "confirmation page" \ + "200 OK" \ + delete \ + "file=file1.txt" + +check_body \ + "filename shown" \ + "file1.txt" \ + delete \ + "file=file1.txt" + +check_body \ + "delete form" \ + '<form method="post"' \ + delete \ + "file=file1.txt" + +check_body \ + "cancel link" \ + "./" \ + delete \ + "file=file1.txt" + +check_body \ + "file parameter with extra query parameters" \ + "file2.txt" \ + delete \ + "foo=bar&file=file2.txt&baz=qux" + +# +# Parameter validation +# + +check_body \ + "missing file parameter" \ + "Missing filename" \ + delete \ + "foo=file1.txt" + +check_body \ + "empty file parameter" \ + "Invalid filename" \ + delete \ + "file=" + +check_body \ + "missing file" \ + "404 Not Found" \ + delete \ + "file=missing.txt" + +check_body \ + "invalid filename" \ + "400 Bad Request" \ + delete \ + "file=../passwd" + +check_body \ + "encoded path traversal" \ + "400 Bad Request" \ + delete \ + "file=%2e%2e/passwd" + +check_body \ + "mixed encoded traversal" \ + "400 Bad Request" \ + delete \ + "file=.%2e/passwd" + +check_body \ + "encoded NUL" \ + "400 Bad Request" \ + delete \ + "file=file%00.txt" + +check_body \ + "malformed percent encoding" \ + "400 Bad Request" \ + delete \ + "file=%" + +check_body \ + "short percent encoding" \ + "400 Bad Request" \ + delete \ + "file=%2" + +check_body \ + "invalid percent encoding" \ + "400 Bad Request" \ + delete \ + "file=%ZZ" + +check_status \ + "reject duplicate file parameter" \ + "400 Bad Request" \ + delete \ + "file=file1.txt&file=file2.txt" + +check_body \ + "file parameter after another parameter" \ + "200 OK" \ + delete \ + "foo=bar&file=file2.txt" + +check_body \ + "file parameter before another parameter" \ + "200 OK" \ + delete \ + "file=file2.txt&foo=bar" + +# +# Delete +# + +check_body \ + "encoded POST path traversal" \ + "400 Bad Request" \ + delete_post \ + "file=%2e%2e/passwd" + +check_body \ + "encoded POST NUL" \ + "400 Bad Request" \ + delete_post \ + "file=file%00.txt" + +check_body \ + "reject duplicate POST file parameter" \ + "400 Bad Request" \ + delete_post \ + "file=file1.txt&file=file2.txt" + +check_redirect \ + "delete redirects" \ + "303 See Other" \ + "./" \ + delete_post \ + "file=file1.txt" + +check_file_missing \ + "file deleted" \ + "$TESTROOT/storage/default/file1.txt" + +check_body \ + "delete missing file" \ + "File not found" \ + delete_post \ + "file=file1.txt" + +check_body \ + "malformed POST percent encoding" \ + "400 Bad Request" \ + delete_post \ + "file=%" + +check_body \ + "invalid POST percent encoding" \ + "400 Bad Request" \ + delete_post \ + "file=%ZZ" + +# +# Filename decoding +# + +space_file="$TESTROOT/storage/default/hello world.txt" + +printf 'hello' > "$space_file" + +check_redirect \ + "plus-decoded delete redirects" \ + "303 See Other" \ + "./" \ + delete_post \ + "file=hello+world.txt" + +check_file_missing \ + "plus-decoded file deleted" \ + "$space_file" + +special_file="$TESTROOT/storage/default/<test>&file.txt" +special_body='file=%3Ctest%3E%26file.txt' + +printf 'hello' > "$special_file" + +check_body \ + "delete encoded filename" \ + "Delete " \ + delete \ + "$special_body" + +printf 'hello' > "$special_file" + +check_redirect \ + "encoded delete redirects" \ + "303 See Other" \ + "./" \ + delete_post \ + "$special_body" + +check_file_missing \ + "encoded file deleted" \ + "$special_file" + +# +# Protected file +# + +write_default_repo_config "ADMIN_USER=bob" + +check_status \ + "reject .config delete" \ + "400 Bad Request" \ + delete \ + "file=.config" + +check_file_exists \ + ".config still exists" \ + "$TESTROOT/storage/default/.config" + +check_status \ + "reject .config POST delete" \ + "400 Bad Request" \ + delete_post \ + "file=.config" + +check_file_exists \ + ".config survives POST" \ + "$TESTROOT/storage/default/.config" + +# +# CSRF +# + +reset_testroot + +write_default_repo_config "ADMIN_USER=bob" + +printf 'hello\n' > "$TESTROOT/storage/default/csrf.txt" + +check_status \ + "accept configured Origin" \ + "303 See Other" \ + delete_post_origin \ + "http://localhost:8000" \ + "file=csrf.txt" + +check_file_missing \ + "configured Origin deletes file" \ + "$TESTROOT/storage/default/csrf.txt" + +reset_testroot + +write_default_repo_config "ADMIN_USER=bob" + +printf 'hello\n' > "$TESTROOT/storage/default/csrf.txt" + +check_status \ + "reject missing Origin" \ + "403 Forbidden" \ + delete_post_no_origin \ + "file=csrf.txt" + +check_file_exists \ + "missing Origin preserves file" \ + "$TESTROOT/storage/default/csrf.txt" + +reset_testroot + +write_default_repo_config "ADMIN_USER=bob" + +printf 'hello\n' > "$TESTROOT/storage/default/csrf.txt" + +check_status \ + "reject wrong Origin" \ + "403 Forbidden" \ + delete_post_origin \ + "https://evil.example" \ + "file=csrf.txt" + +check_file_exists \ + "wrong Origin preserves file" \ + "$TESTROOT/storage/default/csrf.txt" + +check_status \ + "reject Origin prefix" \ + "403 Forbidden" \ + delete_post_origin \ + "https://localhost.evil.example" \ + "file=csrf.txt" + +check_status \ + "reject Origin in path" \ + "403 Forbidden" \ + delete_post_origin \ + "https://evil.example/?origin=https://localhost:8000" \ + "file=csrf.txt" + +check_status \ + "reject wrong Origin scheme" \ + "403 Forbidden" \ + delete_post_origin \ + "https://localhost:8000" \ + "file=csrf.txt" + +reset_testroot + +write_global_config "CSRF_ORIGIN=https://files.example" +write_default_repo_config "ADMIN_USER=bob" + +printf 'hello\n' > "$TESTROOT/storage/default/csrf.txt" + +check_status \ + "accept custom Origin" \ + "303 See Other" \ + delete_post_origin \ + "https://files.example" \ + "file=csrf.txt" + +check_file_missing \ + "custom Origin deletes file" \ + "$TESTROOT/storage/default/csrf.txt" + +reset_testroot + +write_global_config "CSRF_ORIGIN=https://files.example" +write_default_repo_config "ADMIN_USER=bob" + +printf 'hello\n' > "$TESTROOT/storage/default/csrf.txt" + +check_status \ + "reject old Origin" \ + "403 Forbidden" \ + delete_post_origin \ + "https://localhost" \ + "file=csrf.txt" + +check_file_exists \ + "old Origin preserves file" \ + "$TESTROOT/storage/default/csrf.txt" + +# +# Read-only repository +# + +reset_testroot + +write_default_repo_config "READ_ONLY=1" + +printf 'readonly' > "$TESTROOT/storage/default/readonly.txt" + +check_body \ + "readonly GET rejected" \ + "read-only" \ + delete \ + "file=readonly.txt" + +check_file_exists \ + "readonly GET preserves file" \ + "$TESTROOT/storage/default/readonly.txt" + +check_status \ + "readonly POST rejected" \ + "403 Forbidden" \ + delete_post \ + "file=readonly.txt" + +check_file_exists \ + "readonly POST preserves file" \ + "$TESTROOT/storage/default/readonly.txt" + +# +# Access control +# + +reset_testroot + +write_default_repo_config "" + +printf 'legacy\n' > "$TESTROOT/storage/default/legacy.txt" + +check_status \ + "delete rejected without ADMIN_USER" \ + "403 Forbidden" \ + delete_post \ + "file=legacy.txt" + +check_file_exists \ + "no ADMIN_USER preserves file" \ + "$TESTROOT/storage/default/legacy.txt" + +reset_testroot + +write_default_repo_config "ADMIN_USER=bob" + +printf 'admin\n' > "$TESTROOT/storage/default/admin.txt" + +check_status \ + "delete allowed for admin" \ + "303 See Other" \ + delete_post_user \ + "bob" \ + "file=admin.txt" + +check_file_missing \ + "admin delete succeeded" \ + "$TESTROOT/storage/default/admin.txt" + +reset_testroot + +write_default_repo_config "ADMIN_USER=bob" + +printf 'readonly\n' > "$TESTROOT/storage/default/readonly.txt" + +check_status \ + "delete rejected for non-admin" \ + "403 Forbidden" \ + delete_post_user \ + "alice" \ + "file=readonly.txt" + +check_file_exists \ + "non-admin preserves file" \ + "$TESTROOT/storage/default/readonly.txt" + +# +# GET access control +# + +reset_testroot + +write_default_repo_config "ADMIN_USER=bob" + +printf 'hello' > "$TESTROOT/storage/default/file.txt" + +check_status \ + "non-admin GET rejected" \ + "403 Forbidden" \ + delete_user \ + "alice" \ + "file=file.txt" + +check_file_exists \ + "non-admin GET preserves file" \ + "$TESTROOT/storage/default/file.txt" + +reset_testroot + +write_default_repo_config "ADMIN_USER=bob" + +printf 'hello' > "$TESTROOT/storage/default/file.txt" + +check_status \ + "anonymous GET rejected" \ + "403 Forbidden" \ + delete_no_user \ + "file=file.txt" + +check_file_exists \ + "anonymous GET preserves file" \ + "$TESTROOT/storage/default/file.txt" + +# +# Anonymous read does not grant write access +# + +reset_testroot + +write_default_repo_config "ADMIN_USER=bob" +write_global_config "ANONYMOUS_READ=1" + +printf 'hello' > "$TESTROOT/storage/default/file.txt" + +check_status \ + "anonymous GET rejected despite anonymous read" \ + "403 Forbidden" \ + delete_no_user \ + "file=file.txt" + +check_status \ + "anonymous POST rejected despite anonymous read" \ + "403 Forbidden" \ + delete_post_no_user \ + "file=file.txt" + +check_file_exists \ + "anonymous source preserved" \ + "$TESTROOT/storage/default/file.txt" + +check_file_missing \ + "anonymous destination not created" \ + "$TESTROOT/storage/default/renamed.txt" + +# +# Response generation +# + +reset_testroot + +write_default_repo_config "ADMIN_USER=bob" + +printf 'hello\n' > "$TESTROOT/storage/default/file1.txt" + +mkdir -p "$TESTROOT/html" + +printf '{{UNKNOWN_VARIABLE}}' > "$TESTROOT/html/delete.html" + +check_status \ + "template failure returns 500" \ + "500 Internal Server Error" \ + env \ + SCRIPT_NAME=/default/delete.cgi \ + STORAGE_ROOT="$TESTROOT/storage" \ + SIMPLEFILES_ROOT="$TESTROOT/html" \ + SIMPLEFILES_CONFIG="$TEST_CONFIG" \ + REQUEST_METHOD=GET \ + QUERY_STRING="file=file1.txt" \ + REMOTE_USER=bob \ + "$CGI" + +check_not_body \ + "template failure does not return 200" \ + "Status: 200 OK" \ + env \ + SCRIPT_NAME=/default/delete.cgi \ + STORAGE_ROOT="$TESTROOT/storage" \ + SIMPLEFILES_ROOT="$TESTROOT/html" \ + SIMPLEFILES_CONFIG="$TEST_CONFIG" \ + REQUEST_METHOD=GET \ + QUERY_STRING="file=file1.txt" \ + REMOTE_USER=bob \ + "$CGI" + +test_result diff --git a/tests/regression/download.sh b/tests/regression/download.sh @@ -0,0 +1,471 @@ +#!/bin/sh + +set -eu + +. "$(dirname "$0")/testlib.sh" + +CGI=${1:-build/download.cgi} + +download() { + env \ + SCRIPT_NAME=/default/download.cgi \ + STORAGE_ROOT="$TESTROOT/storage" \ + SIMPLEFILES_CONFIG="$TEST_CONFIG" \ + REQUEST_METHOD=GET \ + QUERY_STRING="${1:-}" \ + REMOTE_USER=bob \ + "$CGI" +} + +download_post() { + env \ + SCRIPT_NAME=/default/download.cgi \ + STORAGE_ROOT="$TESTROOT/storage" \ + SIMPLEFILES_CONFIG="$TEST_CONFIG" \ + REQUEST_METHOD=POST \ + CONTENT_LENGTH=0 \ + QUERY_STRING="${1:-}" \ + REMOTE_USER=bob \ + "$CGI" +} + +download_user() { + user=$1 + + env \ + SCRIPT_NAME=/default/download.cgi \ + STORAGE_ROOT="$TESTROOT/storage" \ + SIMPLEFILES_CONFIG="$TEST_CONFIG" \ + REQUEST_METHOD=GET \ + QUERY_STRING="file=download.txt" \ + REMOTE_USER="$user" \ + "$CGI" +} + +download_no_user() { + query=${1:-} + + ( + unset REMOTE_USER + + export SCRIPT_NAME=/default/download.cgi + export STORAGE_ROOT="$TESTROOT/storage" + export SIMPLEFILES_CONFIG="$TEST_CONFIG" + export REQUEST_METHOD=GET + export QUERY_STRING="$query" + + "$CGI" + ) +} + +#################################################### + +reset_testroot + +write_default_repo_config "ADMIN_USER=bob" + +echo "hello world" > "$TESTROOT/storage/default/file1.txt" +echo "hello world" > "$TESTROOT/storage/default/file2.txt" +echo "special filename" > "$TESTROOT/storage/default/<test>&file.txt" +printf 'space filename\n' > "$TESTROOT/storage/default/hello world.txt" + +dd if=/dev/urandom \ + of="$TESTROOT/storage/default/binary.bin" \ + bs=1024 count=1 2>/dev/null + +# +# Valid download +# + +check_status \ + "valid download status" \ + "200 OK" \ + download \ + "file=file1.txt" + +check_header \ + "content-type header" \ + "Content-Type" \ + "application/octet-stream" \ + download \ + "file=file1.txt" + +check_header \ + "content-disposition header" \ + "Content-Disposition" \ + 'attachment; filename="file1.txt"' \ + download \ + "file=file1.txt" + +check_header \ + "content-length header" \ + "Content-Length" \ + "12" \ + download \ + "file=file1.txt" + +# +# Binary file +# + +check_header \ + "binary content-length" \ + "Content-Length" \ + "1024" \ + download \ + "file=binary.bin" + +# +# Missing / invalid files +# + +check_body \ + "missing file" \ + "404 Not Found" \ + download \ + "file=nonexistent.txt" + +printf 'QUOTA=1024\n' > "$TESTROOT/storage/default/.config" + +check_body \ + ".config not downloadable" \ + "400 Bad Request" \ + download \ + "file=.config" + +check_body \ + "path traversal ../" \ + "400 Bad Request" \ + download \ + "file=../passwd" + +check_body \ + "path traversal .." \ + "400 Bad Request" \ + download \ + "file=.." + +check_body \ + "path traversal with slash" \ + "400 Bad Request" \ + download \ + "file=subdir/file.txt" + +check_body \ + "encoded dot traversal" \ + "400 Bad Request" \ + download \ + "file=%2e%2e/passwd" + +check_body \ + "mixed encoded traversal" \ + "400 Bad Request" \ + download \ + "file=.%2e/passwd" + +check_body \ + "mixed encoded slash traversal" \ + "400 Bad Request" \ + download \ + "file=..%2Fpasswd" + +check_body \ + "encoded nested traversal" \ + "400 Bad Request" \ + download \ + "file=%2e%2e%2f%2e%2e%2fpasswd" + +check_body \ + "encoded NUL" \ + "400 Bad Request" \ + download \ + "file=file%00.txt" + +check_body \ + "malformed percent encoding" \ + "400 Bad Request" \ + download \ + "file=file%ZZ.txt" + +check_body \ + "incomplete percent encoding" \ + "400 Bad Request" \ + download \ + "file=file%2.txt" + +check_body \ + "empty filename" \ + "400 Bad Request" \ + download \ + "file=" + +check_body \ + "missing query string" \ + "400 Bad Request" \ + download + +check_body \ + "missing file parameter" \ + "Missing filename" \ + download \ + "foo=bar" + +check_body \ + "file parameter without equals" \ + "Missing filename" \ + download \ + "file" + +check_body \ + "empty file parameter" \ + "Invalid filename" \ + download \ + "file=" + +check_body \ + "empty file among parameters" \ + "Invalid filename" \ + download \ + "foo=bar&file=&baz=qux" + +check_status \ + "file parameter after another parameter" \ + "200 OK" \ + download \ + "foo=bar&file=file1.txt" + +check_status \ + "file parameter before another parameter" \ + "200 OK" \ + download \ + "file=file1.txt&foo=bar" + +check_status \ + "file parameter with extra query parameters" \ + "200 OK" \ + download \ + "foo=bar&file=file1.txt&baz=qux" + +check_status \ + "reject duplicate file parameter" \ + "400 Bad Request" \ + download \ + "file=file1.txt&file=file2.txt" + +check_status \ + "reject duplicate file parameter reversed" \ + "400 Bad Request" \ + download \ + "file=file2.txt&file=file1.txt" + +# +# Invalid method +# + +check_body \ + "post not allowed" \ + "GET required" \ + download_post \ + "file=file1.txt" + +# +# Directory +# + +mkdir -p "$TESTROOT/storage/default/subdir" + +check_body \ + "directory not downloadable" \ + "404 Not Found" \ + download \ + "file=subdir" + +check_body \ + "encoded path traversal" \ + "400 Bad Request" \ + download \ + "file=subdir%2ffile.txt" + +# +# Special filenames +# + +check_body \ + "encoded special filename" \ + "200 OK" \ + download \ + "file=%3Ctest%3E%26file.txt" + +check_body \ + "plus decoded to space" \ + "200 OK" \ + download \ + "file=hello+world.txt" + +check_body \ + "plus decoded filename content" \ + "space filename" \ + download \ + "file=hello+world.txt" + +# +# Header injection +# + +check_body \ + "encoded CR rejected" \ + "400 Bad Request" \ + download \ + "file=foo%0d%0aX-Test%3A%20injected.txt" + +check_body \ + "encoded LF rejected" \ + "400 Bad Request" \ + download \ + "file=foo%0aX-Test%3A%20injected.txt" + +# +# Symlink outside repository +# + +reset_testroot + +write_default_repo_config "ADMIN_USER=bob" + +mkdir -p "$TESTROOT/storage/other" + +printf 'secret\n' > "$TESTROOT/storage/other/secret.txt" + +ln -s "$TESTROOT/storage/other/secret.txt" \ + "$TESTROOT/storage/default/linked.txt" + +check_body \ + "symlink outside repository" \ + "404 Not Found" \ + download \ + "file=linked.txt" + +check_not_body \ + "symlink target not exposed" \ + "secret" \ + download \ + "file=linked.txt" + +# +# Authentication +# + +reset_testroot + +write_default_repo_config "" + +printf '%s' 'download content' \ + > "$TESTROOT/storage/default/download.txt" + +check_status \ + "anonymous download rejected without ADMIN_USER" \ + "403 Forbidden" \ + download_no_user \ + "file=download.txt" + +check_file \ + "anonymous download preserves file" \ + "$TESTROOT/storage/default/download.txt" \ + "download content" + +# +# ADMIN_USER configured: admin user allowed +# + +reset_testroot + +write_default_repo_config "ADMIN_USER=bob" + +printf '%s' 'download content' \ + > "$TESTROOT/storage/default/download.txt" + +check_status \ + "admin user allowed to download" \ + "200 OK" \ + download_user \ + "bob" + +check_body \ + "admin download succeeds" \ + "download content" \ + download_user \ + "bob" + +# +# ADMIN_USER configured: non-admin still has read access +# + +reset_testroot + +write_default_repo_config "ADMIN_USER=bob" + +printf '%s' 'download content' \ + > "$TESTROOT/storage/default/download.txt" + +check_status \ + "non-admin user allowed to download" \ + "200 OK" \ + download_user \ + "alice" + +check_body \ + "non-admin download succeeds" \ + "download content" \ + download_user \ + "alice" + +# +# Anonymous access with ADMIN_USER +# + +reset_testroot + +write_default_repo_config "ADMIN_USER=bob" + +printf '%s' 'download content' \ + > "$TESTROOT/storage/default/download.txt" + +check_status \ + "anonymous download rejected" \ + "403 Forbidden" \ + download_no_user \ + "file=download.txt" + +check_file \ + "anonymous download preserves file" \ + "$TESTROOT/storage/default/download.txt" \ + "download content" + +# +# Anonymous read access +# + +reset_testroot + +write_default_repo_config "ADMIN_USER=bob" + +write_global_config "ANONYMOUS_READ=1" + +printf '%s' 'download content' \ + > "$TESTROOT/storage/default/download.txt" + +check_status \ + "anonymous download allowed with anonymous read" \ + "200 OK" \ + download_no_user \ + "file=download.txt" + +check_body \ + "anonymous download succeeds with anonymous read" \ + "download content" \ + download_no_user \ + "file=download.txt" + +test_result + +test_result diff --git a/tests/regression/httpd.sh b/tests/regression/httpd.sh @@ -0,0 +1,71 @@ +#!/bin/sh + +set -eu + +PASS=0 +FAIL=0 + +HTTP_URL=${HTTP_URL:-http://127.0.0.1:8000} +UPLOAD="$HTTP_URL/default/upload.cgi" + +TMPDIR=${TMPDIR:-/tmp} + +fixture() { + file="$TMPDIR/upload-test.txt" + + printf '%s\n' "hello upload test" > "$file" + + printf '%s\n' "$file" +} + +check() { + name=$1 + expected=$2 + shift 2 + + printf '%-35s' "$name" + + output=$("$@" 2>&1 || true) + + if printf '%s\n' "$output" | grep -q "$expected"; then + printf '%s\n' "ok" + PASS=$((PASS + 1)) + else + printf '%s\n' "failed" + printf ' expected: %s\n' "$expected" + printf '%s\n' "$output" + FAIL=$((FAIL + 1)) + fi +} + +test_upload() { + file=$1 + + curl -s -i \ + -F "file=@$file" \ + "$UPLOAD" +} + +main() { + file=$(fixture) + + check \ + "upload through httpd" \ + "303 See Other" \ + test_upload \ + "$file" + + check \ + "redirect location" \ + "Location: list.cgi" \ + test_upload \ + "$file" + + rm -f "$file" + + printf '\n%s passed, %s failed\n' "$PASS" "$FAIL" + + [ "$FAIL" -eq 0 ] +} + +main "$@" diff --git a/tests/regression/list.sh b/tests/regression/list.sh @@ -0,0 +1,403 @@ +#!/bin/sh + +set -eu + +. "$(dirname "$0")/testlib.sh" + +CGI=${1:-build/list.cgi} + +list() { + env \ + STORAGE_ROOT="$TESTROOT/storage" \ + SIMPLEFILES_ROOT=./html \ + REMOTE_USER=alice \ + REQUEST_METHOD=GET \ + "$CGI" +} + +list_repo() { + env \ + STORAGE_ROOT="$TESTROOT/storage" \ + SIMPLEFILES_ROOT=./html \ + SCRIPT_NAME="/$1/list.cgi" \ + REMOTE_USER=alice \ + REQUEST_METHOD=GET \ + "$CGI" +} + +list_user() { + user=$1 + + env \ + SCRIPT_NAME=/default/list.cgi \ + STORAGE_ROOT="$TESTROOT/storage" \ + SIMPLEFILES_ROOT=./html \ + REQUEST_METHOD=GET \ + REMOTE_USER="$user" \ + "$CGI" +} + +list_no_user() { + ( + unset REMOTE_USER + + export SCRIPT_NAME=/default/list.cgi + export SIMPLEFILES_ROOT=./html + export SIMPLEFILES_CONFIG="$TEST_CONFIG" + export STORAGE_ROOT="$TESTROOT/storage" + export REQUEST_METHOD=GET + + "$CGI" + ) +} + +list_no_user_root() { + root=$1 + + ( + unset REMOTE_USER + + export SCRIPT_NAME=/default/list.cgi + export SIMPLEFILES_ROOT="$root" + export SIMPLEFILES_CONFIG="$TEST_CONFIG" + export STORAGE_ROOT="$TESTROOT/storage" + export REQUEST_METHOD=GET + + "$CGI" + ) +} + +############################################################################## + +reset_testroot + +check_body \ + "empty page" \ + "SimpleFiles" \ + list + +check_body \ + "table header" \ + "<th>Name</th>" \ + list + +############################################################################## + +printf 'dummy\n' > "$TESTROOT/storage/default/file1.txt" + +check_body \ + "one filename" \ + "file1.txt" \ + list + +############################################################################## + +printf 'dummy\n' > "$TESTROOT/storage/default/file2.txt" + +check_body \ + "second filename" \ + "file2.txt" \ + list + +############################################################################## + +mkdir "$TESTROOT/storage/default/subdir" + +check_not_body \ + "ignore directories" \ + "subdir" \ + list + +############################################################################## + +mkdir -p "$TESTROOT/storage/music" +mkdir -p "$TESTROOT/storage/poetry" + +printf 'music\n' > "$TESTROOT/storage/music/song.mp3" +printf 'poetry\n' > "$TESTROOT/storage/poetry/poem.txt" + +check_body \ + "music repository" \ + "song.mp3" \ + list_repo music + +check_not_body \ + "music excludes poetry" \ + "poem.txt" \ + list_repo music + +check_body \ + "poetry repository" \ + "poem.txt" \ + list_repo poetry + +check_not_body \ + "poetry excludes music" \ + "song.mp3" \ + list_repo poetry + +############################################################################## + +mkdir -p "$TESTROOT/secret" + +printf 'secret\n' > "$TESTROOT/secret/password.txt" + +check_not_body \ + "reject repository traversal" \ + "password.txt" \ + list_repo "../secret" + +############################################################################## + +# Test HTML escaping of filenames + +escaped_filename='&lt;test&gt;&amp;file.txt' +raw_filename='<test>&file.txt' + +printf 'dummy\n' > "$TESTROOT/storage/default/$raw_filename" + +check_body \ + "escape filename in html" \ + "$escaped_filename" \ + list + +check_body \ + "url encode filename" \ + "download.cgi?file=%3Ctest%3E%26file.txt" \ + list + +############################################################################## + +printf 'QUOTA=1024\n' > "$TESTROOT/storage/default/.config" + +check_not_body \ + "hide .config" \ + ".config" \ + list + +############################################################################## +# +# Read-only repository +# + +reset_testroot + +printf 'READ_ONLY=1\n' > "$TESTROOT/storage/default/.config" +printf 'hello\n' > "$TESTROOT/storage/default/file1.txt" + +check_body \ + "readonly list still shows files" \ + "file1.txt" \ + list + +check_not_body \ + "readonly list hides upload link" \ + "upload.cgi" \ + list + +check_not_body \ + "readonly list hides delete link" \ + "delete.cgi" \ + list + +#################################################### +# +# Authentication-aware access control +# + +# 1 Anonymous access is rejected by default + +reset_testroot + +write_default_repo_config "" + +printf '%s' 'list content' \ + > "$TESTROOT/storage/default/file.txt" + +check_status \ + "anonymous listing rejected by default" \ + "403 Forbidden" \ + list_no_user + +# 2 Anonymous listing is allowed when explicitly set + +reset_testroot + +write_default_repo_config "" + +printf '%s' 'list content' \ + > "$TESTROOT/storage/default/file.txt" + +write_global_config "ANONYMOUS_READ=1" + +check_status \ + "anonymous listing allowed with ANONYMOUS_READ" \ + "200 OK" \ + list_no_user + +check_body \ + "anonymous listing with ANONYMOUS_READ succeeds" \ + "file.txt" \ + list_no_user + +check_not_body \ + "anonymous listing allowed hides upload link" \ + "upload.cgi" \ + list_no_user + +check_not_body \ + "anonymous listing allowed hides delete link" \ + "delete.cgi?file=file.txt" \ + list_no_user + +# 3 Authenticated non-admin listing remains allowed + +reset_testroot + +write_default_repo_config "" + +printf '%s' 'list content' \ + > "$TESTROOT/storage/default/file.txt" + +check_status \ + "authenticated listing allowed without ADMIN_USER" \ + "200 OK" \ + list_user \ + "alice" + +check_body \ + "authenticated listing without ADMIN_USER succeeds" \ + "file.txt" \ + list_user \ + "alice" + +check_not_body \ + "non-admin listing hides upload link" \ + "upload.cgi" \ + list_user \ + "alice" + +check_not_body \ + "non-admin listing hides delete link" \ + "delete.cgi?file=file.txt" \ + list_user \ + "alice" + +# 4 Matching admin user is allowed + +reset_testroot + +write_default_repo_config "ADMIN_USER=bob" + +printf '%s' 'list content' \ + > "$TESTROOT/storage/default/file.txt" + +check_status \ + "admin user allowed to list" \ + "200 OK" \ + list_user \ + "bob" + +check_body \ + "admin listing succeeds" \ + "file.txt" \ + list_user \ + "bob" + +check_body \ + "admin listing shows upload link" \ + "upload.cgi" \ + list_user \ + "bob" + +check_body \ + "admin listing shows delete link" \ + "delete.cgi?file=file.txt" \ + list_user \ + "bob" + +# 5 Non-admin authenticated user can read but cannot write + +reset_testroot + +write_default_repo_config "ADMIN_USER=bob" + +printf '%s' 'list content' \ + > "$TESTROOT/storage/default/file.txt" + +check_status \ + "non-admin user allowed to list" \ + "200 OK" \ + list_user \ + "alice" + +check_body \ + "non-admin listing succeeds" \ + "file.txt" \ + list_user \ + "alice" + +check_not_body \ + "non-admin listing hides upload link" \ + "upload.cgi" \ + list_user \ + "alice" + +check_not_body \ + "non-admin listing hides delete link" \ + "delete.cgi?file=file.txt" \ + list_user \ + "alice" + +# 6 ANONYMOUS_READ allows anonymous access even when ADMIN_USER is configured + +reset_testroot + +write_default_repo_config "ADMIN_USER=bob" + +printf '%s' 'list content' \ + > "$TESTROOT/storage/default/file.txt" + +write_global_config "ANONYMOUS_READ=1" + +check_status \ + "anonymous listing allowed with ADMIN_USER and ANONYMOUS_READ" \ + "200 OK" \ + list_no_user + +check_body \ + "anonymous listing succeeds with ADMIN_USER and ANONYMOUS_READ" \ + "file.txt" \ + list_no_user + +check_not_body \ + "anonymous listing hides upload link" \ + "upload.cgi" \ + list_no_user + +check_not_body \ + "anonymous listing hides delete link" \ + "delete.cgi?file=file.txt" \ + list_no_user + +# +# Response generation +# + +reset_testroot + +write_default_repo_config "" +write_global_config "ANONYMOUS_READ=1" + +printf 'hello\n' > "$TESTROOT/storage/default/file1.txt" + +mkdir -p "$TESTROOT/html" +printf '{{UNKNOWN_VARIABLE}}' > "$TESTROOT/html/list.html" + +check_status \ + "template failure returns 500" \ + "500 Internal Server Error" \ + list_no_user_root \ + "$TESTROOT/html" + +test_result diff --git a/tests/regression/rename.sh b/tests/regression/rename.sh @@ -0,0 +1,663 @@ +#!/bin/sh + +# +# Regression tests for rename.cgi +# + +set -eu + +. "$(dirname "$0")/testlib.sh" + +CGI=${1:-build/rename.cgi} + +############################################################################## +# HTTP helpers +############################################################################## + +rename() { + env \ + SCRIPT_NAME=/default/rename.cgi \ + STORAGE_ROOT="$TESTROOT/storage" \ + SIMPLEFILES_ROOT=./html \ + REQUEST_METHOD=GET \ + QUERY_STRING="${1:-}" \ + REMOTE_USER=bob \ + "$CGI" +} + +rename_user() { + user=$1 + query=${2:-} + + env \ + SCRIPT_NAME=/default/rename.cgi \ + STORAGE_ROOT="$TESTROOT/storage" \ + SIMPLEFILES_ROOT=./html \ + REQUEST_METHOD=GET \ + QUERY_STRING="$query" \ + REMOTE_USER="$user" \ + "$CGI" +} + +rename_no_user() { + query=${1:-} + + ( + unset REMOTE_USER + + export SCRIPT_NAME=/default/rename.cgi + export STORAGE_ROOT="$TESTROOT/storage" + export SIMPLEFILES_ROOT=./html + export SIMPLEFILES_CONFIG="$TEST_CONFIG" + export REQUEST_METHOD=GET + export QUERY_STRING="$query" + + "$CGI" + ) +} + +rename_post() { + body=${1:-} + + printf '%s' "$body" | + env \ + SCRIPT_NAME=/default/rename.cgi \ + STORAGE_ROOT="$TESTROOT/storage" \ + REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \ + SIMPLEFILES_CONFIG="$TEST_CONFIG" \ + SIMPLEFILES_ROOT=./html \ + HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \ + REMOTE_USER=bob \ + REQUEST_METHOD=POST \ + CONTENT_LENGTH="$(printf '%s' "$body" | wc -c | tr -d ' ')" \ + "$CGI" +} + +rename_post_user() { + user=$1 + body=${2:-} + + printf '%s' "$body" | + env \ + SCRIPT_NAME=/default/rename.cgi \ + STORAGE_ROOT="$TESTROOT/storage" \ + REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \ + SIMPLEFILES_CONFIG="$TEST_CONFIG" \ + SIMPLEFILES_ROOT=./html \ + HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \ + REMOTE_USER="$user" \ + REQUEST_METHOD=POST \ + CONTENT_LENGTH="$(printf '%s' "$body" | wc -c | tr -d ' ')" \ + "$CGI" +} + +rename_post_no_user() { + body=${1:-} + + printf '%s' "$body" | + env \ + SCRIPT_NAME=/default/rename.cgi \ + STORAGE_ROOT="$TESTROOT/storage" \ + REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \ + SIMPLEFILES_CONFIG="$TEST_CONFIG" \ + SIMPLEFILES_ROOT=./html \ + HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \ + REQUEST_METHOD=POST \ + CONTENT_LENGTH="$(printf '%s' "$body" | wc -c | tr -d ' ')" \ + "$CGI" +} + +rename_post_origin() { + origin=$1 + body=${2:-} + + printf '%s' "$body" | + env \ + SCRIPT_NAME=/default/rename.cgi \ + STORAGE_ROOT="$TESTROOT/storage" \ + REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \ + SIMPLEFILES_CONFIG="$TEST_CONFIG" \ + SIMPLEFILES_ROOT=./html \ + HTTP_ORIGIN="$origin" \ + REMOTE_USER=bob \ + REQUEST_METHOD=POST \ + CONTENT_LENGTH="$(printf '%s' "$body" | wc -c | tr -d ' ')" \ + "$CGI" +} + +rename_post_no_origin() { + body=${1:-} + + printf '%s' "$body" | + env \ + SCRIPT_NAME=/default/rename.cgi \ + STORAGE_ROOT="$TESTROOT/storage" \ + REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \ + SIMPLEFILES_CONFIG="$TEST_CONFIG" \ + SIMPLEFILES_ROOT=./html \ + REMOTE_USER=bob \ + REQUEST_METHOD=POST \ + CONTENT_LENGTH="$(printf '%s' "$body" | wc -c | tr -d ' ')" \ + "$CGI" +} + +rename_template_failure() { + env \ + SCRIPT_NAME=/default/rename.cgi \ + STORAGE_ROOT="$TESTROOT/storage" \ + SIMPLEFILES_ROOT="$TESTROOT/html" \ + SIMPLEFILES_CONFIG="$TEST_CONFIG" \ + REQUEST_METHOD=GET \ + QUERY_STRING="file=file1.txt" \ + REMOTE_USER=bob \ + "$CGI" +} + +############################################################################## +# Basic functionality +############################################################################## + +reset_testroot + +write_default_repo_config "ADMIN_USER=bob" + +printf 'hello' > "$TESTROOT/storage/default/file1.txt" +printf 'second' > "$TESTROOT/storage/default/file2.txt" + +check_status \ + "rename confirmation page" \ + "200 OK" \ + rename \ + "file=file1.txt" + +check_body \ + "old filename shown" \ + "file1.txt" \ + rename \ + "file=file1.txt" + +check_body \ + "rename form" \ + '<form method="post"' \ + rename \ + "file=file1.txt" + +check_body \ + "new name field" \ + "new_filename" \ + rename \ + "file=file1.txt" + +check_redirect \ + "rename redirects" \ + "303 See Other" \ + "./" \ + rename_post \ + "file=file1.txt&new_filename=renamed.txt" + +check_file_exists \ + "new filename exists" \ + "$TESTROOT/storage/default/renamed.txt" + +check_file_missing \ + "old filename removed" \ + "$TESTROOT/storage/default/file1.txt" + +check_file \ + "contents preserved" \ + "$TESTROOT/storage/default/renamed.txt" \ + "hello" + +############################################################################## +# Parameter validation +############################################################################## + +check_body \ + "missing file parameter" \ + "Missing filename" \ + rename \ + "new_filename=file2.txt" + +check_body \ + "empty file parameter" \ + "Invalid filename" \ + rename \ + "file=&new_filename=file2.txt" + +check_body \ + "missing new name" \ + "Missing new filename" \ + rename_post \ + "file=file1.txt" + +check_body \ + "empty new name" \ + "Invalid filename" \ + rename_post \ + "file=file1.txt&new_filename=" + +check_status \ + "reject duplicate file parameter" \ + "400 Bad Request" \ + rename \ + "file=file1.txt&file=file2.txt" + +check_status \ + "reject duplicate new_filename parameter" \ + "400 Bad Request" \ + rename_post \ + "file=file1.txt&new_filename=a.txt&new_filename=b.txt" + +############################################################################## +# Filename validation +############################################################################## + +check_body \ + "reject source traversal" \ + "400 Bad Request" \ + rename \ + "file=../passwd" + +check_body \ + "reject destination traversal" \ + "400 Bad Request" \ + rename_post \ + "file=file2.txt&new_filename=../passwd" + +check_body \ + "reject encoded traversal" \ + "400 Bad Request" \ + rename_post \ + "file=file2.txt&new_filename=%2e%2e/passwd" + +check_body \ + "reject encoded NUL destination" \ + "400 Bad Request" \ + rename_post \ + "file=file2.txt&new_filename=test%00.txt" + +check_body \ + "reject malformed percent encoding" \ + "400 Bad Request" \ + rename_post \ + "file=file2.txt&new_filename=%ZZ" + +############################################################################## +# Rename failures +############################################################################## + +printf 'original' > "$TESTROOT/storage/default/source.txt" +printf 'destination' > "$TESTROOT/storage/default/destination.txt" + +check_body \ + "reject overwrite" \ + "Destination already exists" \ + rename_post \ + "file=source.txt&new_filename=destination.txt" + +check_file \ + "destination unchanged" \ + "$TESTROOT/storage/default/destination.txt" \ + "destination" + +check_file \ + "source survives failed overwrite" \ + "$TESTROOT/storage/default/source.txt" \ + "original" + +check_body \ + "rename missing file" \ + "File not found" \ + rename_post \ + "file=missing.txt&new_filename=new.txt" + +############################################################################## +# Special filenames +############################################################################## + +special="$TESTROOT/storage/default/<test>&file.txt" + +printf 'special\n' > "$special" + +check_redirect \ + "rename encoded filename" \ + "303 See Other" \ + "./" \ + rename_post \ + "file=%3Ctest%3E%26file.txt&new_filename=special.txt" + +check_file_exists \ + "encoded source renamed" \ + "$TESTROOT/storage/default/special.txt" + +printf 'space\n' > "$TESTROOT/storage/default/hello world.txt" + +check_redirect \ + "rename plus decoded source" \ + "303 See Other" \ + "./" \ + rename_post \ + "file=hello+world.txt&new_filename=space.txt" + +check_file_exists \ + "space source renamed" \ + "$TESTROOT/storage/default/space.txt" + +############################################################################## +# .config protection +############################################################################## + +printf 'QUOTA=1024\n' >> "$TESTROOT/storage/default/.config" + +check_status \ + "reject .config source rename" \ + "400 Bad Request" \ + rename \ + "file=.config" + +check_status \ + "reject .config destination" \ + "400 Bad Request" \ + rename_post \ + "file=file2.txt&new_filename=.config" + +check_file_exists \ + ".config survives" \ + "$TESTROOT/storage/default/.config" + +############################################################################## +# CSRF protection +############################################################################## + +reset_testroot + +write_default_repo_config "ADMIN_USER=bob" + +printf 'csrf\n' > "$TESTROOT/storage/default/csrf.txt" + +check_status \ + "rename accepts configured Origin" \ + "303 See Other" \ + rename_post_origin \ + "http://localhost:8000" \ + "file=csrf.txt&new_filename=renamed.txt" + +check_file_exists \ + "csrf rename succeeded" \ + "$TESTROOT/storage/default/renamed.txt" + +reset_testroot + +write_default_repo_config "ADMIN_USER=bob" + +printf 'csrf\n' > "$TESTROOT/storage/default/csrf.txt" + +check_status \ + "rename rejects missing Origin" \ + "403 Forbidden" \ + rename_post_no_origin \ + "file=csrf.txt&new_filename=renamed.txt" + +check_file_exists \ + "missing Origin preserves source" \ + "$TESTROOT/storage/default/csrf.txt" + +reset_testroot + +write_default_repo_config "ADMIN_USER=bob" + +printf 'csrf\n' > "$TESTROOT/storage/default/csrf.txt" + +check_status \ + "rename rejects wrong Origin" \ + "403 Forbidden" \ + rename_post_origin \ + "https://evil.example" \ + "file=csrf.txt&new_filename=renamed.txt" + +check_file_exists \ + "wrong Origin preserves source" \ + "$TESTROOT/storage/default/csrf.txt" + +############################################################################## +# Other valid renames +############################################################################## + +reset_testroot + +write_default_repo_config "ADMIN_USER=bob" + +printf 'hello' > "$TESTROOT/storage/default/same.txt" + +check_redirect \ + "rename to same filename" \ + "303 See Other" \ + "./" \ + rename_post \ + "file=same.txt&new_filename=same.txt" + +check_file \ + "same filename preserved" \ + "$TESTROOT/storage/default/same.txt" \ + "hello" + +: > "$TESTROOT/storage/default/empty.txt" + +check_redirect \ + "rename empty file" \ + "303 See Other" \ + "./" \ + rename_post \ + "file=empty.txt&new_filename=empty-renamed.txt" + +check_file_exists \ + "empty file renamed" \ + "$TESTROOT/storage/default/empty-renamed.txt" + +reset_testroot + +write_default_repo_config "ADMIN_USER=bob" + +printf 'extension' > "$TESTROOT/storage/default/file.txt" + +check_redirect \ + "rename extension" \ + "303 See Other" \ + "./" \ + rename_post \ + "file=file.txt&new_filename=file.md" + +check_file_exists \ + "renamed extension exists" \ + "$TESTROOT/storage/default/file.md" + +check_file_missing \ + "old extension removed" \ + "$TESTROOT/storage/default/file.txt" + +check_file \ + "extension rename preserves contents" \ + "$TESTROOT/storage/default/file.md" \ + "extension" + +############################################################################## +# Read-only repository +############################################################################## + +reset_testroot + +printf 'readonly' > "$TESTROOT/storage/default/readonly.txt" + +write_default_repo_config "READ_ONLY=1" + +check_status \ + "reject rename in readonly repository" \ + "403 Forbidden" \ + rename_post \ + "file=readonly.txt&new_filename=renamed.txt" + +check_file_exists \ + "readonly source preserved" \ + "$TESTROOT/storage/default/readonly.txt" + +check_file_missing \ + "readonly destination not created" \ + "$TESTROOT/storage/default/renamed.txt" + +check_body \ + "readonly GET rename rejected" \ + "read-only" \ + rename \ + "file=readonly.txt" + +check_file \ + "readonly source contents preserved" \ + "$TESTROOT/storage/default/readonly.txt" \ + "readonly" + +check_status \ + "readonly rename rejects missing Origin" \ + "403 Forbidden" \ + rename_post_no_origin \ + "file=readonly.txt&new_filename=new.txt" + +############################################################################## +# Authorization +############################################################################## + +reset_testroot + +printf 'readonly\n' > "$TESTROOT/storage/default/readonly.txt" + +write_default_repo_config "ADMIN_USER=bob" + +check_status \ + "rename rejected for non-admin user" \ + "403 Forbidden" \ + rename_post_user \ + "alice" \ + "file=readonly.txt&new_filename=renamed.txt" + +check_file_exists \ + "non-admin source preserved" \ + "$TESTROOT/storage/default/readonly.txt" + +check_file_missing \ + "non-admin destination not created" \ + "$TESTROOT/storage/default/renamed.txt" + +check_status \ + "non-admin rename GET rejected" \ + "403 Forbidden" \ + rename_user \ + "alice" \ + "file=readonly.txt" + +reset_testroot + +printf 'anonymous\n' > "$TESTROOT/storage/default/anonymous.txt" + +write_default_repo_config "ADMIN_USER=bob" + +check_status \ + "anonymous rename POST rejected" \ + "403 Forbidden" \ + rename_post_no_user \ + "file=anonymous.txt&new_filename=renamed.txt" + +check_file_exists \ + "anonymous source preserved" \ + "$TESTROOT/storage/default/anonymous.txt" + +check_file_missing \ + "anonymous destination not created" \ + "$TESTROOT/storage/default/renamed.txt" + +check_status \ + "anonymous rename GET rejected" \ + "403 Forbidden" \ + rename_no_user \ + "file=anonymous.txt" + +############################################################################## +# Anonymous read does not grant write access +############################################################################## + +reset_testroot + +write_default_repo_config "ADMIN_USER=bob" + +printf 'hello' > "$TESTROOT/storage/default/file.txt" + +write_global_config "ANONYMOUS_READ=1" + +check_status \ + "anonymous rename GET rejected despite anonymous read" \ + "403 Forbidden" \ + rename_no_user \ + "file=file.txt" + +check_status \ + "anonymous rename POST rejected despite anonymous read" \ + "403 Forbidden" \ + rename_post_no_user \ + "file=file.txt&new_filename=renamed.txt" + +check_file_exists \ + "anonymous rename source preserved" \ + "$TESTROOT/storage/default/file.txt" + +check_file_missing \ + "anonymous rename destination not created" \ + "$TESTROOT/storage/default/renamed.txt" + +############################################################################## +# Authentication alone does not grant write access +############################################################################## + +reset_testroot + +write_default_repo_config "" + +printf 'hello' > "$TESTROOT/storage/default/file.txt" + +check_status \ + "authenticated user cannot rename without ADMIN_USER" \ + "403 Forbidden" \ + rename_post_user \ + "alice" \ + "file=file.txt&new_filename=renamed.txt" + +check_file_exists \ + "source preserved without ADMIN_USER" \ + "$TESTROOT/storage/default/file.txt" + +check_file_missing \ + "destination not created without ADMIN_USER" \ + "$TESTROOT/storage/default/renamed.txt" + +# +# Response generation +# + +reset_testroot + +write_default_repo_config "ADMIN_USER=bob" + +printf 'hello\n' > "$TESTROOT/storage/default/file1.txt" + +mkdir -p "$TESTROOT/html" + +printf '{{UNKNOWN_VARIABLE}}' > "$TESTROOT/html/rename.html" + +check_status \ + "template failure returns 500" \ + "500 Internal Server Error" \ + rename_template_failure + +check_not_body \ + "template failure does not return 200" \ + "Status: 200 OK" \ + rename_template_failure + +test_result diff --git a/tests/regression/testlib.sh b/tests/regression/testlib.sh @@ -0,0 +1,311 @@ +#!/bin/sh + +PASS=0 +FAIL=0 + +TESTROOT=${TESTROOT:-testroot} +TEST_LOCK_DIR="$TESTROOT/run/simplefiles/lock" +TEST_UPLOAD_DIR="$TESTROOT/run/simplefiles/uploads" +TEST_CONFIG="$TESTROOT/etc/simplefiles.conf" +TEST_CSRF_ORIGIN=${TEST_CSRF_ORIGIN:-http://localhost:8000} + +# +# Reporting +# + +ok() { + printf '%-60s ok\n' "$1" + PASS=$((PASS + 1)) +} + +not_ok() { + printf '%-60s failed\n' "$1" + printf ' %s\n' "$2" + FAIL=$((FAIL + 1)) +} + +# +# Run a command and capture its output. +# +# These are globals deliberately. POSIX sh has no local variables, +# and keeping command execution in one place makes the assertions +# simpler and safer with "set -e". +# + +run() { + TEST_OUTPUT= + TEST_RC=0 + + if TEST_OUTPUT=$("$@" 2>&1); then + TEST_RC=0 + else + TEST_RC=$? + fi + + return 0 +} + +# +# HTTP helpers +# + +status_from_output() { + printf '%s\n' "$1" | + sed -n 's/^Status: //p' | + sed -n '1p' | + tr -d '\r' +} + +header_from_output() { + header_name=$1 + output=$2 + + printf '%s\n' "$output" | + sed -n "s/^${header_name}: //p" | + sed -n '1p' | + tr -d '\r' +} + +# +# Assertions +# + +check_status() { + name=$1 + expected=$2 + shift 2 + + run "$@" + + got=$(status_from_output "$TEST_OUTPUT") + + if [ "$got" = "$expected" ]; then + ok "$name" + else + not_ok "$name" \ + "expected status: $expected (got: ${got:-none})" + printf '%s\n' "$TEST_OUTPUT" + fi +} + +check_body() { + name=$1 + expected=$2 + shift 2 + + run "$@" + + if printf '%s' "$TEST_OUTPUT" | grep -F "$expected" > /dev/null; then + ok "$name" + else + not_ok "$name" "missing body: $expected" + fi +} + +check_not_body() { + name=$1 + unexpected=$2 + shift 2 + + run "$@" + + if printf '%s' "$TEST_OUTPUT" | grep -F "$unexpected" > /dev/null; then + not_ok "$name" "unexpected body: $unexpected" + else + ok "$name" + fi +} + +check_header() { + name=$1 + header_name=$2 + expected=$3 + shift 3 + + run "$@" + + got=$(header_from_output "$header_name" "$TEST_OUTPUT") + + if [ "$got" = "$expected" ]; then + ok "$name" + else + not_ok "$name" \ + "expected $header_name: $expected (got: ${got:-none})" + fi +} + +check_redirect() { + name=$1 + expected_status=$2 + expected_location=$3 + shift 3 + + run "$@" + + got_status=$(status_from_output "$TEST_OUTPUT") + + if [ "$got_status" != "$expected_status" ]; then + not_ok "$name" \ + "expected status: $expected_status (got: ${got_status:-none})" + return + fi + + got_location=$(header_from_output Location "$TEST_OUTPUT") + + if [ "$got_location" != "$expected_location" ]; then + not_ok "$name" \ + "expected Location: $expected_location (got: ${got_location:-none})" + return + fi + + ok "$name" +} + +# +# Filesystem assertions +# + +check_file() { + name=$1 + file=$2 + expected=$3 + + if [ ! -f "$file" ]; then + not_ok "$name" "missing file: $file" + return + fi + + size=$(wc -c < "$file" | tr -d ' ') + + # + # POSIX sh does not have ${#string}, so use wc for the expected + # byte count too. + # + expected_size=$(printf '%s' "$expected" | wc -c | tr -d ' ') + + if [ "$size" -ne "$expected_size" ]; then + not_ok "$name" \ + "wrong size: expected $expected_size bytes, got $size" + return + fi + + actual=$(cat "$file") + + if [ "$actual" != "$expected" ]; then + not_ok "$name" "unexpected file contents" + return + fi + + ok "$name" +} + +check_file_exists() { + name=$1 + file=$2 + + if [ -f "$file" ]; then + ok "$name" + else + not_ok "$name" "missing file: $file" + fi +} + +check_file_missing() { + name=$1 + file=$2 + + if [ ! -e "$file" ]; then + ok "$name" + else + not_ok "$name" "unexpected file: $file" + fi +} + +check_dir_missing() { + name=$1 + dir=$2 + + if [ ! -d "$dir" ]; then + ok "$name" + else + not_ok "$name" "unexpected directory: $dir" + fi +} + +check_no_temp_uploads() { + name=$1 + dir=$2 + + if [ ! -d "$dir" ]; then + ok "$name" + return + fi + + if find "$dir" -name '.upload-*' -type f -print | + grep -q .; then + not_ok "$name" "temporary upload remains" + else + ok "$name" + fi +} + +check_file_bytes() { + name=$1 + file=$2 + expected=$3 + + tmp=$(mktemp) + + printf '%b' "$expected" > "$tmp" + + if [ ! -f "$file" ]; then + rm -f "$tmp" + not_ok "$name" "file missing: $file" + return + fi + + if cmp -s "$file" "$tmp"; then + ok "$name" + else + not_ok "$name" "file contents differ: $file" + fi + + rm -f "$tmp" +} + +# +# Test environment helpers +# + +reset_testroot() { + rm -rf "$TESTROOT" + mkdir -p "$TESTROOT/storage/default" + mkdir -p "$TEST_LOCK_DIR" + mkdir -p "$TEST_UPLOAD_DIR" + mkdir -p "$TESTROOT/etc" + chmod go-w "$TEST_UPLOAD_DIR" + printf 'CSRF_ORIGIN=%s\n' "$TEST_CSRF_ORIGIN" > "$TEST_CONFIG" + chmod 644 "$TEST_CONFIG" +} + +make_repo() { + mkdir -p "$TESTROOT/storage/$1" +} + +write_global_config() { + printf '%s\n' "$1" > "$TEST_CONFIG" +} + +write_default_repo_config() { + mkdir -p "$TESTROOT/storage/default" + printf '%s\n' "$1" > "$TESTROOT/storage/default/.config" +} + +# +# Final result +# + +test_result() { + printf '\n%d passed, %d failed\n' "$PASS" "$FAIL" + [ "$FAIL" -eq 0 ] +} diff --git a/tests/regression/upload.sh b/tests/regression/upload.sh @@ -0,0 +1,1423 @@ +#!/bin/sh + +set -eu + +. "$(dirname "$0")/testlib.sh" + +CGI=${1:-build/upload.cgi} + +upload_get() { + env \ + STORAGE_ROOT="$TESTROOT/storage" \ + SIMPLEFILES_ROOT=./html \ + SIMPLEFILES_CONFIG="$TEST_CONFIG" \ + REMOTE_USER=bob \ + REQUEST_METHOD=GET \ + "$CGI" +} + +upload_put() { + env \ + STORAGE_ROOT="$TESTROOT/storage" \ + SIMPLEFILES_CONFIG="$TEST_CONFIG" \ + REMOTE_USER=bob \ + REQUEST_METHOD=PUT \ + "$CGI" +} + +upload_post() { + content_type=$1 + content_length=$2 + + env \ + STORAGE_ROOT="$TESTROOT/storage" \ + UPLOAD_TMP_DIR="$TEST_UPLOAD_DIR" \ + REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \ + SIMPLEFILES_CONFIG="$TEST_CONFIG" \ + HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \ + REMOTE_USER=bob \ + REQUEST_METHOD=POST \ + CONTENT_TYPE="$content_type" \ + CONTENT_LENGTH="$content_length" \ + "$CGI" +} + +upload_post_no_length() { + content_type=$1 + + env \ + STORAGE_ROOT="$TESTROOT/storage" \ + UPLOAD_TMP_DIR="$TEST_UPLOAD_DIR" \ + REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \ + SIMPLEFILES_CONFIG="$TEST_CONFIG" \ + HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \ + REMOTE_USER=bob \ + REQUEST_METHOD=POST \ + CONTENT_TYPE="$content_type" \ + "$CGI" +} + +upload() { + body=$( + printf '%s\r\n' '--test' + printf '%s\r\n' \ + 'Content-Disposition: form-data; name="file"; filename="hello world.mp4"' + printf '%s\r\n' 'Content-Type: video/mp4' + printf '\r\n' + printf '%s' 'dummy data' + printf '\r\n--test--\r\n' + ) + + printf '%s' "$body" | + env \ + STORAGE_ROOT="$TESTROOT/storage" \ + UPLOAD_TMP_DIR="$TEST_UPLOAD_DIR" \ + REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \ + SIMPLEFILES_CONFIG="$TEST_CONFIG" \ + HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \ + REMOTE_USER=bob \ + REQUEST_METHOD=POST \ + CONTENT_TYPE="multipart/form-data; boundary=test" \ + CONTENT_LENGTH="$(printf '%s' "$body" | wc -c | tr -d ' ')" \ + "$CGI" +} + +upload_boundary_in_file() { + body=$( + printf '%s\r\n' '--test' + printf '%s\r\n' \ + 'Content-Disposition: form-data; name="file"; filename="boundary.txt"' + printf '%s\r\n' 'Content-Type: text/plain' + printf '\r\n' + printf '%s' 'before +--test-not-a-boundary +after' + printf '\r\n--test--\r\n' + ) + + printf '%s' "$body" | + env \ + STORAGE_ROOT="$TESTROOT/storage" \ + UPLOAD_TMP_DIR="$TEST_UPLOAD_DIR" \ + REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \ + SIMPLEFILES_CONFIG="$TEST_CONFIG" \ + HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \ + REMOTE_USER=bob \ + REQUEST_METHOD=POST \ + CONTENT_TYPE="multipart/form-data; boundary=test" \ + CONTENT_LENGTH="$(printf '%s' "$body" | wc -c | tr -d ' ')" \ + "$CGI" +} + +upload_boundary_split() { + tmp=$(mktemp) + + { + printf '%s\r\n' '--test' + printf '%s\r\n' \ + 'Content-Disposition: form-data; name="file"; filename="split.txt"' + printf '%s\r\n' 'Content-Type: text/plain' + printf '\r\n' + + awk 'BEGIN { + for (i = 0; i < 8192; i++) + printf "A" + }' + + printf '\r\n--test--\r\n' + } > "$tmp" + + env \ + STORAGE_ROOT="$TESTROOT/storage" \ + UPLOAD_TMP_DIR="$TEST_UPLOAD_DIR" \ + REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \ + SIMPLEFILES_CONFIG="$TEST_CONFIG" \ + HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \ + REMOTE_USER=bob \ + REQUEST_METHOD=POST \ + CONTENT_TYPE="multipart/form-data; boundary=test" \ + CONTENT_LENGTH="$(wc -c < "$tmp" | tr -d ' ')" \ + "$CGI" < "$tmp" + + rm -f "$tmp" +} + +upload_quota_ok() { + tmp=$(mktemp) + + { + printf '%s\r\n' '--test' + printf '%s\r\n' \ + 'Content-Disposition: form-data; name="file"; filename="quota_ok.txt"' + printf '%s\r\n' 'Content-Type: application/octet-stream' + printf '\r\n' + awk 'BEGIN { for (i = 0; i < 100; i++) printf "A" }' + printf '\r\n--test--\r\n' + } > "$tmp" + + env \ + REPO_QUOTA_BYTES=10240 \ + STORAGE_ROOT="$TESTROOT/storage" \ + UPLOAD_TMP_DIR="$TEST_UPLOAD_DIR" \ + REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \ + SIMPLEFILES_CONFIG="$TEST_CONFIG" \ + HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \ + REMOTE_USER=bob \ + REQUEST_METHOD=POST \ + CONTENT_TYPE="multipart/form-data; boundary=test" \ + CONTENT_LENGTH="$(wc -c < "$tmp" | tr -d ' ')" \ + "$CGI" < "$tmp" + + rm -f "$tmp" +} + +upload_quota_exceeds() { + tmp=$(mktemp) + + { + printf '%s\r\n' '--test' + printf '%s\r\n' \ + 'Content-Disposition: form-data; name="file"; filename="toobig.txt"' + printf '%s\r\n' 'Content-Type: application/octet-stream' + printf '\r\n' + dd if=/dev/zero bs=1 count=2000 2> /dev/null + printf '\r\n--test--\r\n' + } > "$tmp" + + env \ + REPO_QUOTA_BYTES=10240 \ + MIN_FREE_BYTES=1024 \ + STORAGE_ROOT="$TESTROOT/storage" \ + UPLOAD_TMP_DIR="$TEST_UPLOAD_DIR" \ + REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \ + SIMPLEFILES_CONFIG="$TEST_CONFIG" \ + HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \ + REMOTE_USER=bob \ + REQUEST_METHOD=POST \ + CONTENT_TYPE="multipart/form-data; boundary=test" \ + CONTENT_LENGTH="$(wc -c < "$tmp" | tr -d ' ')" \ + "$CGI" < "$tmp" + + rm -f "$tmp" +} + +upload_exact_quota() { + quota=$1 + tmp=$(mktemp) + + { + printf '%s\r\n' '--test' + printf '%s\r\n' \ + 'Content-Disposition: form-data; name="file"; filename="exact.txt"' + printf '%s\r\n' 'Content-Type: application/octet-stream' + printf '\r\n' + dd if=/dev/zero bs=1 count=1024 2> /dev/null + printf '\r\n--test--\r\n' + } > "$tmp" + + env \ + REPO_QUOTA_BYTES="$quota" \ + STORAGE_ROOT="$TESTROOT/storage" \ + UPLOAD_TMP_DIR="$TEST_UPLOAD_DIR" \ + REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \ + SIMPLEFILES_CONFIG="$TEST_CONFIG" \ + HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \ + REMOTE_USER=bob \ + REQUEST_METHOD=POST \ + CONTENT_TYPE="multipart/form-data; boundary=test" \ + CONTENT_LENGTH="$(wc -c < "$tmp" | tr -d ' ')" \ + "$CGI" < "$tmp" + + rm -f "$tmp" +} + +upload_filename() { + filename=$1 + + body=$( + printf '%s\r\n' '--test' + printf '%s\r\n' \ + "Content-Disposition: form-data; name=\"file\"; filename=\"$filename\"" + printf '%s\r\n' 'Content-Type: video/mp4' + printf '\r\n' + printf '%s' 'dummy data' + printf '\r\n--test--\r\n' + ) + + printf '%s' "$body" | + env \ + STORAGE_ROOT="$TESTROOT/storage" \ + UPLOAD_TMP_DIR="$TEST_UPLOAD_DIR" \ + REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \ + SIMPLEFILES_CONFIG="$TEST_CONFIG" \ + HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \ + REMOTE_USER=bob \ + REQUEST_METHOD=POST \ + CONTENT_TYPE="multipart/form-data; boundary=test" \ + CONTENT_LENGTH="$(printf '%s' "$body" | wc -c | tr -d ' ')" \ + "$CGI" +} + +upload_truncated() { + body=$( + printf '%s\r\n' '--test' + printf '%s\r\n' \ + 'Content-Disposition: form-data; name="file"; filename="truncated.txt"' + printf '%s\r\n' 'Content-Type: text/plain' + printf '\r\n' + printf '%s' 'partial data' + ) + + printf '%s' "$body" | + env \ + UPLOAD_TMP_DIR="$TEST_UPLOAD_DIR" \ + STORAGE_ROOT="$TESTROOT/storage" \ + REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \ + SIMPLEFILES_CONFIG="$TEST_CONFIG" \ + HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \ + REMOTE_USER=bob \ + REQUEST_METHOD=POST \ + CONTENT_TYPE="multipart/form-data; boundary=test" \ + CONTENT_LENGTH="$(printf '%s' "$body" | wc -c | tr -d ' ')" \ + "$CGI" +} + +upload_no_filename() { + body=$( + printf '%s\r\n' '--test' + printf '%s\r\n' \ + 'Content-Disposition: form-data; name="file"' + printf '%s\r\n' 'Content-Type: text/plain' + printf '\r\n' + printf '%s' 'dummy data' + printf '\r\n--test--\r\n' + ) + + printf '%s' "$body" | + env \ + UPLOAD_TMP_DIR="$TEST_UPLOAD_DIR" \ + STORAGE_ROOT="$TESTROOT/storage" \ + REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \ + SIMPLEFILES_CONFIG="$TEST_CONFIG" \ + HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \ + REMOTE_USER=bob \ + REQUEST_METHOD=POST \ + CONTENT_TYPE="multipart/form-data; boundary=test" \ + CONTENT_LENGTH="$(printf '%s' "$body" | wc -c | tr -d ' ')" \ + "$CGI" +} + +upload_malformed() { + body=$( + printf '%s\r\n' '--test' + printf '%s\r\n' \ + 'Content-Disposition: form-data; name="file"; filename="bad.txt"' + printf '%s\r\n' 'Content-Type: text/plain' + printf '%s\r\n' 'dummy data' + printf '%s\r\n' '--test--' + ) + + printf '%s' "$body" | + env \ + UPLOAD_TMP_DIR="$TEST_UPLOAD_DIR" \ + STORAGE_ROOT="$TESTROOT/storage" \ + REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \ + SIMPLEFILES_CONFIG="$TEST_CONFIG" \ + HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \ + REMOTE_USER=bob \ + REQUEST_METHOD=POST \ + CONTENT_TYPE="multipart/form-data; boundary=test" \ + CONTENT_LENGTH="$(printf '%s' "$body" | wc -c | tr -d ' ')" \ + "$CGI" +} + +upload_failed_after_data() { + tmp=$(mktemp) + + { + printf '%s\r\n' '--test' + printf '%s\r\n' \ + 'Content-Disposition: form-data; name="file"; filename="failed.txt"' + printf '%s\r\n' 'Content-Type: text/plain' + printf '\r\n' + + awk 'BEGIN { + for (i = 0; i < 8192; i++) + printf "A" + }' + } > "$tmp" + + env \ + UPLOAD_TMP_DIR="$TEST_UPLOAD_DIR" \ + STORAGE_ROOT="$TESTROOT/storage" \ + REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \ + SIMPLEFILES_CONFIG="$TEST_CONFIG" \ + HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \ + REMOTE_USER=bob \ + REQUEST_METHOD=POST \ + CONTENT_TYPE="multipart/form-data; boundary=test" \ + CONTENT_LENGTH="$(wc -c < "$tmp" | tr -d ' ')" \ + "$CGI" < "$tmp" + + rm -f "$tmp" +} + +upload_short_body() { + body='short body' + + printf '%s' "$body" | + env \ + UPLOAD_TMP_DIR="$TEST_UPLOAD_DIR" \ + STORAGE_ROOT="$TESTROOT/storage" \ + REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \ + SIMPLEFILES_CONFIG="$TEST_CONFIG" \ + HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \ + REMOTE_USER=bob \ + REQUEST_METHOD=POST \ + CONTENT_TYPE="multipart/form-data; boundary=test" \ + CONTENT_LENGTH=999 \ + "$CGI" +} + +upload_min_free_rejected() { + body=$( + printf '%s\r\n' '--test' + printf '%s\r\n' \ + 'Content-Disposition: form-data; name="file"; filename="minfree.txt"' + printf '%s\r\n' 'Content-Type: text/plain' + printf '\r\n' + printf '%s' 'dummy data' + printf '\r\n--test--\r\n' + ) + + printf '%s' "$body" | + env \ + STORAGE_ROOT="$TESTROOT/storage" \ + UPLOAD_TMP_DIR="$TEST_UPLOAD_DIR" \ + REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \ + MIN_FREE_BYTES=9223372036854775807 \ + SIMPLEFILES_CONFIG="$TEST_CONFIG" \ + HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \ + REMOTE_USER=bob \ + REQUEST_METHOD=POST \ + CONTENT_TYPE="multipart/form-data; boundary=test" \ + CONTENT_LENGTH="$(printf '%s' "$body" | wc -c | tr -d ' ')" \ + "$CGI" +} + +upload_min_free_checked_before_spool() { + body=$( + printf '%s\r\n' '--test' + printf '%s\r\n' \ + 'Content-Disposition: form-data; name="file"; filename="minfree.txt"' + printf '%s\r\n' 'Content-Type: text/plain' + printf '\r\n' + printf '%s' 'dummy data' + printf '\r\n--test--\r\n' + ) + + bad_tmp="$TESTROOT/not-a-directory" + printf '%s' > "$bad_tmp" + + printf '%s' "$body" | + env \ + STORAGE_ROOT="$TESTROOT/storage" \ + UPLOAD_TMP_DIR="$bad_tmp" \ + REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \ + MIN_FREE_BYTES=9223372036854775807 \ + SIMPLEFILES_CONFIG="$TEST_CONFIG" \ + HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \ + REMOTE_USER=bob \ + REQUEST_METHOD=POST \ + CONTENT_TYPE="multipart/form-data; boundary=test" \ + CONTENT_LENGTH="$(printf '%s' "$body" | wc -c | tr -d ' ')" \ + "$CGI" +} + +upload_min_free_allows() { + body=$( + printf '%s\r\n' '--test' + printf '%s\r\n' \ + 'Content-Disposition: form-data; name="file"; filename="minfree-ok.txt"' + printf '%s\r\n' 'Content-Type: text/plain' + printf '\r\n' + printf '%s' 'dummy data' + printf '\r\n--test--\r\n' + ) + + printf '%s' "$body" | + env \ + STORAGE_ROOT="$TESTROOT/storage" \ + UPLOAD_TMP_DIR="$TEST_UPLOAD_DIR" \ + REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \ + MIN_FREE_BYTES=1 \ + SIMPLEFILES_CONFIG="$TEST_CONFIG" \ + HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \ + REMOTE_USER=bob \ + REQUEST_METHOD=POST \ + CONTENT_TYPE="multipart/form-data; boundary=test" \ + CONTENT_LENGTH="$(printf '%s' "$body" | wc -c | tr -d ' ')" \ + "$CGI" +} + +upload_post_origin() { + origin=$1 + + body=$( + printf '%s\r\n' '--test' + printf '%s\r\n' \ + 'Content-Disposition: form-data; name="file"; filename="csrf.txt"' + printf '%s\r\n' 'Content-Type: text/plain' + printf '\r\n' + printf '%s' 'csrf test' + printf '\r\n--test--\r\n' + ) + + printf '%s' "$body" | + env \ + STORAGE_ROOT="$TESTROOT/storage" \ + UPLOAD_TMP_DIR="$TEST_UPLOAD_DIR" \ + REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \ + SIMPLEFILES_CONFIG="$TEST_CONFIG" \ + HTTP_ORIGIN="$origin" \ + REMOTE_USER=bob \ + REQUEST_METHOD=POST \ + CONTENT_TYPE="multipart/form-data; boundary=test" \ + CONTENT_LENGTH="$(printf '%s' "$body" | wc -c | tr -d ' ')" \ + "$CGI" +} + +upload_post_no_origin() { + body=$( + printf '%s\r\n' '--test' + printf '%s\r\n' \ + 'Content-Disposition: form-data; name="file"; filename="csrf.txt"' + printf '%s\r\n' 'Content-Type: text/plain' + printf '\r\n' + printf '%s' 'csrf test' + printf '\r\n--test--\r\n' + ) + + printf '%s' "$body" | + env \ + STORAGE_ROOT="$TESTROOT/storage" \ + UPLOAD_TMP_DIR="$TEST_UPLOAD_DIR" \ + REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \ + SIMPLEFILES_CONFIG="$TEST_CONFIG" \ + REQUEST_METHOD=POST \ + REMOTE_USER=bob \ + CONTENT_TYPE="multipart/form-data; boundary=test" \ + CONTENT_LENGTH="$(printf '%s' "$body" | wc -c | tr -d ' ')" \ + "$CGI" +} + +upload_binary() { + tmp=$(mktemp) + + { + printf '%s\r\n' '--test' + printf '%s\r\n' \ + 'Content-Disposition: form-data; name="file"; filename="binary.bin"' + printf '%s\r\n' 'Content-Type: application/octet-stream' + printf '\r\n' + printf '\000\001\002\003\377' + printf '\r\n--test--\r\n' + } > "$tmp" + + env \ + STORAGE_ROOT="$TESTROOT/storage" \ + UPLOAD_TMP_DIR="$TEST_UPLOAD_DIR" \ + REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \ + SIMPLEFILES_CONFIG="$TEST_CONFIG" \ + HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \ + REMOTE_USER=bob \ + REQUEST_METHOD=POST \ + CONTENT_TYPE="multipart/form-data; boundary=test" \ + CONTENT_LENGTH="$(wc -c < "$tmp" | tr -d ' ')" \ + "$CGI" < "$tmp" + + rm -f "$tmp" +} + +upload_post_user() { + user=$1 + + body=$( + printf '%s\r\n' '--test' + printf '%s\r\n' \ + 'Content-Disposition: form-data; name="file"; filename="upload.txt"' + printf '%s\r\n' 'Content-Type: text/plain' + printf '\r\n' + printf '%s' 'uploaded content' + printf '\r\n--test--\r\n' + ) + + printf '%s' "$body" | + env \ + SCRIPT_NAME=/default/upload.cgi \ + STORAGE_ROOT="$TESTROOT/storage" \ + UPLOAD_TMP_DIR="$TEST_UPLOAD_DIR" \ + REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \ + SIMPLEFILES_CONFIG="$TEST_CONFIG" \ + HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \ + REMOTE_USER="$user" \ + REQUEST_METHOD=POST \ + CONTENT_TYPE="multipart/form-data; boundary=test" \ + CONTENT_LENGTH="$(printf '%s' "$body" | wc -c | tr -d ' ')" \ + "$CGI" +} + +upload_post_no_user() { + body=$( + printf '%s\r\n' '--test' + printf '%s\r\n' \ + 'Content-Disposition: form-data; name="file"; filename="upload.txt"' + printf '%s\r\n' 'Content-Type: text/plain' + printf '\r\n' + printf '%s' 'uploaded content' + printf '\r\n--test--\r\n' + ) + + ( + unset REMOTE_USER + + printf '%s' "$body" | + STORAGE_ROOT="$TESTROOT/storage" \ + UPLOAD_TMP_DIR="$TEST_UPLOAD_DIR" \ + REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \ + SIMPLEFILES_CONFIG="$TEST_CONFIG" \ + HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \ + SCRIPT_NAME=/default/upload.cgi \ + REQUEST_METHOD=POST \ + CONTENT_TYPE="multipart/form-data; boundary=test" \ + CONTENT_LENGTH="$(printf '%s' "$body" | wc -c | tr -d ' ')" \ + "$CGI" + ) +} + +upload_filename_in_body() { + body=$( + printf '%s\r\n' '--test' + printf '%s\r\n' \ + 'Content-Disposition: form-data; name="file"; filename="safe.txt"' + printf '%s\r\n' 'Content-Type: text/plain' + printf '\r\n' + printf '%s' 'filename="evil.txt"' + printf '\r\n--test--\r\n' + ) + + printf '%s' "$body" | + env \ + STORAGE_ROOT="$TESTROOT/storage" \ + UPLOAD_TMP_DIR="$TEST_UPLOAD_DIR" \ + REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \ + SIMPLEFILES_CONFIG="$TEST_CONFIG" \ + HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \ + REMOTE_USER=bob \ + REQUEST_METHOD=POST \ + CONTENT_TYPE="multipart/form-data; boundary=test" \ + CONTENT_LENGTH="$(printf '%s' "$body" | wc -c | tr -d ' ')" \ + "$CGI" +} + +upload_multiple_files() { + body=$( + printf '%s\r\n' '--test' + printf '%s\r\n' \ + 'Content-Disposition: form-data; name="file"; filename="one.txt"' + printf '%s\r\n' 'Content-Type: text/plain' + printf '\r\n' + printf '%s\r\n' 'first file' + printf '%s\r\n' '--test' + printf '%s\r\n' \ + 'Content-Disposition: form-data; name="file"; filename="two.txt"' + printf '%s\r\n' 'Content-Type: text/plain' + printf '\r\n' + printf '%s\r\n' 'second file' + printf '%s\r\n' '--test--' + ) + + printf '%s' "$body" | + env \ + STORAGE_ROOT="$TESTROOT/storage" \ + UPLOAD_TMP_DIR="$TEST_UPLOAD_DIR" \ + REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \ + SIMPLEFILES_CONFIG="$TEST_CONFIG" \ + HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \ + REMOTE_USER=bob \ + REQUEST_METHOD=POST \ + CONTENT_TYPE="multipart/form-data; boundary=test" \ + CONTENT_LENGTH="$(printf '%s' "$body" | wc -c | tr -d ' ')" \ + "$CGI" +} + +upload_cleanup_failure() { + body=$( + printf '%s\r\n' '--test' + printf '%s\r\n' \ + 'Content-Disposition: form-data; name="file"; filename="cleanup-failure.txt"' + printf '%s\r\n' 'Content-Type: text/plain' + printf '\r\n' + printf '%s' 'published despite cleanup failure' + printf '\r\n--test--\r\n' + ) + + printf '%s' "$body" | + env \ + TEST_FAIL_TEMP_UNLINK=1 \ + STORAGE_ROOT="$TESTROOT/storage" \ + UPLOAD_TMP_DIR="$TEST_UPLOAD_DIR" \ + REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \ + SIMPLEFILES_CONFIG="$TEST_CONFIG" \ + HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \ + REMOTE_USER=bob \ + REQUEST_METHOD=POST \ + CONTENT_TYPE="multipart/form-data; boundary=test" \ + CONTENT_LENGTH="$(printf '%s' "$body" | wc -c | tr -d ' ')" \ + "$CGI" +} + +#################################################### + +# +# Request validation +# + +reset_testroot + +write_default_repo_config "ADMIN_USER=bob" + +check_status \ + "GET upload page" \ + "200 OK" \ + upload_get + +check_status \ + "reject invalid method" \ + "405 Method Not Allowed" \ + upload_put + +check_body \ + "invalid method message" \ + "GET or POST required" \ + upload_put + +check_status \ + "reject missing content type" \ + "400 Bad Request" \ + upload_post \ + "" \ + 10 + +check_status \ + "reject invalid content type" \ + "400 Bad Request" \ + upload_post \ + "text/plain; boundary=test" \ + 10 + +check_status \ + "reject missing boundary" \ + "400 Bad Request" \ + upload_post \ + "multipart/form-data" \ + 10 + +check_status \ + "reject invalid length" \ + "400 Bad Request" \ + upload_post \ + "multipart/form-data; boundary=test" \ + abc + +check_status \ + "reject zero length" \ + "413 Payload Too Large" \ + upload_post \ + "multipart/form-data; boundary=test" \ + 0 + +check_status \ + "reject oversized upload" \ + "413 Payload Too Large" \ + upload_post \ + "multipart/form-data; boundary=test" \ + 999999999 + +check_status \ + "reject missing content length" \ + "411 Length Required" \ + upload_post_no_length \ + "multipart/form-data; boundary=test" + +check_status \ + "reject empty boundary" \ + "400 Bad Request" \ + upload_post \ + "multipart/form-data; boundary=" \ + 10 + +long_boundary=$(printf '%0129d' 0 | tr '0' 'a') + +check_status \ + "reject oversized boundary" \ + "400 Bad Request" \ + upload_post \ + "multipart/form-data; boundary=$long_boundary" \ + 10 + +#################################################### + +# +# Multipart validation +# + +check_status \ + "reject missing filename" \ + "400 Bad Request" \ + upload_no_filename + +check_status \ + "reject malformed multipart body" \ + "400 Bad Request" \ + upload_malformed + +check_status \ + "reject short request body" \ + "400 Bad Request" \ + upload_short_body + +check_redirect \ + "boundary-like string inside file" \ + "303 See Other" \ + "./" \ + upload_boundary_in_file + +check_file \ + "boundary-like string preserved" \ + "$TESTROOT/storage/default/boundary.txt" \ + "before +--test-not-a-boundary +after" + +check_redirect \ + "multipart boundary across reads" \ + "303 See Other" \ + "./" \ + upload_boundary_split + +check_file \ + "multipart data preserved across reads" \ + "$TESTROOT/storage/default/split.txt" \ + "$(awk 'BEGIN { + for (i = 0; i < 8192; i++) + printf "A" + }')" + +check_redirect \ + "filename is extracted from headers only" \ + "303 See Other" \ + "./" \ + upload_filename_in_body + +check_file \ + "filename from header is preserved" \ + "$TESTROOT/storage/default/safe.txt" \ + 'filename="evil.txt"' + +check_file_missing \ + "filename from body is not used" \ + "$TESTROOT/storage/default/evil.txt" + +#################################################### + +# +# Multipart cardinatlity +# + +reset_testroot + +write_default_repo_config "ADMIN_USER=bob" + +check_status \ + "reject multiple file upload" \ + "400 Bad Request" \ + upload_multiple_files + +check_file_missing \ + "multiple upload creates no first file" \ + "$TESTROOT/storage/default/one.txt" + +check_file_missing \ + "multiple upload creates no second file" \ + "$TESTROOT/storage/default/two.txt" + +#################################################### + +# +# Standalone uploads +# + +reset_testroot +write_default_repo_config "ADMIN_USER=bob" + +check_redirect \ + "extract filename" \ + "303 See Other" \ + "./" \ + upload + +check_file \ + "file created" \ + "$TESTROOT/storage/default/hello world.mp4" \ + "dummy data" + +check_redirect \ + "duplicate upload redirects" \ + "303 See Other" \ + "./" \ + upload + +check_file \ + "duplicate file created" \ + "$TESTROOT/storage/default/hello world.1.mp4" \ + "dummy data" + +#################################################### + +# +# Quota tests +# + +reset_testroot +write_default_repo_config "ADMIN_USER=bob" + +check_redirect \ + "upload within quota" \ + "303 See Other" \ + "./" \ + upload_quota_ok + +check_file \ + "quota ok file created" \ + "$TESTROOT/storage/default/quota_ok.txt" \ + "$(awk 'BEGIN { for (i = 0; i < 100; i++) printf "A" }')" + +reset_testroot +write_default_repo_config "ADMIN_USER=bob" + +dd \ + if=/dev/zero \ + of="$TESTROOT/storage/default/filler.bin" \ + bs=1 \ + count=9000 \ + 2> /dev/null + +check_status \ + "upload exceeds repo quota" \ + "507 Insufficient Storage" \ + upload_quota_exceeds + +#################################################### + +# +# Upload at exact quota boundary +# + +reset_testroot + +write_default_repo_config "ADMIN_USER=bob" + +dd \ + if=/dev/zero \ + of="$TESTROOT/storage/default/filler.bin" \ + bs=1 \ + count=9216 \ + 2> /dev/null + +config_size=$(wc -c < "$TESTROOT/storage/default/.config" | tr -d ' ') +exact_quota=$((9216 + 1024 + config_size)) + +check_redirect \ + "upload at exact quota limit" \ + "303 See Other" \ + "./" \ + upload_exact_quota \ + "$exact_quota" + +#################################################### + +# +# Default quota +# + +reset_testroot +write_default_repo_config "ADMIN_USER=bob" + +check_redirect \ + "default quota allows upload" \ + "303 See Other" \ + "./" \ + upload + +#################################################### + +# +# Repository QUOTA +# + +reset_testroot + +write_default_repo_config 'ADMIN_USER=bob +QUOTA=1' + +check_status \ + "config quota rejects upload" \ + "507 Insufficient Storage" \ + upload + +reset_testroot + +write_default_repo_config 'ADMIN_USER=bob +QUOTA=1024' + +check_redirect \ + "config quota allows upload" \ + "303 See Other" \ + "./" \ + upload + +#################################################### + +# +# Repository MAX_UPLOAD +# + +reset_testroot + +write_default_repo_config 'ADMIN_USER=bob +MAX_UPLOAD=1' + +check_status \ + "config max upload rejects upload" \ + "413 Payload Too Large" \ + upload + +reset_testroot + +write_default_repo_config 'ADMIN_USER=bob +MAX_UPLOAD=1024' + +check_redirect \ + "config max upload allows upload" \ + "303 See Other" \ + "./" \ + upload + +#################################################### + +# +# Repository MAX_UPLOAD + QUOTA +# + +reset_testroot + +write_default_repo_config 'ADMIN_USER=bob +MAX_UPLOAD=1024 +QUOTA=1024' + +check_redirect \ + "config max upload+quota" \ + "303 See Other" \ + "./" \ + upload + +reset_testroot + +write_default_repo_config 'ADMIN_USER=bob +MAX_UPLOAD=1024 +QUOTA=1' + +check_status \ + "config max upload+quota rejects upload" \ + "507 Insufficient Storage" \ + upload + +#################################################### + +# +# Invalid filenames +# + +reset_testroot +write_default_repo_config "ADMIN_USER=bob" + +check_status \ + "reject .config upload" \ + "400 Bad Request" \ + upload_filename \ + ".config" + +check_status \ + "reject path traversal filename" \ + "400 Bad Request" \ + upload_filename \ + "../passwd" + +check_status \ + "reject slash filename" \ + "400 Bad Request" \ + upload_filename \ + "subdir/file.txt" + +check_status \ + "reject empty filename" \ + "400 Bad Request" \ + upload_filename \ + "" + +check_status \ + "reject dot filename" \ + "400 Bad Request" \ + upload_filename \ + "." + +check_status \ + "reject dot-dot filename" \ + "400 Bad Request" \ + upload_filename \ + ".." + +long_filename=$(awk 'BEGIN { for (i = 0; i < 300; i++) printf "a" }') + +check_status \ + "reject oversized filename" \ + "400 Bad Request" \ + upload_filename \ + "$long_filename" + +#################################################### + +# +# Temporary upload cleanup +# + +reset_testroot +write_default_repo_config "ADMIN_USER=bob" + +check_status \ + "reject missing closing boundary" \ + "400 Bad Request" \ + upload_truncated + +check_no_temp_uploads \ + "no temporary upload remains" \ + "$TEST_UPLOAD_DIR" + +#################################################### + +# +# Failed upload cleanup +# + +reset_testroot +write_default_repo_config "ADMIN_USER=bob" + +check_status \ + "reject failed upload" \ + "400 Bad Request" \ + upload_failed_after_data + +check_file_missing \ + "failed upload leaves no destination file" \ + "$TESTROOT/storage/default/failed.txt" + +check_no_temp_uploads \ + "failed upload leaves no temporary file" \ + "$TEST_UPLOAD_DIR" + +#################################################### + +# +# Minimum free space +# + +reset_testroot +write_default_repo_config "ADMIN_USER=bob" + +check_status \ + "reject upload when minimum free space is not available" \ + "507 Insufficient Storage" \ + upload_min_free_rejected + +check_status \ + "minimum-free check happens before spool creation" \ + "507 Insufficient Storage" \ + upload_min_free_checked_before_spool + +reset_testroot +write_default_repo_config "ADMIN_USER=bob" + +check_redirect \ + "upload with sufficient minimum free space" \ + "303 See Other" \ + "./" \ + upload_min_free_allows + +check_file \ + "minimum-free upload created file" \ + "$TESTROOT/storage/default/minfree-ok.txt" \ + "dummy data" + +#################################################### + +# +# CSRF Origin protection +# + +reset_testroot +write_default_repo_config "ADMIN_USER=bob" + +check_status \ + "upload accepts configured Origin" \ + "303 See Other" \ + upload_post_origin \ + "$TEST_CSRF_ORIGIN" + +check_file \ + "configured Origin upload creates file" \ + "$TESTROOT/storage/default/csrf.txt" \ + "csrf test" + +reset_testroot +write_default_repo_config "ADMIN_USER=bob" + +check_status \ + "upload rejects missing Origin" \ + "403 Forbidden" \ + upload_post_no_origin + +check_file_missing \ + "missing Origin creates no file" \ + "$TESTROOT/storage/default/csrf.txt" + +reset_testroot +write_default_repo_config "ADMIN_USER=bob" + +check_status \ + "upload rejects wrong Origin" \ + "403 Forbidden" \ + upload_post_origin \ + "https://evil.example" + +check_file_missing \ + "wrong Origin creates no file" \ + "$TESTROOT/storage/default/csrf.txt" + +reset_testroot +write_default_repo_config "ADMIN_USER=bob" + +check_status \ + "upload rejects Origin with configured origin as prefix" \ + "403 Forbidden" \ + upload_post_origin \ + "https://localhost.evil.example" + +reset_testroot +write_default_repo_config "ADMIN_USER=bob" + +check_status \ + "upload rejects wrong Origin scheme" \ + "403 Forbidden" \ + upload_post_origin \ + "https://localhost:8000" + +#################################################### + +# +# Binary upload +# + +reset_testroot +write_default_repo_config "ADMIN_USER=bob" + +check_redirect \ + "binary upload succeeds" \ + "303 See Other" \ + "./" \ + upload_binary + +check_file_bytes \ + "binary file preserved" \ + "$TESTROOT/storage/default/binary.bin" \ + '\000\001\002\003\377' + +#################################################### + +# +# Read-only repository +# + +reset_testroot +write_default_repo_config 'ADMIN_USER=bob +READ_ONLY=1' + +check_body \ + "readonly GET upload rejected" \ + "read-only" \ + upload_get + +check_file_missing \ + "readonly GET does not create file" \ + "$TESTROOT/storage/default/readonly.txt" + +check_status \ + "readonly POST upload rejected" \ + "403 Forbidden" \ + upload + +#################################################### + +# +# Authentication +# + +# +# ADMIN_USER configured: admin can upload. +# + +reset_testroot + +write_default_repo_config "ADMIN_USER=bob" + +check_status \ + "admin user allowed to upload" \ + "303 See Other" \ + upload_post_user \ + "bob" + +check_file \ + "admin upload succeeds" \ + "$TESTROOT/storage/default/upload.txt" \ + "uploaded content" + +# +# ADMIN_USER configured: non-admin is rejected. +# + +reset_testroot + +write_default_repo_config "ADMIN_USER=bob" + +check_status \ + "non-admin user rejected from upload" \ + "403 Forbidden" \ + upload_post_user \ + "alice" + +check_file_missing \ + "non-admin upload not created" \ + "$TESTROOT/storage/default/upload.txt" + +# +# ADMIN_USER configured: anonymous is rejected. +# + +reset_testroot + +write_default_repo_config "ADMIN_USER=bob" + +check_status \ + "anonymous upload rejected" \ + "403 Forbidden" \ + upload_post_no_user + +check_file_missing \ + "anonymous upload not created" \ + "$TESTROOT/storage/default/upload.txt" + +# +# No ADMIN_USER: anonymous is rejected. +# + +reset_testroot + +write_default_repo_config "" + +check_status \ + "anonymous upload rejected without ADMIN_USER" \ + "403 Forbidden" \ + upload_post_no_user + +check_file_missing \ + "anonymous upload without ADMIN_USER not created" \ + "$TESTROOT/storage/default/upload.txt" + +# +# No ADMIN_USER: authenticated user is rejected. +# + +reset_testroot + +write_default_repo_config "" + +check_status \ + "authenticated upload rejected without ADMIN_USER" \ + "403 Forbidden" \ + upload_post_user \ + "alice" + +check_file_missing \ + "authenticated upload without ADMIN_USER not created" \ + "$TESTROOT/storage/default/upload.txt" + +# +# Template failure handling +# + +reset_testroot +write_default_repo_config "ADMIN_USER=bob" + +check_status \ + "template failure returns 500" \ + "500 Internal Server Error" \ + env \ + STORAGE_ROOT="$TESTROOT/storage" \ + SIMPLEFILES_CONFIG="$TEST_CONFIG" \ + REMOTE_USER=bob \ + REQUEST_METHOD=GET \ + SIMPLEFILES_ROOT="$TESTROOT/does-not-exist" \ + "$CGI" + +check_body \ + "template failure does not return 200" \ + "Template error" \ + env \ + STORAGE_ROOT="$TESTROOT/storage" \ + SIMPLEFILES_CONFIG="$TEST_CONFIG" \ + REMOTE_USER=bob \ + REQUEST_METHOD=GET \ + SIMPLEFILES_ROOT="$TESTROOT/does-not-exist" \ + "$CGI" + +#################################################### + +# +# Temporary upload cleanup failure +# + +reset_testroot +write_default_repo_config "ADMIN_USER=bob" + +check_redirect \ + "cleanup failure preserves published upload" \ + "303 See Other" \ + "./" \ + upload_cleanup_failure + +check_file \ + "cleanup failure preserves published file" \ + "$TESTROOT/storage/default/cleanup-failure.txt" \ + "published despite cleanup failure" + +test_result diff --git a/tests/unit/common.c b/tests/unit/common.c @@ -0,0 +1,218 @@ +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <unistd.h> + +#include "common.h" + +static int +write_config(const char *path, const char *content) +{ + FILE *fp; + + fp = fopen(path, "w"); + if (fp == NULL) + return -1; + + if (fputs(content, fp) == EOF) { + fclose(fp); + return -1; + } + + fclose(fp); + return 0; +} + +static int +test_csrf_missing_config(void) +{ + char path[] = "/tmp/simplefiles-common-test-XXXXXX"; + int fd; + int rc; + + fd = mkstemp(path); + if (fd == -1) { + fprintf(stderr, + "FAIL: csrf missing config: mkstemp failed\n"); + return 1; + } + + close(fd); + + /* + * Empty config file means CSRF_ORIGIN is not configured. + */ + setenv("SIMPLEFILES_CONFIG", path, 1); + unsetenv("HTTP_ORIGIN"); + + rc = check_csrf_origin(); + + unlink(path); + unsetenv("SIMPLEFILES_CONFIG"); + + if (rc != -2) { + fprintf(stderr, + "FAIL: csrf missing config returned %d, expected -2\n", + rc); + return 1; + } + + printf("ok: csrf missing config\n"); + return 0; +} + +static int +test_csrf_missing_origin(void) +{ + char path[] = "/tmp/simplefiles-common-test-XXXXXX"; + int fd; + int rc; + + fd = mkstemp(path); + if (fd == -1) { + fprintf(stderr, + "FAIL: csrf missing origin: mkstemp failed\n"); + return 1; + } + + close(fd); + + if (write_config(path, + "CSRF_ORIGIN=http://localhost:8000\n") == -1) { + fprintf(stderr, + "FAIL: csrf missing origin: could not write config\n"); + unlink(path); + return 1; + } + + setenv("SIMPLEFILES_CONFIG", path, 1); + unsetenv("HTTP_ORIGIN"); + + rc = check_csrf_origin(); + + unlink(path); + unsetenv("SIMPLEFILES_CONFIG"); + + if (rc != -1) { + fprintf(stderr, + "FAIL: csrf missing origin returned %d, expected -1\n", + rc); + return 1; + } + + printf("ok: csrf missing origin\n"); + return 0; +} + +static int +test_csrf_wrong_origin(void) +{ + char path[] = "/tmp/simplefiles-common-test-XXXXXX"; + int fd; + int rc; + + fd = mkstemp(path); + if (fd == -1) { + fprintf(stderr, + "FAIL: csrf wrong origin: mkstemp failed\n"); + return 1; + } + + close(fd); + + if (write_config(path, + "CSRF_ORIGIN=http://localhost:8000\n") == -1) { + fprintf(stderr, + "FAIL: csrf wrong origin: could not write config\n"); + unlink(path); + return 1; + } + + setenv("SIMPLEFILES_CONFIG", path, 1); + setenv("HTTP_ORIGIN", + "http://evil.example.com", + 1); + + rc = check_csrf_origin(); + + unlink(path); + unsetenv("SIMPLEFILES_CONFIG"); + unsetenv("HTTP_ORIGIN"); + + if (rc != -1) { + fprintf(stderr, + "FAIL: csrf wrong origin returned %d, expected -1\n", + rc); + return 1; + } + + printf("ok: csrf wrong origin\n"); + return 0; +} + +static int +test_csrf_correct_origin(void) +{ + char path[] = "/tmp/simplefiles-common-test-XXXXXX"; + int fd; + int rc; + + fd = mkstemp(path); + if (fd == -1) { + fprintf(stderr, + "FAIL: csrf correct origin: mkstemp failed\n"); + return 1; + } + + close(fd); + + if (write_config(path, + "CSRF_ORIGIN=http://localhost:8000\n") == -1) { + fprintf(stderr, + "FAIL: csrf correct origin: could not write config\n"); + unlink(path); + return 1; + } + + setenv("SIMPLEFILES_CONFIG", path, 1); + setenv("HTTP_ORIGIN", + "http://localhost:8000", + 1); + + rc = check_csrf_origin(); + + unlink(path); + unsetenv("SIMPLEFILES_CONFIG"); + unsetenv("HTTP_ORIGIN"); + + if (rc != 0) { + fprintf(stderr, + "FAIL: csrf correct origin returned %d, expected 0\n", + rc); + return 1; + } + + printf("ok: csrf correct origin\n"); + return 0; +} + +int +main(void) +{ + int failures = 0; + + failures += test_csrf_missing_config(); + failures += test_csrf_missing_origin(); + failures += test_csrf_wrong_origin(); + failures += test_csrf_correct_origin(); + + if (failures != 0) { + fprintf(stderr, + "%d test(s) failed\n", + failures); + return EXIT_FAILURE; + } + + printf("all tests passed\n"); + return EXIT_SUCCESS; +} diff --git a/tests/unit/config.c b/tests/unit/config.c @@ -0,0 +1,215 @@ +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/stat.h> +#include <unistd.h> + +#include "config.h" + +#define TEST_ROOT "/tmp/simplefiles-config-test" +#define TEST_STORAGE TEST_ROOT "/storage" +#define TEST_REPO TEST_STORAGE "/default" +#define TEST_CONFIG TEST_REPO "/.config" + +struct config_test_case { + const char *name; + const char *contents; + off_t expected_upload; + off_t expected_quota; +}; + +static const struct config_test_case config_tests[] = { + { + "valid values", + "MAX_UPLOAD=123456\n" + "QUOTA=987654\n", + 123456, + 987654, + }, + + { + "whitespace around values", + "MAX_UPLOAD = 222222\n" + "QUOTA= 333333\n", + 222222, + 333333, + }, + + { + "invalid values rejected", + "MAX_UPLOAD=-1\n" + "QUOTA=abc\n", + DEFAULT_MAX_UPLOAD, + DEFAULT_REPO_QUOTA, + }, + + { + "zero values rejected", + "MAX_UPLOAD=0\n" + "QUOTA=0\n", + DEFAULT_MAX_UPLOAD, + DEFAULT_REPO_QUOTA, + }, + + { + "comments ignored", + "# MAX_UPLOAD=1\n" + "# QUOTA=2\n" + "MAX_UPLOAD=444444\n" + "QUOTA=555555\n", + 444444, + 555555, + }, +}; + + +static int +mkdir_p(const char *path) +{ + if (mkdir(path, 0700) == -1) { + if (access(path, F_OK) == 0) + return 0; + + return -1; + } + + return 0; +} + + +static int +setup_environment(void) +{ + if (mkdir_p(TEST_ROOT) == -1) + return -1; + + if (mkdir_p(TEST_STORAGE) == -1) + return -1; + + if (mkdir_p(TEST_REPO) == -1) + return -1; + + setenv("STORAGE_ROOT", TEST_STORAGE, 1); + setenv("SCRIPT_NAME", "/default/upload.cgi", 1); + + return 0; +} + + +static int +write_config(const char *contents) +{ + FILE *fp; + + fp = fopen(TEST_CONFIG, "w"); + if (fp == NULL) + return -1; + + if (contents != NULL) + fprintf(fp, "%s", contents); + + fclose(fp); + + return 0; +} + + +static int +test_repository_config(void) +{ + size_t i; + int failures = 0; + + for (i = 0; + i < sizeof(config_tests) / sizeof(config_tests[0]); + i++) { + + if (write_config(config_tests[i].contents) == -1) { + fprintf(stderr, + "FAIL: %s: cannot write config\n", + config_tests[i].name); + failures++; + continue; + } + + if (config_max_upload() != + config_tests[i].expected_upload) { + fprintf(stderr, + "FAIL: %s: config_max_upload() returned %lld, " + "expected %lld\n", + config_tests[i].name, + (long long)config_max_upload(), + (long long)config_tests[i].expected_upload); + failures++; + continue; + } + + if (config_repo_quota() != + config_tests[i].expected_quota) { + fprintf(stderr, + "FAIL: %s: config_repo_quota() returned %lld, " + "expected %lld\n", + config_tests[i].name, + (long long)config_repo_quota(), + (long long)config_tests[i].expected_quota); + failures++; + continue; + } + + printf("ok: %s\n", config_tests[i].name); + } + + return failures; +} + + +static int +test_missing_config(void) +{ + unlink(TEST_CONFIG); + + if (config_max_upload() != DEFAULT_MAX_UPLOAD) { + fprintf(stderr, + "FAIL: missing config max upload\n"); + return 1; + } + + if (config_repo_quota() != DEFAULT_REPO_QUOTA) { + fprintf(stderr, + "FAIL: missing config quota\n"); + return 1; + } + + printf("ok: missing config uses defaults\n"); + + return 0; +} + + +int +main(void) +{ + int failures; + + failures = 0; + + if (setup_environment() == -1) { + fprintf(stderr, + "FAIL: setup failed\n"); + return EXIT_FAILURE; + } + + failures += test_repository_config(); + failures += test_missing_config(); + + if (failures != 0) { + fprintf(stderr, + "%d test(s) failed\n", + failures); + return EXIT_FAILURE; + } + + printf("all tests passed\n"); + + return EXIT_SUCCESS; +} diff --git a/tests/unit/html.c b/tests/unit/html.c @@ -0,0 +1,555 @@ +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <unistd.h> + +#include "html.h" + +#define ARRAY_SIZE(a) (sizeof(a) / sizeof((a)[0])) + +struct test_case { + const char *name; + const char *template; + const struct html_var *vars; + size_t nvars; + const struct html_block *blocks; + size_t nblocks; + const char *expected; + int expected_result; +}; + +struct escape_test_case { + const char *name; + const char *input; + const char *expected; +}; +/* --- Test data --- */ + +/* Basic Variables */ + +static const struct html_var basic_vars[] = { + {"NAME", "SimpleFiles"}, + {"MAX_UPLOAD", "100 MB"}, +}; +/* FILES: normal rows */ +static const struct html_var file1_vars[] = { + {"NAME", "one.txt"}, + {"SIZE", "123 B"}, +}; + +static const struct html_var file2_vars[] = { + {"NAME", "two.txt"}, + {"SIZE", "456 B"}, +}; + +static const struct html_row file_rows[] = { + {file1_vars, 2}, + {file2_vars, 2}, +}; + +static const struct html_block files_block[] = { + {"FILES", file_rows, 2}, +}; +/* FILES: empty block */ +static const struct html_block empty_block[] = { + {"FILES", NULL, 0}, +}; +/* FILES: variable precedence */ +static const struct html_var global_vars[] = { + {"NAME", "global"}, +}; + +static const struct html_var row_vars[] = { + {"NAME", "row"}, +}; + +static const struct html_row override_rows[] = { + {row_vars, 1}, +}; + +static const struct html_block override_block[] = { + {"FILES", override_rows, 1}, +}; +/* FILES: global fallback */ + +static const struct html_var site_vars[] = { + {"SITE", "SimpleFiles"}, +}; + +static const struct html_var row_name_vars[] = { + {"NAME", "test.txt"}, +}; + +static const struct html_row fallback_rows[] = { + {row_name_vars, 1}, +}; + +static const struct html_block fallback_block[] = { + {"FILES", fallback_rows, 1}, +}; + +static const struct html_var empty_vars[] = { + {"NAME", ""}, +}; +/* FILES: HTML escaping */ + +static const struct html_var escaped_row_vars[] = { + {"NAME", "<script>alert(\"x\")</script>"}, +}; + +static const struct html_row escaped_rows[] = { + {escaped_row_vars, 1}, +}; + +static const struct html_block escaped_block[] = { + {"FILES", escaped_rows, 1}, +}; +/* FILES: empty variable value */ + +static const struct html_var empty_row_vars[] = { + {"NAME", ""}, +}; + +static const struct html_row empty_value_rows[] = { + {empty_row_vars, 1}, +}; + +static const struct html_block empty_value_block[] = { + {"FILES", empty_value_rows, 1}, +}; +/* --- Test helpers --- */ + +static int +run_test(const struct test_case * test) +{ + FILE *template; + FILE *output; + char path[] = "/tmp/html-test-XXXXXX"; + char buf[4096]; + size_t n; + int fd; + int result; + int failures = 0; + + fd = mkstemp(path); + if (fd == -1) { + perror("mkstemp"); + return 1; + } + template = fdopen(fd, "w+"); + if (template == NULL) { + perror("fdopen"); + close(fd); + unlink(path); + return 1; + } + if (fputs(test->template, template) == EOF) { + perror("fputs"); + fclose(template); + unlink(path); + return 1; + } + fflush(template); + rewind(template); + + output = tmpfile(); + if (output == NULL) { + perror("tmpfile"); + fclose(template); + unlink(path); + return 1; + } + result = render_template(output, path, + test->vars, test->nvars, test->blocks, test->nblocks); + + if (result != test->expected_result) { + fprintf(stderr, + "FAIL: %s: render_template() returned %d, " + "expected %d\n", + test->name, + result, + test->expected_result); + failures++; + goto done; + } + if (result == 0) { + fflush(output); + rewind(output); + + n = fread(buf, 1, sizeof(buf) - 1, output); + buf[n] = '\0'; + + if (strcmp(buf, test->expected) != 0) { + fprintf(stderr, + "FAIL: %s: output was:\n" + "---\n%s---\n" + "expected:\n" + "---\n%s---\n", + test->name, + buf, + test->expected); + failures++; + goto done; + } + } + printf("ok: %s\n", test->name); + +done: + fclose(output); + fclose(template); + unlink(path); + + return failures; +} + +static int +run_escape_test(const struct escape_test_case * test) +{ + FILE *output; + char buf[4096]; + size_t n; + int failures = 0; + + output = tmpfile(); + if (output == NULL) { + perror("tmpfile"); + return 1; + } + html_escape(output, test->input); + + fflush(output); + rewind(output); + + n = fread(buf, 1, sizeof(buf) - 1, output); + buf[n] = '\0'; + + if (strcmp(buf, test->expected) != 0) { + fprintf(stderr, + "FAIL: %s: html_escape() output was:\n" + "---\n%s---\n" + "expected:\n" + "---\n%s---\n", + test->name, + buf, + test->expected); + failures++; + } + if (failures == 0) + printf("ok: %s\n", test->name); + + fclose(output); + + return failures; +} +/* --- Template rendering tests --- */ +static const struct test_case tests[] = { + { + "plain text", + "hello world\n", + NULL, + 0, + NULL, + 0, + "hello world\n", + 0 + }, + { + "one variable", + "hello {{NAME}}\n", + basic_vars, + ARRAY_SIZE(basic_vars), + NULL, + 0, + "hello SimpleFiles\n", + 0 + }, + { + "multiple variables", + "{{NAME}}: max {{MAX_UPLOAD}}\n", + basic_vars, + ARRAY_SIZE(basic_vars), + NULL, + 0, + "SimpleFiles: max 100 MB\n", + 0 + }, + { + "unknown variable", + "hello {{MISSING}}\n", + NULL, + 0, + NULL, + 0, + NULL, + -1 + }, + { + "html is preserved", + "<h1>{{NAME}}</h1>\n" + "<p>Upload a file.</p>\n", + basic_vars, + ARRAY_SIZE(basic_vars), + NULL, + 0, + "<h1>SimpleFiles</h1>\n" + "<p>Upload a file.</p>\n", + 0 + }, + { + "block with two rows", + "<ul>\n" + "{{#FILES}}" + "<li>{{NAME}} ({{SIZE}})</li>\n" + "{{/FILES}}" + "</ul>\n", + NULL, + 0, + files_block, + ARRAY_SIZE(files_block), + "<ul>\n" + "<li>one.txt (123 B)</li>\n" + "<li>two.txt (456 B)</li>\n" + "</ul>\n", + 0 + }, + { + "empty block", + "before\n" + "{{#FILES}}" + "<li>{{NAME}}</li>\n" + "{{/FILES}}" + "after\n", + NULL, + 0, + empty_block, + ARRAY_SIZE(empty_block), + "before\n" + "after\n", + 0 + }, + { + "row variable overrides global variable", + "{{#FILES}}{{NAME}}{{/FILES}}", + global_vars, + ARRAY_SIZE(global_vars), + override_block, + ARRAY_SIZE(override_block), + "row", + 0 + }, + { + "global variable available inside block", + "{{#FILES}}{{SITE}}: {{NAME}}{{/FILES}}", + site_vars, + ARRAY_SIZE(site_vars), + fallback_block, + ARRAY_SIZE(fallback_block), + "SimpleFiles: test.txt", + 0 + }, + { + "unknown variable inside block", + "{{#FILES}}{{MISSING}}{{/FILES}}", + NULL, + 0, + files_block, + ARRAY_SIZE(files_block), + NULL, + -1 + }, + { + "unknown block", + "before{{#MISSING}}x{{/MISSING}}after", + NULL, + 0, + files_block, + ARRAY_SIZE(files_block), + NULL, + -1 + }, + { + "missing closing block", + "{{#FILES}}{{NAME}}", + NULL, + 0, + files_block, + ARRAY_SIZE(files_block), + NULL, + -1 + }, + { + "malformed block opening", + "{{#FILES", + NULL, + 0, + files_block, + ARRAY_SIZE(files_block), + NULL, + -1 + }, + { + "unterminated variable", + "hello {{NAME", + basic_vars, + ARRAY_SIZE(basic_vars), + NULL, + 0, + NULL, + -1 + }, + { + "empty variable", + "hello {{}}", + basic_vars, + ARRAY_SIZE(basic_vars), + NULL, + 0, + NULL, + -1 + }, + { + "overlong variable name", + "{{AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA}}", + basic_vars, + ARRAY_SIZE(basic_vars), + NULL, + 0, + NULL, + -1 + }, + { + "empty variable value", + "before[{{NAME}}]after", + empty_vars, + ARRAY_SIZE(empty_vars), + NULL, + 0, + "before[]after", + 0 + }, + { + "HTML escaping inside block", + "{{#FILES}}{{NAME}}{{/FILES}}", + NULL, + 0, + escaped_block, + ARRAY_SIZE(escaped_block), + "&lt;script&gt;alert(&quot;x&quot;)&lt;/script&gt;", + 0 + }, + { + "unterminated variable with whitespace", + "hello {{ world", + basic_vars, + ARRAY_SIZE(basic_vars), + NULL, + 0, + NULL, + -1 + }, + { + "block with static text", + "before{{#FILES}}hello{{/FILES}}after", + NULL, + 0, + files_block, + ARRAY_SIZE(files_block), + "beforehellohelloafter", + 0 + }, + { + "empty row variable value", + "{{#FILES}}[{{NAME}}]{{/FILES}}", + NULL, + 0, + empty_value_block, + ARRAY_SIZE(empty_value_block), + "[]", + 0 + }, + { + "text surrounding block", + "before\n" + "{{#FILES}}" + "{{NAME}}\n" + "{{/FILES}}" + "after\n", + NULL, + 0, + files_block, + ARRAY_SIZE(files_block), + "before\n" + "one.txt\n" + "two.txt\n" + "after\n", + 0 + }, +}; +/* --- HTML escaping tests --- */ + +static const struct escape_test_case escape_tests[] = { + { + "escape plain text", + "hello world", + "hello world" + }, + { + "escape ampersand", + "a & b", + "a &amp; b" + }, + { + "escape less-than", + "<script>", + "&lt;script&gt;" + }, + { + "escape greater-than", + "1 > 0", + "1 &gt; 0" + }, + { + "escape double quote", + "hello \"world\"", + "hello &quot;world&quot;" + }, + { + "escape single quote", + "it's", + "it&#39;s" + }, + { + "escape multiple characters", + "<a href=\"foo?a=1&b=2\">it's ok</a>", + "&lt;a href=&quot;foo?a=1&amp;b=2&quot;&gt;it&#39;s ok&lt;/a&gt;" + }, + { + "escape empty string", + "", + "" + }, +}; + +int +main(void) +{ + size_t i; + int failures = 0; + + for (i = 0; i < ARRAY_SIZE(tests); + i++) + failures += run_test(&tests[i]); + + for (i = 0; i < ARRAY_SIZE(escape_tests); + i++) + failures += run_escape_test(&escape_tests[i]); + + if (failures != 0) { + fprintf(stderr, + "%d test(s) failed\n", + failures); + return EXIT_FAILURE; + } + printf("all tests passed\n"); + return EXIT_SUCCESS; +} diff --git a/tests/unit/http.c b/tests/unit/http.c @@ -0,0 +1,85 @@ +#include <assert.h> +#include <stdio.h> +#include <string.h> +#include <unistd.h> + +#include "http.h" + +static int saved_stdout; + +static void +capture_stdout(FILE *fp) +{ + saved_stdout = dup(STDOUT_FILENO); + assert(saved_stdout != -1); + + assert(dup2(fileno(fp), STDOUT_FILENO) != -1); +} + +static void +restore_stdout(void) +{ + assert(dup2(saved_stdout, STDOUT_FILENO) != -1); + close(saved_stdout); +} + +static void +test_valid_filename(void) +{ + FILE *fp; + char buffer[1024]; + size_t n; + + fp = tmpfile(); + assert(fp != NULL); + + capture_stdout(fp); + + assert(download_headers("hello.txt", 123) == 0); + + fflush(stdout); + rewind(fp); + + n = fread(buffer, 1, sizeof(buffer) - 1, fp); + buffer[n] = '\0'; + + restore_stdout(); + + assert(strstr(buffer, + "Content-Disposition: attachment; filename=\"hello.txt\"\r\n") + != NULL); + + assert(strstr(buffer, "Content-Length: 123\r\n") != NULL); + + fclose(fp); + + printf("ok: valid download headers\n"); +} + +static void +test_reject_cr(void) +{ + assert(download_headers("hello\r\nX-Injected: yes", 123) == -1); + + printf("ok: reject CR in filename\n"); +} + +static void +test_reject_lf(void) +{ + assert(download_headers("hello\nX-Injected: yes", 123) == -1); + + printf("ok: reject LF in filename\n"); +} + +int +main(void) +{ + test_valid_filename(); + test_reject_cr(); + test_reject_lf(); + + printf("all tests passed\n"); + + return 0; +} diff --git a/tests/unit/multipart.c b/tests/unit/multipart.c @@ -0,0 +1,1816 @@ +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <fcntl.h> +#include <unistd.h> + +#include "multipart.h" + +#define TEST_SPOOL "/tmp/simplefiles-multipart-test-spool" +#define TEST_OUTPUT "/tmp/simplefiles-multipart-test-output" +#define TEST_BOUNDARY_MAX 70 + +static int write_file(int fd, const char *data); +static int create_spool(const char *body, struct multipart_request * req); + +static int +test_parse_request(const char *ctype, + const char *expected_boundary, + int expected) +{ + struct multipart_request req; + int ret; + + memset(&req, 0, sizeof(req)); + + if (setenv("CONTENT_TYPE", ctype, 1) == -1 || + setenv("CONTENT_LENGTH", "100", 1) == -1) { + fprintf(stderr, + "FAIL: parse request: could not set environment\n"); + return 1; + } + ret = multipart_parse_request(&req); + + if (ret != expected) { + fprintf(stderr, + "FAIL: parse request: '%s' returned %d, " + "expected %d\n", + ctype, ret, expected); + return 1; + } + if (expected_boundary != NULL && + strcmp(req.boundary, expected_boundary) != 0) { + fprintf(stderr, + "FAIL: parse request: boundary was '%s', " + "expected '%s'\n", + req.boundary, expected_boundary); + return 1; + } + return 0; +} + +static int +create_spool(const char *body, struct multipart_request * req) +{ + int fd; + + fd = open(TEST_SPOOL, O_RDWR | O_CREAT | O_TRUNC, 0600); + if (fd == -1) + return -1; + + if (write_file(fd, body) == -1) { + close(fd); + unlink(TEST_SPOOL); + return -1; + } + memset(req, 0, sizeof(*req)); + + if (strlcpy(req->boundary, "test-boundary", + sizeof(req->boundary)) >= sizeof(req->boundary)) { + close(fd); + unlink(TEST_SPOOL); + return -1; + } + req->length = (off_t) strlen(body); + + return fd; +} + +static int +write_file(int fd, const char *data) +{ + size_t len; + size_t written; + ssize_t n; + + len = strlen(data); + written = 0; + + while (written < len) { + n = write(fd, data + written, len - written); + if (n <= 0) + return -1; + + written += (size_t) n; + } + + if (lseek(fd, 0, SEEK_SET) == (off_t) - 1) + return -1; + + return 0; +} + +static int +test_extract_part(void) +{ + const char *body = + "--test-boundary\r\n" + "Content-Disposition: form-data; name=\"file\"; " + "filename=\"hello.txt\"\r\n" + "Content-Type: text/plain\r\n" + "\r\n" + "hello world\r\n" + "--test-boundary--\r\n"; + struct multipart_request req; + struct multipart_part part; + char output[64]; + int fd; + int outfd; + ssize_t n; + + fd = open(TEST_SPOOL, O_RDWR | O_CREAT | O_TRUNC, 0600); + if (fd == -1) { + fprintf(stderr, + "FAIL: extract part: cannot create spool\n"); + return 1; + } + if (write_file(fd, body) == -1) { + fprintf(stderr, + "FAIL: extract part: cannot write spool\n"); + close(fd); + unlink(TEST_SPOOL); + return 1; + } + memset(&req, 0, sizeof(req)); + + if (strlcpy(req.boundary, "test-boundary", + sizeof(req.boundary)) >= sizeof(req.boundary)) { + fprintf(stderr, + "FAIL: extract part: boundary too long\n"); + close(fd); + unlink(TEST_SPOOL); + return 1; + } + req.length = (off_t) strlen(body); + + if (multipart_extract_part(fd, &req, &part) != MULTIPART_OK) { + fprintf(stderr, + "FAIL: extract part: multipart_extract_part() failed\n"); + close(fd); + unlink(TEST_SPOOL); + return 1; + } + if (strcmp(part.filename, "hello.txt") != 0) { + fprintf(stderr, + "FAIL: extract part: filename was '%s', " + "expected 'hello.txt'\n", + part.filename); + close(fd); + unlink(TEST_SPOOL); + return 1; + } + if (part.length != 11) { + fprintf(stderr, + "FAIL: extract part: length was %lld, expected 11\n", + (long long) part.length); + close(fd); + unlink(TEST_SPOOL); + return 1; + } + outfd = open(TEST_OUTPUT, O_RDWR | O_CREAT | O_TRUNC, 0600); + if (outfd == -1) { + fprintf(stderr, + "FAIL: extract part: cannot create output\n"); + close(fd); + unlink(TEST_SPOOL); + return 1; + } + if (multipart_copy_part(fd, &part, outfd) != MULTIPART_OK) { + fprintf(stderr, + "FAIL: extract part: multipart_copy_part() failed\n"); + close(outfd); + close(fd); + unlink(TEST_SPOOL); + unlink(TEST_OUTPUT); + return 1; + } + if (lseek(outfd, 0, SEEK_SET) == (off_t) - 1) { + fprintf(stderr, + "FAIL: extract part: cannot seek output\n"); + close(outfd); + close(fd); + unlink(TEST_SPOOL); + unlink(TEST_OUTPUT); + return 1; + } + n = read(outfd, output, sizeof(output) - 1); + if (n < 0) { + fprintf(stderr, + "FAIL: extract part: cannot read output\n"); + close(outfd); + close(fd); + unlink(TEST_SPOOL); + unlink(TEST_OUTPUT); + return 1; + } + output[n] = '\0'; + + if (strcmp(output, "hello world") != 0) { + fprintf(stderr, + "FAIL: extract part: output was '%s', " + "expected 'hello world'\n", + output); + close(outfd); + close(fd); + unlink(TEST_SPOOL); + unlink(TEST_OUTPUT); + return 1; + } + close(outfd); + close(fd); + unlink(TEST_SPOOL); + unlink(TEST_OUTPUT); + + printf("ok: extract and copy multipart part\n"); + + return 0; +} + +static int +test_multipart_copy_empty_part(void) +{ + struct multipart_part part; + int fd; + int outfd; + + fd = open(TEST_SPOOL, O_RDWR | O_CREAT | O_TRUNC, 0600); + if (fd == -1) { + fprintf(stderr, + "FAIL: empty part: cannot create spool\n"); + return 1; + } + memset(&part, 0, sizeof(part)); + + part.offset = 0; + part.length = 0; + + outfd = open(TEST_OUTPUT, O_RDWR | O_CREAT | O_TRUNC, 0600); + if (outfd == -1) { + fprintf(stderr, + "FAIL: empty part: cannot create output\n"); + close(fd); + unlink(TEST_SPOOL); + return 1; + } + if (multipart_copy_part(fd, &part, outfd) == -1) { + fprintf(stderr, + "FAIL: empty part: multipart_copy_part() failed\n"); + close(outfd); + close(fd); + unlink(TEST_SPOOL); + unlink(TEST_OUTPUT); + return 1; + } + close(outfd); + close(fd); + unlink(TEST_SPOOL); + unlink(TEST_OUTPUT); + + printf("ok: copy empty multipart part\n"); + + return 0; +} + +static int +test_filename_not_found_in_payload(void) +{ + const char *body = + "--test-boundary\r\n" + "Content-Disposition: form-data; name=\"file\"; " + "filename=\"hello.txt\"\r\n" + "Content-Type: text/plain\r\n" + "\r\n" + "this is file data containing filename=\"fake.txt\"\r\n" + "--test-boundary--\r\n"; + struct multipart_request req; + struct multipart_part part; + int fd; + + fd = open(TEST_SPOOL, O_RDWR | O_CREAT | O_TRUNC, 0600); + if (fd == -1) { + fprintf(stderr, + "FAIL: filename payload: cannot create spool\n"); + return 1; + } + if (write_file(fd, body) == -1) { + fprintf(stderr, + "FAIL: filename payload: cannot write spool\n"); + close(fd); + unlink(TEST_SPOOL); + return 1; + } + memset(&req, 0, sizeof(req)); + + if (strlcpy(req.boundary, "test-boundary", + sizeof(req.boundary)) >= sizeof(req.boundary)) { + fprintf(stderr, + "FAIL: filename payload: boundary too long\n"); + close(fd); + unlink(TEST_SPOOL); + return 1; + } + req.length = (off_t) strlen(body); + + if (multipart_extract_part(fd, &req, &part) == -1) { + fprintf(stderr, + "FAIL: filename payload: extraction failed\n"); + close(fd); + unlink(TEST_SPOOL); + return 1; + } + if (strcmp(part.filename, "hello.txt") != 0) { + fprintf(stderr, + "FAIL: filename payload: filename was '%s', " + "expected 'hello.txt'\n", + part.filename); + close(fd); + unlink(TEST_SPOOL); + return 1; + } + close(fd); + unlink(TEST_SPOOL); + + printf("ok: filename is extracted from headers only\n"); + + return 0; +} + +static int +test_reject_multiple_file_parts(void) +{ + const char *body = + "--test-boundary\r\n" + "Content-Disposition: form-data; name=\"file\"; " + "filename=\"one.txt\"\r\n" + "Content-Type: text/plain\r\n" + "\r\n" + "one\r\n" + "--test-boundary\r\n" + "Content-Disposition: form-data; name=\"file\"; " + "filename=\"two.txt\"\r\n" + "Content-Type: text/plain\r\n" + "\r\n" + "two\r\n" + "--test-boundary--\r\n"; + struct multipart_request req; + struct multipart_part part; + int fd; + + fd = open(TEST_SPOOL, O_RDWR | O_CREAT | O_TRUNC, 0600); + if (fd == -1) { + fprintf(stderr, + "FAIL: multiple file parts: cannot create spool\n"); + return 1; + } + if (write_file(fd, body) == -1) { + fprintf(stderr, + "FAIL: multiple file parts: cannot write spool\n"); + close(fd); + unlink(TEST_SPOOL); + return 1; + } + memset(&req, 0, sizeof(req)); + + if (strlcpy(req.boundary, "test-boundary", + sizeof(req.boundary)) >= sizeof(req.boundary)) { + fprintf(stderr, + "FAIL: multiple file parts: boundary too long\n"); + close(fd); + unlink(TEST_SPOOL); + return 1; + } + req.length = (off_t) strlen(body); + + if (multipart_extract_part(fd, &req, &part) != MULTIPART_MULTIPLE_FILES) { + fprintf(stderr, + "FAIL: multiple file parts: " + "multipart_extract_part() accepted multiple files\n"); + close(fd); + unlink(TEST_SPOOL); + return 1; + } + close(fd); + unlink(TEST_SPOOL); + + printf("ok: reject multiple file parts\n"); + + return 0; +} + +static int +test_missing_opening_boundary(void) +{ + const char *body = + "garbage\r\n" + "Content-Disposition: form-data; name=\"file\"; " + "filename=\"hello.txt\"\r\n" + "\r\n" + "hello\r\n" + "--test-boundary--\r\n"; + struct multipart_request req; + struct multipart_part part; + int fd; + + fd = create_spool(body, &req); + if (fd == -1) { + fprintf(stderr, + "FAIL: missing opening boundary: setup failed\n"); + return 1; + } + if (multipart_extract_part(fd, &req, &part) != + MULTIPART_MALFORMED) { + fprintf(stderr, + "FAIL: missing opening boundary: " + "accepted malformed body\n"); + close(fd); + unlink(TEST_SPOOL); + return 1; + } + close(fd); + unlink(TEST_SPOOL); + + printf("ok: reject missing opening boundary\n"); + return 0; +} + +static int +test_wrong_opening_boundary(void) +{ + const char *body = + "--wrong-boundary\r\n" + "Content-Disposition: form-data; name=\"file\"; " + "filename=\"hello.txt\"\r\n" + "\r\n" + "hello\r\n" + "--wrong-boundary--\r\n"; + struct multipart_request req; + struct multipart_part part; + int fd; + + fd = create_spool(body, &req); + if (fd == -1) { + fprintf(stderr, + "FAIL: wrong opening boundary: setup failed\n"); + return 1; + } + if (multipart_extract_part(fd, &req, &part) != + MULTIPART_MALFORMED) { + fprintf(stderr, + "FAIL: wrong opening boundary: " + "accepted malformed body\n"); + close(fd); + unlink(TEST_SPOOL); + return 1; + } + close(fd); + unlink(TEST_SPOOL); + + printf("ok: reject wrong opening boundary\n"); + return 0; +} + +static int +test_missing_header_terminator(void) +{ + const char *body = + "--test-boundary\r\n" + "Content-Disposition: form-data; name=\"file\"; " + "filename=\"hello.txt\"\r\n" + "Content-Type: text/plain\r\n" + "hello"; + struct multipart_request req; + struct multipart_part part; + int fd; + + fd = create_spool(body, &req); + if (fd == -1) { + fprintf(stderr, + "FAIL: missing header terminator: setup failed\n"); + return 1; + } + if (multipart_extract_part(fd, &req, &part) != MULTIPART_MALFORMED) { + fprintf(stderr, + "FAIL: missing header terminator: " + "accepted malformed body\n"); + close(fd); + unlink(TEST_SPOOL); + return 1; + } + close(fd); + unlink(TEST_SPOOL); + + printf("ok: reject missing header terminator\n"); + return 0; +} + +static int +test_missing_closing_boundary(void) +{ + const char *body = + "--test-boundary\r\n" + "Content-Disposition: form-data; name=\"file\"; " + "filename=\"hello.txt\"\r\n" + "\r\n" + "hello\r\n"; + struct multipart_request req; + struct multipart_part part; + int fd; + + fd = create_spool(body, &req); + + if (fd == -1) { + fprintf(stderr, + "FAIL: missing closing boundary: setup failed\n"); + return 1; + } + if (multipart_extract_part(fd, &req, &part) != MULTIPART_MALFORMED) { + fprintf(stderr, + "FAIL: missing closing boundary: " + "accepted malformed body\n"); + close(fd); + unlink(TEST_SPOOL); + return 1; + } + close(fd); + unlink(TEST_SPOOL); + + printf("ok: reject missing closing boundary\n"); + return 0; +} + +static int +test_truncated_closing_boundary(void) +{ + const char *body = + "--test-boundary\r\n" + "Content-Disposition: form-data; name=\"file\"; " + "filename=\"hello.txt\"\r\n" + "\r\n" + "hello\r\n" + "--test-boundary"; + struct multipart_request req; + struct multipart_part part; + int fd; + + fd = create_spool(body, &req); + if (fd == -1) { + fprintf(stderr, + "FAIL: truncated closing boundary: setup failed\n"); + return 1; + } + if (multipart_extract_part(fd, &req, &part) != + MULTIPART_MALFORMED) { + fprintf(stderr, + "FAIL: truncated closing boundary: " + "accepted malformed body\n"); + close(fd); + unlink(TEST_SPOOL); + return 1; + } + close(fd); + unlink(TEST_SPOOL); + + printf("ok: reject truncated closing boundary\n"); + return 0; +} + +static int +test_invalid_boundary_suffix(void) +{ + const char *body = + "--test-boundary\r\n" + "Content-Disposition: form-data; name=\"file\"; " + "filename=\"hello.txt\"\r\n" + "\r\n" + "hello\r\n" + "--test-boundaryX\r\n"; + struct multipart_request req; + struct multipart_part part; + int fd; + + fd = create_spool(body, &req); + if (fd == -1) { + fprintf(stderr, + "FAIL: invalid boundary suffix: setup failed\n"); + return 1; + } + if (multipart_extract_part(fd, &req, &part) != + MULTIPART_MALFORMED) { + fprintf(stderr, + "FAIL: invalid boundary suffix: " + "accepted malformed body\n"); + close(fd); + unlink(TEST_SPOOL); + return 1; + } + close(fd); + unlink(TEST_SPOOL); + + printf("ok: reject invalid boundary suffix\n"); + return 0; +} + +static int +test_missing_content_disposition(void) +{ + const char *body = + "--test-boundary\r\n" + "Content-Type: text/plain\r\n" + "\r\n" + "hello\r\n" + "--test-boundary--\r\n"; + struct multipart_request req; + struct multipart_part part; + int fd; + + fd = create_spool(body, &req); + if (fd == -1) + return 1; + + if (multipart_extract_part(fd, &req, &part) != + MULTIPART_MISSING_FILENAME) { + fprintf(stderr, + "FAIL: missing Content-Disposition: " + "accepted body\n"); + close(fd); + unlink(TEST_SPOOL); + return 1; + } + close(fd); + unlink(TEST_SPOOL); + + printf("ok: reject missing Content-Disposition\n"); + return 0; +} + +static int +test_wrong_content_disposition(void) +{ + const char *body = + "--test-boundary\r\n" + "Content-Disposition: attachment; filename=\"hello.txt\"\r\n" + "\r\n" + "hello\r\n" + "--test-boundary--\r\n"; + struct multipart_request req; + struct multipart_part part; + int fd; + + fd = create_spool(body, &req); + if (fd == -1) + return 1; + + if (multipart_extract_part(fd, &req, &part) != + MULTIPART_MALFORMED) { + fprintf(stderr, + "FAIL: wrong Content-Disposition: " + "accepted body\n"); + close(fd); + unlink(TEST_SPOOL); + return 1; + } + close(fd); + unlink(TEST_SPOOL); + + printf("ok: reject wrong Content-Disposition\n"); + return 0; +} + +static int +test_unquoted_filename(void) +{ + const char *body = + "--test-boundary\r\n" + "Content-Disposition: form-data; name=\"file\"; " + "filename=hello.txt\r\n" + "\r\n" + "hello\r\n" + "--test-boundary--\r\n"; + struct multipart_request req; + struct multipart_part part; + int fd; + + fd = create_spool(body, &req); + if (fd == -1) + return 1; + + if (multipart_extract_part(fd, &req, &part) != + MULTIPART_MALFORMED) { + fprintf(stderr, + "FAIL: unquoted filename: accepted body\n"); + close(fd); + unlink(TEST_SPOOL); + return 1; + } + close(fd); + unlink(TEST_SPOOL); + + printf("ok: reject unquoted filename\n"); + return 0; +} + +static int +test_empty_filename(void) +{ + const char *body = + "--test-boundary\r\n" + "Content-Disposition: form-data; name=\"file\"; " + "filename=\"\"\r\n" + "\r\n" + "hello\r\n" + "--test-boundary--\r\n"; + struct multipart_request req; + struct multipart_part part; + int fd; + + fd = create_spool(body, &req); + if (fd == -1) + return 1; + + if (multipart_extract_part(fd, &req, &part) != + MULTIPART_MALFORMED) { + fprintf(stderr, + "FAIL: empty filename: accepted body\n"); + close(fd); + unlink(TEST_SPOOL); + return 1; + } + close(fd); + unlink(TEST_SPOOL); + + printf("ok: reject empty filename\n"); + return 0; +} + +static int +test_boundary_like_payload(void) +{ + const char *body = + "--test-boundary\r\n" + "Content-Disposition: form-data; name=\"file\"; " + "filename=\"hello.txt\"\r\n" + "\r\n" + "hello\r\n" + "--test-boundaryX\r\n" + "still file data\r\n" + "--test-boundary--\r\n"; + struct multipart_request req; + struct multipart_part part; + int fd; + + fd = create_spool(body, &req); + if (fd == -1) + return 1; + + if (multipart_extract_part(fd, &req, &part) != MULTIPART_OK) { + fprintf(stderr, + "FAIL: boundary-like payload: extraction failed\n"); + close(fd); + unlink(TEST_SPOOL); + return 1; + } + close(fd); + unlink(TEST_SPOOL); + + printf("ok: ignore boundary-like payload\n"); + return 0; +} + +static int +test_content_type_normal(void) +{ + if (test_parse_request( + "multipart/form-data; boundary=test-boundary", + "test-boundary", + MULTIPART_OK) != 0) + return 1; + + printf("ok: parse normal Content-Type boundary\n"); + return 0; +} + +static int +test_content_type_case_insensitive(void) +{ + if (test_parse_request( + "Multipart/Form-Data; boundary=test-boundary", + "test-boundary", + MULTIPART_OK) != 0) + return 1; + + printf("ok: Content-Type media type is case-insensitive\n"); + return 0; +} + +static int +test_content_type_boundary_case_insensitive(void) +{ + if (test_parse_request( + "multipart/form-data; BOUNDARY=test-boundary", + "test-boundary", + MULTIPART_OK) != 0) + return 1; + + printf("ok: boundary parameter name is case-insensitive\n"); + return 0; +} + +static int +test_content_type_quoted_boundary(void) +{ + if (test_parse_request( + "multipart/form-data; boundary=\"test-boundary\"", + "test-boundary", + MULTIPART_OK) != 0) + return 1; + + printf("ok: parse quoted boundary\n"); + return 0; +} + +static int +test_content_type_boundary_whitespace(void) +{ + if (test_parse_request( + "multipart/form-data; boundary = test-boundary", + "test-boundary", + MULTIPART_OK) != 0) + return 1; + + printf("ok: parse boundary with whitespace\n"); + return 0; +} + +static int +test_content_type_parameter_before_boundary(void) +{ + if (test_parse_request( + "multipart/form-data; charset=utf-8; " + "boundary=test-boundary", + "test-boundary", + MULTIPART_OK) != 0) + return 1; + + printf("ok: parse boundary after another parameter\n"); + return 0; +} + +static int +test_content_type_missing_boundary(void) +{ + if (test_parse_request( + "multipart/form-data", + NULL, + MULTIPART_MISSING_BOUNDARY) != 0) + return 1; + + printf("ok: reject missing Content-Type boundary\n"); + return 0; +} + +static int +test_content_type_invalid_boundary_character(void) +{ + if (test_parse_request( + "multipart/form-data; boundary=\"test boundary\"", + NULL, + MULTIPART_INVALID_BOUNDARY) != 0) + return 1; + + printf("ok: reject invalid boundary character\n"); + return 0; +} + +static int +test_content_type_boundary_too_long(void) +{ + char ctype[128]; + size_t prefix_len; + size_t i; + int ret; + + strcpy(ctype, "multipart/form-data; boundary="); + prefix_len = strlen(ctype); + + for (i = 0; i <= TEST_BOUNDARY_MAX; i++) + ctype[prefix_len + i] = 'a'; + + ctype[prefix_len + TEST_BOUNDARY_MAX + 1] = '\0'; + + ret = test_parse_request( + ctype, + NULL, + MULTIPART_INVALID_BOUNDARY); + + if (ret != 0) + return 1; + + printf("ok: reject oversized boundary\n"); + return 0; +} + +static int +test_content_type_malformed_boundary_parameter(void) +{ + if (test_parse_request( + "multipart/form-data; boundary", + NULL, + MULTIPART_INVALID_CONTENT_TYPE) != 0) + return 1; + + printf("ok: reject malformed boundary parameter\n"); + return 0; +} + +static int +test_content_type_wrong_media_type(void) +{ + if (test_parse_request( + "application/x-www-form-urlencoded; boundary=test-boundary", + NULL, + MULTIPART_INVALID_CONTENT_TYPE) != 0) + return 1; + + printf("ok: reject wrong media type\n"); + return 0; +} + +static int +test_content_type_duplicate_boundary(void) +{ + if (test_parse_request( + "multipart/form-data; boundary=one; boundary=two", + NULL, + MULTIPART_INVALID_BOUNDARY) != 0) + return 1; + + printf("ok: reject duplicate boundary parameters\n"); + return 0; +} + +static int +test_filename_is_actual_parameter(void) +{ + const char *body = + "--test-boundary\r\n" + "Content-Disposition: form-data; " + "name=\"file\"; " + "filename=\"filename=not-the-filename.txt\"\r\n" + "\r\n" + "hello\r\n" + "--test-boundary--\r\n"; + struct multipart_request req; + struct multipart_part part; + int fd; + + fd = create_spool(body, &req); + if (fd == -1) + return 1; + + if (multipart_extract_part(fd, &req, &part) != MULTIPART_OK) { + fprintf(stderr, + "FAIL: actual filename parameter: extraction failed\n"); + close(fd); + unlink(TEST_SPOOL); + return 1; + } + + if (strcmp(part.filename, "filename=not-the-filename.txt") != 0) { + fprintf(stderr, + "FAIL: actual filename parameter: filename was '%s', " + "expected 'filename=not-the-filename.txt'\n", + part.filename); + close(fd); + unlink(TEST_SPOOL); + return 1; + } + + close(fd); + unlink(TEST_SPOOL); + + printf("ok: filename is an actual parameter\n"); + return 0; +} + +static int +test_filename_parameter_name_must_match(void) +{ + const char *body = + "--test-boundary\r\n" + "Content-Disposition: form-data; " + "name=\"file\"; filenameX=\"wrong.txt\"\r\n" + "\r\n" + "hello\r\n" + "--test-boundary--\r\n"; + struct multipart_request req; + struct multipart_part part; + int fd; + + fd = create_spool(body, &req); + if (fd == -1) + return 1; + + if (multipart_extract_part(fd, &req, &part) != + MULTIPART_MISSING_FILENAME) { + fprintf(stderr, + "FAIL: filename parameter name: " + "accepted non-filename parameter\n"); + close(fd); + unlink(TEST_SPOOL); + return 1; + } + + close(fd); + unlink(TEST_SPOOL); + + printf("ok: reject filename-like parameter name\n"); + return 0; +} + +static int +test_duplicate_filename(void) +{ + const char *body = + "--test-boundary\r\n" + "Content-Disposition: form-data; " + "name=\"file\"; filename=\"one.txt\"; " + "filename=\"two.txt\"\r\n" + "\r\n" + "hello\r\n" + "--test-boundary--\r\n"; + struct multipart_request req; + struct multipart_part part; + int fd; + + fd = create_spool(body, &req); + if (fd == -1) + return 1; + + if (multipart_extract_part(fd, &req, &part) != + MULTIPART_MALFORMED) { + fprintf(stderr, + "FAIL: duplicate filename: accepted body\n"); + close(fd); + unlink(TEST_SPOOL); + return 1; + } + + close(fd); + unlink(TEST_SPOOL); + + printf("ok: reject duplicate filename parameter\n"); + return 0; +} + +static int +test_filename_parameter_whitespace(void) +{ + const char *body = + "--test-boundary\r\n" + "Content-Disposition: form-data; " + "name=\"file\"; filename = \"hello.txt\"\r\n" + "\r\n" + "hello\r\n" + "--test-boundary--\r\n"; + struct multipart_request req; + struct multipart_part part; + int fd; + + fd = create_spool(body, &req); + if (fd == -1) + return 1; + + if (multipart_extract_part(fd, &req, &part) != MULTIPART_OK) { + fprintf(stderr, + "FAIL: filename whitespace: extraction failed\n"); + close(fd); + unlink(TEST_SPOOL); + return 1; + } + + if (strcmp(part.filename, "hello.txt") != 0) { + fprintf(stderr, + "FAIL: filename whitespace: filename was '%s'\n", + part.filename); + close(fd); + unlink(TEST_SPOOL); + return 1; + } + + close(fd); + unlink(TEST_SPOOL); + + printf("ok: accept whitespace around filename '='\n"); + return 0; +} + +static int +test_reject_extra_non_file_part(void) +{ + const char *body = + "--test-boundary\r\n" + "Content-Disposition: form-data; name=\"file\"; " + "filename=\"hello.txt\"\r\n" + "\r\n" + "hello\r\n" + "--test-boundary\r\n" + "Content-Disposition: form-data; name=\"extra\"\r\n" + "\r\n" + "unexpected\r\n" + "--test-boundary--\r\n"; + struct multipart_request req; + struct multipart_part part; + int fd; + + fd = create_spool(body, &req); + if (fd == -1) + return 1; + + if (multipart_extract_part(fd, &req, &part) != + MULTIPART_MALFORMED) { + fprintf(stderr, + "FAIL: extra non-file part: " + "accepted unexpected part\n"); + close(fd); + unlink(TEST_SPOOL); + return 1; + } + + close(fd); + unlink(TEST_SPOOL); + + printf("ok: reject extra non-file part\n"); + return 0; +} + +static int +test_reject_wrong_field_name(void) +{ + const char *body = + "--test-boundary\r\n" + "Content-Disposition: form-data; name=\"upload\"; " + "filename=\"hello.txt\"\r\n" + "\r\n" + "hello\r\n" + "--test-boundary--\r\n"; + struct multipart_request req; + struct multipart_part part; + int fd; + + fd = create_spool(body, &req); + if (fd == -1) + return 1; + + if (multipart_extract_part(fd, &req, &part) != + MULTIPART_MALFORMED) { + fprintf(stderr, + "FAIL: wrong field name: accepted unexpected field\n"); + close(fd); + unlink(TEST_SPOOL); + return 1; + } + + close(fd); + unlink(TEST_SPOOL); + + printf("ok: reject wrong field name\n"); + return 0; +} + +static int +test_reject_missing_field_name(void) +{ + const char *body = + "--test-boundary\r\n" + "Content-Disposition: form-data; " + "filename=\"hello.txt\"\r\n" + "\r\n" + "hello\r\n" + "--test-boundary--\r\n"; + struct multipart_request req; + struct multipart_part part; + int fd; + + fd = create_spool(body, &req); + if (fd == -1) + return 1; + + if (multipart_extract_part(fd, &req, &part) != + MULTIPART_MALFORMED) { + fprintf(stderr, + "FAIL: missing field name: accepted malformed part\n"); + close(fd); + unlink(TEST_SPOOL); + return 1; + } + + close(fd); + unlink(TEST_SPOOL); + + printf("ok: reject missing field name\n"); + return 0; +} + +static int +test_reject_non_file_part_before_file(void) +{ + const char *body = + "--test-boundary\r\n" + "Content-Disposition: form-data; name=\"extra\"\r\n" + "\r\n" + "unexpected\r\n" + "--test-boundary\r\n" + "Content-Disposition: form-data; name=\"file\"; " + "filename=\"hello.txt\"\r\n" + "\r\n" + "hello\r\n" + "--test-boundary--\r\n"; + struct multipart_request req; + struct multipart_part part; + int fd; + + fd = create_spool(body, &req); + if (fd == -1) + return 1; + + if (multipart_extract_part(fd, &req, &part) != + MULTIPART_MALFORMED) { + fprintf(stderr, + "FAIL: non-file part before file: " + "accepted unexpected structure\n"); + close(fd); + unlink(TEST_SPOOL); + return 1; + } + + close(fd); + unlink(TEST_SPOOL); + + printf("ok: reject non-file part before file\n"); + return 0; +} + +static int +test_reject_negative_part_offset(void) +{ + struct multipart_part part; + int fd; + int outfd; + + fd = open(TEST_SPOOL, O_RDWR | O_CREAT | O_TRUNC, 0600); + if (fd == -1) + return 1; + + outfd = open(TEST_OUTPUT, O_RDWR | O_CREAT | O_TRUNC, 0600); + if (outfd == -1) { + close(fd); + unlink(TEST_SPOOL); + return 1; + } + + memset(&part, 0, sizeof(part)); + + part.offset = -1; + part.length = 1; + + if (multipart_copy_part(fd, &part, outfd) != MULTIPART_ERROR) { + fprintf(stderr, + "FAIL: negative part offset: accepted invalid offset\n"); + close(outfd); + close(fd); + unlink(TEST_SPOOL); + unlink(TEST_OUTPUT); + return 1; + } + + close(outfd); + close(fd); + unlink(TEST_SPOOL); + unlink(TEST_OUTPUT); + + printf("ok: reject negative part offset\n"); + return 0; +} + +static int +test_reject_negative_part_length(void) +{ + struct multipart_part part; + int fd; + int outfd; + + fd = open(TEST_SPOOL, O_RDWR | O_CREAT | O_TRUNC, 0600); + if (fd == -1) + return 1; + + outfd = open(TEST_OUTPUT, O_RDWR | O_CREAT | O_TRUNC, 0600); + if (outfd == -1) { + close(fd); + unlink(TEST_SPOOL); + return 1; + } + + memset(&part, 0, sizeof(part)); + + part.offset = 0; + part.length = -1; + + if (multipart_copy_part(fd, &part, outfd) != MULTIPART_ERROR) { + fprintf(stderr, + "FAIL: negative part length: accepted invalid length\n"); + close(outfd); + close(fd); + unlink(TEST_SPOOL); + unlink(TEST_OUTPUT); + return 1; + } + + close(outfd); + close(fd); + unlink(TEST_SPOOL); + unlink(TEST_OUTPUT); + + printf("ok: reject negative part length\n"); + return 0; +} + +static int +test_reject_part_beyond_spool(void) +{ + const char *data = "hello"; + struct multipart_part part; + int fd; + int outfd; + + fd = open(TEST_SPOOL, O_RDWR | O_CREAT | O_TRUNC, 0600); + if (fd == -1) + return 1; + + if (write_file(fd, data) == -1) { + close(fd); + unlink(TEST_SPOOL); + return 1; + } + + outfd = open(TEST_OUTPUT, O_RDWR | O_CREAT | O_TRUNC, 0600); + if (outfd == -1) { + close(fd); + unlink(TEST_SPOOL); + return 1; + } + + memset(&part, 0, sizeof(part)); + + part.offset = 3; + part.length = 3; + + if (multipart_copy_part(fd, &part, outfd) != MULTIPART_ERROR) { + fprintf(stderr, + "FAIL: part beyond spool: accepted invalid range\n"); + close(outfd); + close(fd); + unlink(TEST_SPOOL); + unlink(TEST_OUTPUT); + return 1; + } + + close(outfd); + close(fd); + unlink(TEST_SPOOL); + unlink(TEST_OUTPUT); + + printf("ok: reject part beyond spool\n"); + return 0; +} + +static int +test_reject_part_range_overflow(void) +{ + struct multipart_part part; + int fd; + int outfd; + + fd = open(TEST_SPOOL, O_RDWR | O_CREAT | O_TRUNC, 0600); + if (fd == -1) + return 1; + + outfd = open(TEST_OUTPUT, O_RDWR | O_CREAT | O_TRUNC, 0600); + if (outfd == -1) { + close(fd); + unlink(TEST_SPOOL); + return 1; + } + + memset(&part, 0, sizeof(part)); + + part.offset = (off_t) -1; + part.length = 2; + + if (multipart_copy_part(fd, &part, outfd) != MULTIPART_ERROR) { + fprintf(stderr, + "FAIL: part range overflow: accepted overflowing range\n"); + close(outfd); + close(fd); + unlink(TEST_SPOOL); + unlink(TEST_OUTPUT); + return 1; + } + + close(outfd); + close(fd); + unlink(TEST_SPOOL); + unlink(TEST_OUTPUT); + + printf("ok: reject overflowing part range\n"); + return 0; +} + +static int +test_reject_request_length_beyond_spool(void) +{ + const char *body = + "--test-boundary\r\n" + "Content-Disposition: form-data; name=\"file\"; " + "filename=\"hello.txt\"\r\n" + "\r\n" + "hello\r\n" + "--test-boundary--\r\n"; + struct multipart_request req; + struct multipart_part part; + int fd; + + fd = create_spool(body, &req); + if (fd == -1) + return 1; + + /* + * Claim that CONTENT_LENGTH extends beyond the actual spool. + */ + req.length += 100; + + if (multipart_extract_part(fd, &req, &part) != + MULTIPART_MALFORMED) { + fprintf(stderr, + "FAIL: request length beyond spool: " + "accepted invalid request length\n"); + close(fd); + unlink(TEST_SPOOL); + return 1; + } + + close(fd); + unlink(TEST_SPOOL); + + printf("ok: reject request length beyond spool\n"); + return 0; +} + +static int +test_content_length_valid(void) +{ + struct multipart_request req; + int ret; + + memset(&req, 0, sizeof(req)); + + if (setenv("CONTENT_TYPE", + "multipart/form-data; boundary=test-boundary", 1) == -1 || + setenv("CONTENT_LENGTH", "100", 1) == -1) { + fprintf(stderr, + "FAIL: valid Content-Length: could not set environment\n"); + return 1; + } + + ret = multipart_parse_request(&req); + + if (ret != MULTIPART_OK) { + fprintf(stderr, + "FAIL: valid Content-Length: returned %d\n", ret); + return 1; + } + + if (req.length != 100) { + fprintf(stderr, + "FAIL: valid Content-Length: parsed length %lld, " + "expected 100\n", + (long long) req.length); + return 1; + } + + printf("ok: accept valid Content-Length\n"); + return 0; +} + +static int +test_content_length_empty(void) +{ + struct multipart_request req; + int ret; + + memset(&req, 0, sizeof(req)); + + if (setenv("CONTENT_TYPE", + "multipart/form-data; boundary=test-boundary", 1) == -1 || + setenv("CONTENT_LENGTH", "", 1) == -1) { + fprintf(stderr, + "FAIL: empty Content-Length: could not set environment\n"); + return 1; + } + + ret = multipart_parse_request(&req); + + if (ret != MULTIPART_INVALID_LENGTH) { + fprintf(stderr, + "FAIL: empty Content-Length: returned %d, " + "expected %d\n", + ret, MULTIPART_INVALID_LENGTH); + return 1; + } + + printf("ok: reject empty Content-Length\n"); + return 0; +} + +static int +test_content_length_negative(void) +{ + struct multipart_request req; + int ret; + + memset(&req, 0, sizeof(req)); + + if (setenv("CONTENT_TYPE", + "multipart/form-data; boundary=test-boundary", 1) == -1 || + setenv("CONTENT_LENGTH", "-1", 1) == -1) { + fprintf(stderr, + "FAIL: negative Content-Length: could not set environment\n"); + return 1; + } + + ret = multipart_parse_request(&req); + + if (ret != MULTIPART_INVALID_LENGTH) { + fprintf(stderr, + "FAIL: negative Content-Length: returned %d, " + "expected %d\n", + ret, MULTIPART_INVALID_LENGTH); + return 1; + } + + printf("ok: reject negative Content-Length\n"); + return 0; +} + +static int +test_content_length_plus_sign(void) +{ + struct multipart_request req; + int ret; + + memset(&req, 0, sizeof(req)); + + if (setenv("CONTENT_TYPE", + "multipart/form-data; boundary=test-boundary", 1) == -1 || + setenv("CONTENT_LENGTH", "+100", 1) == -1) { + fprintf(stderr, + "FAIL: signed Content-Length: could not set environment\n"); + return 1; + } + + ret = multipart_parse_request(&req); + + if (ret != MULTIPART_INVALID_LENGTH) { + fprintf(stderr, + "FAIL: signed Content-Length: returned %d, " + "expected %d\n", + ret, MULTIPART_INVALID_LENGTH); + return 1; + } + + printf("ok: reject plus-signed Content-Length\n"); + return 0; +} + +static int +test_content_length_leading_whitespace(void) +{ + struct multipart_request req; + int ret; + + memset(&req, 0, sizeof(req)); + + if (setenv("CONTENT_TYPE", + "multipart/form-data; boundary=test-boundary", 1) == -1 || + setenv("CONTENT_LENGTH", " 100", 1) == -1) { + fprintf(stderr, + "FAIL: whitespace Content-Length: " + "could not set environment\n"); + return 1; + } + + ret = multipart_parse_request(&req); + + if (ret != MULTIPART_INVALID_LENGTH) { + fprintf(stderr, + "FAIL: leading whitespace Content-Length: returned %d, " + "expected %d\n", + ret, MULTIPART_INVALID_LENGTH); + return 1; + } + + printf("ok: reject leading whitespace in Content-Length\n"); + return 0; +} + +static int +test_content_length_trailing_whitespace(void) +{ + struct multipart_request req; + int ret; + + memset(&req, 0, sizeof(req)); + + if (setenv("CONTENT_TYPE", + "multipart/form-data; boundary=test-boundary", 1) == -1 || + setenv("CONTENT_LENGTH", "100 ", 1) == -1) { + fprintf(stderr, + "FAIL: whitespace Content-Length: " + "could not set environment\n"); + return 1; + } + + ret = multipart_parse_request(&req); + + if (ret != MULTIPART_INVALID_LENGTH) { + fprintf(stderr, + "FAIL: trailing whitespace Content-Length: returned %d, " + "expected %d\n", + ret, MULTIPART_INVALID_LENGTH); + return 1; + } + + printf("ok: reject trailing whitespace in Content-Length\n"); + return 0; +} + +static int +test_content_length_non_digit(void) +{ + struct multipart_request req; + int ret; + + memset(&req, 0, sizeof(req)); + + if (setenv("CONTENT_TYPE", + "multipart/form-data; boundary=test-boundary", 1) == -1 || + setenv("CONTENT_LENGTH", "100abc", 1) == -1) { + fprintf(stderr, + "FAIL: non-digit Content-Length: " + "could not set environment\n"); + return 1; + } + + ret = multipart_parse_request(&req); + + if (ret != MULTIPART_INVALID_LENGTH) { + fprintf(stderr, + "FAIL: non-digit Content-Length: returned %d, " + "expected %d\n", + ret, MULTIPART_INVALID_LENGTH); + return 1; + } + + printf("ok: reject non-digit Content-Length\n"); + return 0; +} + +static int +test_content_length_decimal(void) +{ + struct multipart_request req; + int ret; + + memset(&req, 0, sizeof(req)); + + if (setenv("CONTENT_TYPE", + "multipart/form-data; boundary=test-boundary", 1) == -1 || + setenv("CONTENT_LENGTH", "10.0", 1) == -1) { + fprintf(stderr, + "FAIL: decimal Content-Length: " + "could not set environment\n"); + return 1; + } + + ret = multipart_parse_request(&req); + + if (ret != MULTIPART_INVALID_LENGTH) { + fprintf(stderr, + "FAIL: decimal Content-Length: returned %d, " + "expected %d\n", + ret, MULTIPART_INVALID_LENGTH); + return 1; + } + + printf("ok: reject decimal Content-Length\n"); + return 0; +} + +static int +test_content_length_overflow(void) +{ + struct multipart_request req; + int ret; + + memset(&req, 0, sizeof(req)); + + if (setenv("CONTENT_TYPE", + "multipart/form-data; boundary=test-boundary", 1) == -1 || + setenv("CONTENT_LENGTH", + "184467440737095516160", 1) == -1) { + fprintf(stderr, + "FAIL: overflowing Content-Length: " + "could not set environment\n"); + return 1; + } + + ret = multipart_parse_request(&req); + + if (ret != MULTIPART_INVALID_LENGTH) { + fprintf(stderr, + "FAIL: overflowing Content-Length: returned %d, " + "expected %d\n", + ret, MULTIPART_INVALID_LENGTH); + return 1; + } + + printf("ok: reject overflowing Content-Length\n"); + return 0; +} + +int +main(void) +{ + int failures; + + failures = 0; + + failures += test_extract_part(); + failures += test_filename_not_found_in_payload(); + failures += test_reject_multiple_file_parts(); + failures += test_multipart_copy_empty_part(); + failures += test_missing_opening_boundary(); + failures += test_wrong_opening_boundary(); + failures += test_missing_header_terminator(); + failures += test_missing_closing_boundary(); + failures += test_truncated_closing_boundary(); + failures += test_invalid_boundary_suffix(); + failures += test_missing_content_disposition(); + failures += test_wrong_content_disposition(); + failures += test_unquoted_filename(); + failures += test_empty_filename(); + failures += test_filename_is_actual_parameter(); + failures += test_filename_parameter_name_must_match(); + failures += test_duplicate_filename(); + failures += test_filename_parameter_whitespace(); + failures += test_boundary_like_payload(); + failures += test_content_type_normal(); + failures += test_content_type_case_insensitive(); + failures += test_content_type_boundary_case_insensitive(); + failures += test_content_type_quoted_boundary(); + failures += test_content_type_boundary_whitespace(); + failures += test_content_type_parameter_before_boundary(); + failures += test_content_type_missing_boundary(); + failures += test_content_type_invalid_boundary_character(); + failures += test_content_type_boundary_too_long(); + failures += test_content_type_malformed_boundary_parameter(); + failures += test_content_type_wrong_media_type(); + failures += test_content_type_duplicate_boundary(); + failures += test_reject_extra_non_file_part(); + failures += test_reject_wrong_field_name(); + failures += test_reject_missing_field_name(); + failures += test_reject_non_file_part_before_file(); + failures += test_reject_negative_part_offset(); + failures += test_reject_negative_part_length(); + failures += test_reject_part_beyond_spool(); + failures += test_reject_part_range_overflow(); + failures += test_reject_request_length_beyond_spool(); + failures += test_content_length_valid(); + failures += test_content_length_empty(); + failures += test_content_length_negative(); + failures += test_content_length_plus_sign(); + failures += test_content_length_leading_whitespace(); + failures += test_content_length_trailing_whitespace(); + failures += test_content_length_non_digit(); + failures += test_content_length_decimal(); + failures += test_content_length_overflow(); + + if (failures != 0) { + fprintf(stderr, + "%d test(s) failed\n", + failures); + return EXIT_FAILURE; + } + printf("all tests passed\n"); + + return EXIT_SUCCESS; +} diff --git a/tests/unit/url.c b/tests/unit/url.c @@ -0,0 +1,212 @@ +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +#include "url.h" + +struct encode_test_case { + const char *name; + const char *input; + const char *expected; +}; + +struct decode_test_case { + const char *name; + const char *input; + const char *expected; + int expected_result; +}; + +static const struct encode_test_case encode_tests[] = { + /* Unreserved characters should not be encoded. */ + { "simple filename", "file.txt", "file.txt" }, + { "alphanumeric", "file123", "file123" }, + { "hyphen", "my-file.txt", "my-file.txt" }, + { "underscore", "my_file.txt", "my_file.txt" }, + { "dot", "file.txt", "file.txt" }, + { "tilde", "~file.txt", "~file.txt" }, + + /* Characters requiring encoding. */ + { "space", "my file.txt", "my%20file.txt" }, + { "ampersand", "&file.txt", "%26file.txt" }, + { "plus", "a+b.txt", "a%2Bb.txt" }, + { "percent", "100%.txt", "100%25.txt" }, + { "question mark", "file?.txt", "file%3F.txt" }, + { "equals", "file=name.txt", "file%3Dname.txt" }, + { "slash", "foo/bar.txt", "foo%2Fbar.txt" }, + { "backslash", "foo\\bar.txt", "foo%5Cbar.txt" }, + { "hash", "file#1.txt", "file%231.txt" }, + { "quote", "file\".txt", "file%22.txt" }, + + /* Empty string. */ + { "empty", "", "" }, +}; + +static const struct decode_test_case decode_tests[] = { + /* Plain strings. */ + { "simple filename", "file.txt", "file.txt", 0 }, + { "alphanumeric", "file123", "file123", 0 }, + { "hyphen", "my-file.txt", "my-file.txt", 0 }, + { "underscore", "my_file.txt", "my_file.txt", 0 }, + { "dot", "file.txt", "file.txt", 0 }, + { "tilde", "~file.txt", "~file.txt", 0 }, + + /* Encoded characters. */ + { "space", "my%20file.txt", "my file.txt", 0 }, + { "plus", "my+file.txt", "my file.txt", 0 }, + { "ampersand", "%26file.txt", "&file.txt", 0 }, + { "percent", "100%25.txt", "100%.txt", 0 }, + { "question mark", "file%3F.txt", "file?.txt", 0 }, + { "equals", "file%3Dname.txt", "file=name.txt", 0 }, + { "slash", "foo%2Fbar.txt", "foo/bar.txt", 0 }, + { "backslash", "foo%5Cbar.txt", "foo\\bar.txt", 0 }, + { "hash", "file%23.txt", "file#.txt", 0 }, + { "quote", "file%22.txt", "file\".txt", 0 }, + + /* Hexadecimal case should both work. */ + { "lowercase hex", "%2f", "/", 0 }, + { "uppercase hex", "%2F", "/", 0 }, + { "mixed hex", "%2f%2F", "//", 0 }, + + /* Encoded traversal must decode successfully; + * valid_filename() is responsible for rejecting it. */ + { "encoded dot", "%2e", ".", 0 }, + { "encoded dot dot", "%2e%2e", "..", 0 }, + { "encoded traversal", "%2e%2e%2ffile.txt", + "../file.txt", 0 }, + + /* Empty string. */ + { "empty", "", "", 0 }, + + /* Invalid percent encodings. */ + { "trailing percent", "%", NULL, -1 }, + { "one hex digit", "%2", NULL, -1 }, + { "invalid hex", "%ZZ", NULL, -1 }, + { "invalid high digit", "%G0", NULL, -1 }, + { "invalid low digit", "%0G", NULL, -1 }, + { "incomplete encoding", "file%2", NULL, -1 }, + + /* Encoded NUL must be rejected. */ + { "encoded NUL", "file%00.txt", NULL, -1 }, +}; + +static int +test_encode(void) +{ + size_t i; + int failures = 0; + + for (i = 0; + i < sizeof(encode_tests) / sizeof(encode_tests[0]); + i++) { + char *got; + + got = url_encode(encode_tests[i].input); + + if (got == NULL) { + fprintf(stderr, + "FAIL: %s: url_encode() returned NULL\n", + encode_tests[i].name); + failures++; + continue; + } + + if (strcmp(got, encode_tests[i].expected) != 0) { + fprintf(stderr, + "FAIL: %s: url_encode(%s) returned %s, " + "expected %s\n", + encode_tests[i].name, + encode_tests[i].input, + got, + encode_tests[i].expected); + failures++; + } else { + printf("ok: encode %s\n", + encode_tests[i].name); + } + + free(got); + } + + return failures; +} + +static int +test_decode(void) +{ + size_t i; + int failures = 0; + + for (i = 0; + i < sizeof(decode_tests) / sizeof(decode_tests[0]); + i++) { + char *input; + int got; + + input = malloc(strlen(decode_tests[i].input) + 1); + if (input == NULL) { + fprintf(stderr, + "FAIL: %s: malloc failed\n", + decode_tests[i].name); + failures++; + continue; + } + + strcpy(input, decode_tests[i].input); + + got = url_decode(input); + + if (got != decode_tests[i].expected_result) { + fprintf(stderr, + "FAIL: %s: url_decode() returned %d, " + "expected %d\n", + decode_tests[i].name, + got, + decode_tests[i].expected_result); + failures++; + free(input); + continue; + } + + if (got == 0 && + strcmp(input, decode_tests[i].expected) != 0) { + fprintf(stderr, + "FAIL: %s: url_decode(%s) returned %s, " + "expected %s\n", + decode_tests[i].name, + decode_tests[i].input, + input, + decode_tests[i].expected); + failures++; + } else { + printf("ok: decode %s\n", + decode_tests[i].name); + } + + free(input); + } + + return failures; +} + +int +main(void) +{ + int failures; + + failures = 0; + + failures += test_encode(); + failures += test_decode(); + + if (failures != 0) { + fprintf(stderr, + "%d test(s) failed\n", + failures); + return EXIT_FAILURE; + } + + printf("all tests passed\n"); + return EXIT_SUCCESS; +} + diff --git a/tests/unit/valid_filename.c b/tests/unit/valid_filename.c @@ -0,0 +1,75 @@ +#include <stdio.h> +#include <stdlib.h> + +#include "common.h" + +struct test_case { +const char *name; +const char *filename; +int expected; +}; + +static const struct test_case tests[] = { +/* Valid filenames. */ +{ "simple filename", "file.txt", 1 }, +{ "alphanumeric", "file123", 1 }, +{ "hyphen", "my-file.txt", 1 }, +{ "underscore", "my_file.txt", 1 }, +{ "multiple dots", "archive.tar.gz", 1 }, +{ "space", "my file.txt", 1 }, +{ "ampersand", "&file.txt", 1 }, + +/* Invalid filenames. */ +{ "NULL", NULL, 0 }, +{ "empty", "", 0 }, +{ "dot", ".", 0 }, +{ "dot dot", "..", 0 }, +{ "slash", "file/name.txt", 0 }, +{ "leading slash", "/file.txt", 0 }, +{ "trailing slash", "file.txt/", 0 }, +{ "parent traversal", "../file.txt", 0 }, +{ "embedded traversal", "foo/../file.txt", 0 }, +{ "backslash", "foo\\bar.txt", 0 }, +{ "double quote", "foo\"bar.txt", 0 }, +{ "quote with header syntax", "foo\"; x=\"bar", 0 }, +{ "carriage return", "foo\rbar.txt", 0 }, +{ "line feed", "foo\nbar.txt", 0 }, +{ "tab", "foo\tbar.txt", 0 }, +{ "control character", "foo\x01" "bar.txt", 0 }, +{ "DEL character", "foo\x7f" "bar.txt", 0 }, +}; + +int +main(void) +{ +size_t i; +int failures = 0; +int got; + +for (i = 0; i < sizeof(tests) / sizeof(tests[0]); i++) { + got = valid_filename(tests[i].filename); + + if (got != tests[i].expected) { + fprintf(stderr, + "FAIL: %s: valid_filename(%s) returned %d, " + "expected %d\n", + tests[i].name, + tests[i].filename != NULL ? + tests[i].filename : "NULL", + got, + tests[i].expected); + failures++; + } else { + printf("ok: %s\n", tests[i].name); + } +} + +if (failures != 0) { + fprintf(stderr, "%d test(s) failed\n", failures); + return EXIT_FAILURE; +} + +printf("all tests passed\n"); +return EXIT_SUCCESS; + +}