Caddy CEL Matcher: How to Match Requests With Missing Query Parameter?

Caddy CEL Matcher: How to Match Requests With Missing Query Parameter?

1. The problem I’m having:

I’m trying to configure Caddy to reverse proxy for an app at edit.hacking.xxxxxx.net.
I want to allow all requests that do not have a folder query parameter, and only allow requests with folder=/doc or folder=/doc/*.
All other requests with a folder query param should be denied.

Additionally, the app uses WebSockets, so WebSocket upgrade requests must always be proxied.

The issue:
I cannot find a reliable way to match requests that do not have a folder query parameter using the CEL expression matcher.
I’ve tried several expressions (see below), but either get errors like no such overload or they do not match as expected.
The Caddy docs/examples only show how to match a query parameter with a value, not how to match when it is missing.

Example curl:

curl -v 'https://edit.hacking.xxxxxx.net/'
curl -v 'https://edit.hacking.xxxxxx.net/?folder=/doc'
curl -v 'https://edit.hacking.xxxxxx.net/?folder=/other'
  • / and /?folder=/doc should be allowed.
  • /?folder=/other should be denied.

2. Error messages and/or full log output:

{"level":"error","logger":"http.matchers.expression","msg":"evaluating expression","error":"no such overload"}
ERROR: <input>:1:25: undeclared reference to 'nil' (in container '')
ERROR: <input>:1:19: undeclared reference to 'has' (in container '')

3. Caddy version:

v2.9.0 h1:rteY8N18LsQn+2KVk6R10Vg/AlNsID1N/Ek9JLjm2yE=

4. How I installed and ran Caddy:

a. System environment:

  • Arch Linux
  • systemd
  • Caddy installed from official release

b. Command:

/servers/caddy/caddy run --config /servers/caddy/.config/caddy/Caddyfile

c. Service/unit/compose file:

systemd service file and override
# caddy.service
#
# For using Caddy with a config file.
#
# Make sure the ExecStart and ExecReload commands are correct
# for your installation.
#
# See https://caddyserver.com/docs/install for instructions.
#
# WARNING: This service does not use the --resume flag, so if you
# use the API to make changes, they will be overwritten by the
# Caddyfile next time the service is restarted. If you intend to
# use Caddy's API to configure it, add the --resume flag to the
# `caddy run` command or use the caddy-api.service file instead.

[Unit]
Description=Caddy web server
Documentation=https://caddyserver.com/docs/
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=14400
StartLimitBurst=10

[Service]
Type=notify
User=caddy
Group=caddy
Environment=XDG_DATA_HOME=/var/lib
Environment=XDG_CONFIG_HOME=/etc
ExecStartPre=/usr/bin/caddy validate --config /etc/caddy/Caddyfile
ExecStart=/usr/bin/caddy run --environ --config /etc/caddy/Caddyfile
ExecReload=/usr/bin/caddy reload --config /etc/caddy/Caddyfile --force
ExecStopPost=/usr/bin/rm -f /run/caddy/admin.socket

# Do not allow the process to be restarted in a tight loop. If the
# process fails to start, something critical needs to be fixed.
Restart=on-abnormal

# Use graceful shutdown with a reasonable timeout
TimeoutStopSec=5s

LimitNOFILE=1048576
LimitNPROC=512

# Hardening options
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
DevicePolicy=closed
LockPersonality=true
MemoryAccounting=true
MemoryDenyWriteExecute=true
NoNewPrivileges=true
PrivateDevices=true
PrivateTmp=true
ProcSubset=pid
ProtectClock=true
ProtectControlGroups=true
ProtectHome=true
ProtectHostname=true
ProtectKernelLogs=true
ProtectKernelModules=true
ProtectKernelTunables=true
ProtectProc=invisible
ProtectSystem=strict
RemoveIPC=true
ReadWritePaths=/var/lib/caddy /var/log/caddy /run/caddy
RestrictNamespaces=true
RestrictRealtime=true
RestrictSUIDSGID=true

[Install]
WantedBy=multi-user.target

# /etc/systemd/system/caddy.service.d/01--override.conf
[Service]
Environment=
Type=simple
User=xxxxxx
Group=xxxxxx
WorkingDirectory=/servers/caddy
ExecStartPre=
ExecStart=
ExecStart=/servers/caddy/caddy run
ExecReload=
ExecStopPost=
ReadWritePaths=
ReadWritePaths=/servers/caddy/.data/caddy

d. My complete Caddy config:

@notes host notes.hacking.xxxxxx.net notes.hacking.xxxxxx.net
   handle @notes {
   reverse_proxy trantor.238.xxxxxx.net:8900
   }

@editnotes host edit.hacking.xxxxxx.net edit.hacking.xxxxxx.net

# Match WebSocket upgrades
@ws header Connection *Upgrade*

# Match requests with NO folder query
# Tried all of these, none work:
# @no_folder expression `!request.query.folder`
# @no_folder expression `request.query.folder == nil`
# @no_folder expression `!request.query.has('folder')`
@no_folder expression `request.query.folder == ''`

# Match only requests for /doc or /doc/*
@allowedfolder expression `{query.folder} == '/doc' || {query.folder}.startsWith('/doc/')`

handle @editnotes {
    handle @ws {
        reverse_proxy trantor.238.xxxxxx.net:8901
    }
    handle @no_folder {
        reverse_proxy trantor.238.xxxxxx.net:8901
    }
    handle @allowedfolder {
        reverse_proxy trantor.238.xxxxxx.net:8901
    }
    handle {
        respond "Access denied" 403
    }
}

5. Links to relevant resources:


Summary:
Is there a supported way to match requests where a query parameter is missing in the CEL matcher?
If not, is there a recommended workaround for this use case?

:80 {
	@doc {
		not {
			not query folder=/doc
			query folder=*
		}
	}

	respond @doc "ALLOW" 200
	respond "REJECT" 403
}
$ curl http://localhost/
ALLOW

$ url http://localhost/?folder
REJECT

$ curl http://localhost/?folder=/doc
ALLOW

$ curl http://localhost/?folder=/other
REJECT
1 Like

I went back to your question and noticed that you want to match not only ?folder=/doc, but also any folder query parameter that starts with /doc/.

Here’s the updated matcher:

:80 {

	@doc {
		not {
			not expression `{query.folder} == '/doc' || {query.folder}.startsWith('/doc/')`
			query folder=*
		}
	}

	respond @doc "ALLOW" 200
	respond "REJECT" 403
}
$ curl http://localhost/
ALLOW

$ curl http://localhost/?folder
REJECT

$ curl http://localhost/?folder=/doc
ALLOW
 
$ curl http://localhost/?folder=/doc/file
ALLOW

$ curl http://localhost/?folder=/other
REJECT

As far as I can tell, the query matcher doesn’t support wildcards with prefixes or suffixes, so using CEL seems to be the only way to handle this.

Here’s another thought - if your site is about hacking and the folder parameter is used to point to a document your site displays, you might also want to watch out for directory traversal attacks via the folder parameter.

:80 {

	@doc {
		not {
			not expression `({query.folder} == '/doc' || {query.folder}.startsWith('/doc/')) && !{query.folder}.contains("..")`
			query folder=*
		}
	}

	respond @doc "ALLOW" 200
	respond "REJECT" 403
}

This isn’t bulletproof, but you get the idea :smiley:

$ curl http://localhost/
ALLOW

$ curl http://localhost/?folder
REJECT

$ curl http://localhost/?folder=/doc
ALLOW

$ curl http://localhost/?folder=/doc/file
ALLOW

$ curl http://localhost/?folder=/other
REJECT

$ curl http://localhost/?folder=/doc/../other
REJECT

Edit:

Alright, you can ignore everything I posted here before. You can simplify it all down to this one-line matcher:

@doc `(!query({'folder': '*'}) || {query.folder} == '/doc' || {query.folder}.startsWith('/doc/')) && !{query.folder}.contains("..")`

Credit to @francislavoie for steering me in the right direction :slight_smile:

1 Like

@timelordx thanks for your input.

more background. this is for code-server. What I am trying to do it kinda fix a security flaw which is the app can access ANY folder all the way to root /. which is why they recommend running in a container. I understand this is not a totally secure solution but it at least “jails” the user to the directory I want.

There are bunch of other needed endpoints called (not via UI directly) that do not have the query parameter and must be allowed. Since I really don’t know what they are for now just need to allow

curl http://localhost/any/path/

but should block

curl http://localhost/any/path/?folder=/anyfolder

i.e if folder param then no other subpaths.

Anyway what you provided is already very helpful.

1 Like

@timelordx

just reporting that your last “edit” for @doc works great. It does allow

curl http://localhost/any/path/?folder=/doc but I am thinking that is pretty far fetched url that would not lead to any actual response (other than 404) and you’d have to know a real endpoint anyway. Like I said not really a way to make code-server secure from determined person but does jail my regular users to the folder they are “allowed” to edit. So marking your last as the solution. Thx again.

1 Like

just to share with anyone else needing something similar here was my final setup.

@editnotes host edit.xxxx.net

@doc `(!query({'folder': '*'}) || {query.folder} == '/srv/docs' || {query.folder}.startsWith('/srv/docs/')) && !{query.folder}.contains("..")`

handle @editnotes {
    handle @doc {
        reverse_proxy @doc server.xxxx.net:8901
    }    
	respond "Only access to folder=/srv/docs is allowed, REJECTED " 403

  }

that is reverse proxy to code-server container

version: "3.8"
services:
  code-server:
    image: ghcr.io/coder/code-server:latest
    container_name: edit-server
    ports:
      - "8401:8080"
    volumes:
      - docs:/srv
      - csdata:/home/coder/csdata
    environment: []
    command: >
      --auth none
      --user-data-dir /home/coder/csdata/data
      --extensions-dir /home/coder/csdata/extensions
      /srv
    restart: unless-stopped

volumes:
  docs:
    driver: local
    driver_opts:
      type: none
      device: /data/docs
      o: bind
  csdata:
    driver: local
    driver_opts:
      type: none
      device: /servers/docseditor
      o: bind
2 Likes