Skip to content

About

This repo holds a simple caddyserver v2 upload handler

Resources

Stars

32 stars

Watchers

1 watching

Forks

Repository files navigation

caddyv2-upload

This repo holds a simple caddyserver v2 upload handler

Warning
You have to secure the upload URL as the nature of this handler/plugin is to save uploaded artifacts on the disc.

In this handler are the following modules in use.

  • templates

  • file_server

  • upload :-)

Configuration

Important
The relative paths are relative to the current web root directory http.vars.root!

Environment Variables

Table 1. Parameters
Name Description

APPPORT

The Port on which Caddyserver should listen. The format depends which Configuration file format you use. See the official Caddyserver documentation for the format Network addresses

SKIP_LOG

With this variable can you control if the health check will be logged or not. This feature was implemented as part of the vars handler.

Parameters

Table 2. Parameters
Name Description

dest_dir

The directory in which the files will be uploaded

root_dir

The root directory for this module. When not set will the root directory be evaluated at runtime from this variable http.vars.root.

file_field_name

The field name for the multi-part form upload

fixed_file_name

When set, all uploaded files will be renamed to the provided file name.

response_template

The response template after a upload was successfully

max_form_buffer

The maximum buffer size for ParseMultipartForm. It accepts all size values supported by go-humanize. Here can also be used a Placeholder
JSON: "{env.MAXBUFFSIZE}"
Caddyfile: {$MAXBUFFSIZE}

The default size is 1G.

max_form_buffer_int

The maximum buffer size for ParseMultipartForm.
The default size is 1G.

max_filesize

is the maximum size in bytes allowed for the upload. It accepts all size values supported by go-humanize. Reads of more bytes will return an error with HTTP status 413. Here can also be used a Placeholder
JSON: "{env.MAXFILESIZE}"
Caddyfile: {$MAXFILESIZE}

The default size is 1G.

max_filesize_int

is the maximum size in bytes allowed for the upload. Reads of more bytes will return an error with HTTP status 413.
The default size is 1G.

notify_url

After a successful upload will this URL be called. The only supported schema is https.

notify_method

The default method is GET. If you need to make a POST request please open a feature issue as for now is a request body handling not implemented

insecure

This boolean flag configure the InsecureSkipVerify in the tls-config . Default is false which implies that a valid CA must be used

capath

This is the Parameter where you can define the CA filename for the notify_url.

create_uuid_dir

If set to true, each file will get a unique directory with a UUID as its name. The value is optional and defaults to false; create_uuid_dir alone is the same as create_uuid_dir true.

dest_dir_field_name

If set, the dest_dir will be set from the specified form value.

pass_thru

If set to true, enables pass-thru mode, which continues to the next HTTP handler in the route instead of responding directly. The value is optional and defaults to false.

allowed_extensions

List of allowed file extensions, e.g. .jpg .png .pdf. The check is case-insensitive and the leading dot is optional. Only the last extension of the file name sent by the client is checked (report.tar.gz → .gz), also when fixed_file_name is set. Uploads with other extensions are rejected with HTTP status 415.
Note: the extension is chosen by the client and is easy to fake. Use allowed_mime_types to check the real content.

allowed_mime_types

