Why doesn't a handle match a bare / path?

1. Output of caddy version:

root@srv ~# docker exec -it caddy caddy version
v2.6.2 h1:wKoFIxpmOJLGl3QXoo6PNbYvGW4xLEgo32GPBEjWL8o=

2. How I run Caddy:

a. System environment:

Caddy runs in a container, as a reverse proxy for services that are also containerized.

b. Command:

(see below for the docker compose)

c. Service/unit/compose file:

version: "3"

networks:
  srv:
    external: true
    name: srv

services:
  caddy:
    image: caddy:2
    container_name: caddy
    volumes:
      - /etc/docker/caddy/data/Caddyfile:/etc/caddy/Caddyfile
      - /etc/docker/caddy/data/sites:/data
      - /etc/docker/caddy/data/config:/config
      - /etc/docker:/etc/docker:ro
    ports:
      - 80:80
      - 443:443
      - 2015:2015
    environment:
      - ACME_AGREE=true
    restart: unless-stopped
    networks:
      - srv

d. My complete Caddy config:

Note: despite the several warnings in capital letters (that I completely understand) I ultimately redacted the FQDN. This is because the setup is not correct yet, and while there is no huge risk I want to avoid giving a super obvious way to use the service. This is not for security reasons, but rather for, let’s call them “responsibility” ones.

Note 2: I heavily simplified the configuration from the initial version to pinpoint the issue

{
	admin 0.0.0.0:2015
	email my_email_here
	log {
		level ERROR
	}
}

https://share.example.com {
	@addfile path /
	handle @addfile {
		respond 401
	}

	@admin path /admin
	handle @admin {
		respond 401
	}

	@retrievefile path /*
	handle @retrievefile {
		respond 200
	}
}

3. The problem I’m having:

My expectation with the configuration above was:

  • https://share.example.com matches the 3rd handle and responds 401does not work, responds 200 instead of 401
  • https://share.example.com/admin matches the 2nd handle and responds 401 → works fine
  • https://share.example.com/anything_except_admin matches the 3rd handle, and responds 200 → works fine
$ curl -I https://share.example.com/
HTTP/2 200
alt-svc: h3=":443"; ma=2592000
server: Caddy
date: Thu, 24 Nov 2022 14:29:49 GMT

$ curl -I https://share.example.com/admin
HTTP/2 401
alt-svc: h3=":443"; ma=2592000
server: Caddy
date: Thu, 24 Nov 2022 14:29:44 GMT

$ curl -I https://share.example.com/lmkmlsdkfmldk
HTTP/2 200
alt-svc: h3=":443"; ma=2592000
server: Caddy
date: Thu, 24 Nov 2022 14:29:59 GMT

It would seem to me as if / was matched by /* which I believe should not be the case.

You seem to be running into matcher sorting issues.
In a nutshell, Caddy tries to sort all your matchers from the most specific (read: longest) to less specific (shortest).

With that in mind, that would result in:

  1. /admin (6 chars)
  2. /* (2 chars)
  3. / (1 char)

You can see that in the sorting from the conversion from Caddyfile to json (which Caddy uses internally) yourself when running caddy adapt.

Note: I simplified your Caddyfile even more into the following:

localhost {
	respond / 401

	respond /admin 401

	respond /* 200
}

And here is the important json part:

❯ caddy adapt | jq '.apps.http.servers.srv0.routes | first | .handle'
[
  {
    "handler": "subroute",
    "routes": [
      {
        "handle": [
          {
            "handler": "static_response",
            "status_code": 401
          }
        ],
        "match": [
          {
            "path": [
              "/admin"
            ]
          }
        ]
      },
      {
        "handle": [
          {
            "handler": "static_response",
            "status_code": 200
          }
        ],
        "match": [
          {
            "path": [
              "/*"
            ]
          }
        ]
      },
      {
        "handle": [
          {
            "handler": "static_response",
            "status_code": 401
          }
        ],
        "match": [
          {
            "path": [
              "/"
            ]
          }
        ]
      }
    ]
  }
]

You can however explicitly disable the directive (and matcher) sorting by wrapping everything into route (Caddyfile directive) — Caddy Documentation

localhost {
	route {
		respond / 401

		respond /admin 401

		respond /* 200
	}
}
❯ curl -Iks https://localhost/ | grep HTTP
HTTP/2 401

❯ curl -Iks https://localhost/admin | grep HTTP
HTTP/2 401

❯ curl -Iks https://localhost/lmkmlsdkfmldk | grep HTTP
HTTP/2 200

or instead of

@retrievefile path /*
handle @retrievefile {
	respond 200
}

you could also just use an empty handle block:

handle {
	respond 200
}

A handle block without a matcher is considered a fallback.
Everything get sorted as usual, but you can’t get less specific than a handler without a matcher :sweat_smile:
So that one will always be the last in the chain and thus only checked after all the others.
A fallback :slight_smile:

That’s more or less shown in one of the examples in handle (Caddyfile directive) — Caddy Documentation

4 Likes

Woah, this is the first decision in Caddy I do not understand (being a virulent supporter everywhere I go). I can hardly understand how /hey is more specific than /this_is_secret_make_sure_to_filter_that

Ah - excellent. Thank you very much, this is exactly what I needed.
I would never had guessed this. Thanks again.

It’s the other way around. A longer path is more specific than a shorter path, so longer paths are sorted higher and tried first.

This is a pretty good guess, because a path like /foo/* should not be tried before /foo/bar/* since it would cover it.

But /* is longer than / yet happens to cover it.

IMO we should actually make path /* a warning or error; there’s never a good reason to use it, because it’s tautological. It just means “match all paths”, essentially. It can be simplified by not using a matcher at all which already matches all requests.

This is the first time I’ve seen someone need to match exactly / before everything else though. It’s a pretty rare usecase.

2 Likes

Along with what others have explained, you might find this article helpful! Composing in the Caddyfile

3 Likes

Ah ok - my bad. Thanks for the clarification.

It is. Very much. Thank you.

I actually discovered the “Wiki” section and it is full of useful information. I would even say that some of them (like the article you linked to) should make it to the docs.

1 Like

In my case this is a service through which you can share files (namely GitHub - psi-4ward/psitransfer: Simple open source self-hosted file sharing solution.). I wanted to main page (where you create a share) to be restricted behind a login (as well as /admin), but since the files are hidden behind a key directly under the root path (example.com/the_key_here) I wanted that part to be public.

In other words: only some people can create shares, but once they are created they should be available to everyone who knows the URI.

The wiki is linked to from the docs, quite prominently, actually. Right at the top of the sidebar.

But they’re separate on purpose. The wiki is ideal for more abstract article style content than documentation.

Yeah. Although I do wonder if some wiki articles could graduate to docs. I don’t know that that would actually make them more discoverable though. :man_shrugging:

1 Like