List of allowed MIME types, e.g. image/* application/pdf. A wildcard subtype like image/ is supported. The type is detected from the file content with mimetype, the Content-Type sent by the client is ignored. Uploads with other types are rejected with HTTP status 415.
Parent types are matched too, see the supported MIME types and their hierarchy. For example application/zip also allows docx, xlsx and jar files, and text/plain also allows HTML, CSV and JSON files.
*Warning:
allowing text/plain also allows HTML. If the upload directory is served by a web server this can lead to XSS.

on_abort

What to do when an event handler aborts the upload_finished event, see Events. ignore (default) keeps the file and responds as usual. abort deletes the file (with create_uuid_dir the whole UUID directory) and responds with HTTP status 422.

Caddy Variables

The following Variables will be added to the request flow

Table 3. variables
Name Description

http.upload.filename

The uploaded filename

http.upload.filesize

The uploaded filesize

http.upload.directory

The directory where the file is uploaded

http.upload.uuiddir

The name of the random directory created if create_uuid_dir is enabled

JSON

Because I prefer the JSON Config will I write here the configuration snipplet in JSON Syntax.

	"handle": [
		{
			"MyTlsSetting": {},
			"dest_dir": "upload", (1)
			"handler": "upload",
			"max_filesize": "5GB", (2)
			"max_form_buffer_int": 1000000, (3)
			"response_template": "upload-resp.txt" (4)
		}
	]
  1. Destination Directory on the Server site

  2. Maximal possible upload size

  3. Maximal buffer for uploading

  4. the response template which will be used for response after upload

A full working example is in docker-files/opt/webroot/config/Caddyfile-upload.json

Caddyfile

Here a example Caddyfile which expects that the environment variable APPPORT is set.

{
	order upload before file_server
	log {
		level DEBUG
	}
}

{$APPPORT} {
	root .

	file_server browse
	templates

	@mypost method POST
	upload @mypost {
		dest_dir tmp-upl
		max_form_buffer 1G
		max_filesize 4MB
		response_template templates/upload-resp-template.txt
	}

	log {
		output file access.log
	}
}

Events

After a file was written successfully the upload handler emits the Caddy event upload_finished via the built-in events app. Subscribed event handlers can use the following placeholders.

Table 4. event data
Name Description

{event.data.filename}

The file name (sent by the client, or fixed_file_name)

{event.data.filesize}

The file size in bytes

{event.data.directory}

The directory where the file is stored

{event.data.filepath}

The path of the stored file (directory + filename). It is relative when root/root_dir is relative.

{event.data.uuiddir}

The name of the UUID directory, empty without create_uuid_dir

{event.data.requuid}

The http.request.uuid of the upload request

The container image contains the events.handlers.exec module (experimental) to run a program for an event. docker-files/opt/webroot/config/Caddyfile-events is a working demo which runs docker-files/opt/webroot/scripts/on-upload.sh after every upload.

{
	order upload before file_server
	events {
		on upload_finished exec /opt/webroot/scripts/on-upload.sh {event.data.filepath} {event.data.filesize} {event.data.filename} {event.data.directory} {event.data.uuiddir} {event.data.requuid} {event.id} {event.name} {event.time} {event.time_unix} {event.module}
	}
}

By default exec runs the program in the background, the HTTP response does not wait for it and its exit code is ignored by the upload handler. Caddy logs background command failed when the program exits with a non-zero code or is killed after the timeout (default 30s), a successful run is not logged. The output of the program goes to the stdout/stderr of Caddy.

Note
Programs running in the background survive config reloads, only the exec timeout ends them. Caddy does not wait for them when it exits, so a stop or restart of Caddy (or of its container) can end them in the middle of their work.

Requirements for the called program

The program runs once per upload, in parallel and without any limit on the number of running instances. It must be robust and short-lived:

  • Finish fast. Best is a small "write to queue" program, which only hands over the file (e.g. moves it into a spool directory or pushes a job to a message queue) and leaves long running tasks (scan, conversion, transfer to other systems, …​) to a service outside of Caddy.

  • Limit the runtime yourself. When the exec timeout (default 30s) hits, only the program itself is killed, not the processes it started. A shell script killed this way leaves its running commands behind, they keep running as orphans. Wrap slow commands with timeout <seconds> below the exec timeout (as the demo script does), and use exec <command> for the last command of a shell script.

  • Don’t start background processes (&, nohup, …​), they escape the exec timeout as well.

  • Handle errors. A failing background program is only logged by Caddy (background command failed), nobody retries it. In the foreground, decide whether an error rejects the upload (fail closed, like the demo script) or not.

  • In a container, run Caddy with an init process (e.g. podman run --init, docker run --init or tini), so that orphaned processes are reaped.

Reject an upload from an event handler

To reject an upload, e.g. after a virus scan, the program must run in the foreground with abort_codes, and the upload handler needs on_abort abort. The Caddyfile syntax of exec only supports the command and its arguments, so this needs the JSON config:

"events": {
	"subscriptions": [
		{
			"events": ["upload_finished"],
			"handlers": [
				{
					"handler": "exec",
					"command": "/opt/webroot/scripts/on-upload.sh",
					"args": ["{event.data.filepath}", "{event.data.filesize}", "{event.data.filename}", "{event.data.directory}", "{event.data.uuiddir}", "{event.data.requuid}", "{event.id}", "{event.name}", "{event.time}", "{event.time_unix}", "{event.module}"],
					"foreground": true,
					"abort_codes": [3]
				}
			]
		}
	]
}

The demo script exits with code 3 when the file contains the EICAR test string. The upload then gets HTTP status 422 and the file is deleted.

In the foreground the HTTP response waits for the program. During this time no data is sent to the client, so keep the runtime well below the smallest timeout between client and Caddy. Caddy itself has no write timeout by default, but reverse proxies and load balancers in front of it often have one, e.g. OpenShift routes 30s, nginx proxy_read_timeout 60s, Cloudflare 100s. If such a timeout hits, the client gets an error (e.g. 504) while Caddy continues: the file may be stored anyway, or a 422 never reaches the client.

Warning
When exec kills the program after its timeout (default 30s), the exit code is not one of the abort_codes and the event is not aborted: the upload is accepted. To fail closed, limit the runtime inside the program below the exec timeout and exit with an abort code when the limit is reached. The demo script does this with timeout and the environment variable SCAN_TIMEOUT (default 20s). SCAN_DELAY simulates a slow scan for testing.

Security notes:

  • The file name is chosen by the client. Pass it only as a separate argument and never into a shell like sh -c "…​ {event.data.filename}", this would allow command injection.

  • Prefer {event.data.filepath}: it starts with the upload directory and can’t be mistaken for an option of the program, unlike a file name such as -rf.

build

local

xcaddy build --with github.com/kirsch33/realip \
  --with github.com/git001/caddyv2-upload

docker

buildah bud --tag caddyv2-upload .
# or
docker build --tag caddyv2-upload .

run

cli

APPPORT=:2011 ./caddy run \
  -config Caddyfile-upload.json

docker

You can get this image from docker hub

The default listen port must be defined with this variable

APPPORT=:2011

podman run --rm --network host --name caddy-test \
  --env APPPORT=:8888 -it \
  docker.io/me2digital/caddyv2-upload:latest
# or
docker run --name caddy-test --rm \
  docker.io/me2digital/caddyv2-upload:latest

OpenShift / kubernetes

You can use the examples in the directory openshift to deploy the caddy uploader into your OpenShift or kubernetes Cluster.

oc new-project caddyupload
oc -n caddyupload apply -f openshift/

To adopt the used Caddyfile then can you easily do this with a configmap. The commands below show the steps.

# add configure change trigger
oc -n caddyupload \
  set triggers \
  deployment.apps/caddy-upload \
  --from-config

# create configmap
oc -n caddyupload \
  create configmap \
  caddyfile-json \
  --from-file=Caddyfile-upload.json=<PATH_TO_LOCAL>/Caddyfile-upload.json

# add configmap to caddy deployment
oc -n caddyupload \
  set volumes \
  deployment.apps/caddy-upload \
  --add --configmap-name=caddyfile-json \
  --mount-path=/opt/webroot/config/Caddyfile-upload.json \
  --name=caddyfile-json \
  --sub-path=Caddyfile-upload.json \
  --type=configmap

In case the first shot of the configuration file does not work can you use the following commands to recreate the configuration file.

oc -n caddyupload-nis delete configmap caddyfile-json \
&& oc -n caddyupload-nis create configmap caddyfile-json --from-file=Caddyfile-upload.json=<PATH_TO_LOCAL>/Caddyfile-upload.json

# output of the commands
configmap "caddyfile-json" deleted
configmap/caddyfile-json created

Health probe

There is a builtin health handler with the path /health which just returns 200 and Okay. The log output can be controled via the environment variable SKIP_LOG.

example cli

When you run the Image with port 8888 can you use curl or any other tool to post (upload) files

It’s not necessary to use -X POST as written in this Blog post UNNECESSARY USE OF CURL -X

Here a example call with curl

curl -v --form myFile=@README.adoc http://localhost:8888/templates/upload-template.html
*   Trying 127.0.0.1:8888...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8888 (#0)
> POST /templates/upload-template.html HTTP/1.1
> Host: localhost:8888
> User-Agent: curl/7.68.0
> Accept: */*
> Content-Length: 2492
> Content-Type: multipart/form-data; boundary=------------------------58b770bc61c0e691
> Expect: 100-continue
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 100 Continue
* We are completely uploaded and fine
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< Accept-Ranges: bytes
< Content-Length: 299
< Etag: "rbb1gx8b"
< Last-Modified: Tue, 03 May 2022 11:34:09 GMT
< Server: Caddy
< Date: Thu, 19 May 2022 21:45:07 GMT
<

http.request.uri.path: {{placeholder "http.request.uri.path"}}

http.request.uuid {{placeholder "http.request.uuid" }}
http.request.host {{placeholder "http.request.host" }}

http.upload.filename: {{placeholder "http.upload.filename"}}
http.upload.filesize: {{placeholder "http.upload.filesize"}}

Background informations

The max_form_buffer paramater will be directly passed to readForm function and is used to check if the uploaded file should be saved temporarly on disk or keep it in the memory. This have dicret impact into the performance and disk usage of that module. Keep in mind when this paramter is low and the upload is a big file then will be there a lot of disk io.

INFO: The observation from https://github.com/etherwvlf in issue Memory issues on large uploads was that the initial memory usage is 7-8 times higher then the configured max_form_buffer size.

About

This repo holds a simple caddyserver v2 upload handler

Resources

Stars

32 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages