Conditionally echoing header parts with header_regexp

1. Output of caddy version:

v2.6.2 h1:wKoFIxpmOJLGl3QXoo6PNbYvGW4xLEgo32GPBEjWL8o=

2. How I run Caddy:

a. System environment:

Docker, official “caddy” image

b. Command:

docker run --rm -p 127.0.0.1:81:80 -v "$(pwd)/static-service:/static-service:ro" -v "$(pwd)/Caddyfile-rewrite:/etc/caddy/Caddyfile:ro" caddy caddy version

c. Service/unit/compose file:

d. My complete Caddy config:

Variant A

Building a named matcher:

(frame-embedding-headers) {
	@one-of-ours header_regexp refhost Referer "(https://(.*\.)?(domain1|domain2|domain3)\.com).*"

	header not @one-of-ours X-Frame-Options "DENY"
	header @one-of-ours X-Frame-Options "ALLOW-FROM {re.refhost.1}"
}

:80 {
	import frame-embedding-headers

	file_server /* {
		root /static-service
	}
}

Variant B

Spelling out the matcher in the header directive.

(frame-embedding-headers) {
	header not header_regexp refhost Referer "(https://(.*\.)?(domain1|domain2|domain3)\.com).*" X-Frame-Options "DENY"
	header header_regexp refhost Referer "(https://(.*\.)?(domain1|domain2|domain3)\.com).*" X-Frame-Options "ALLOW-FROM {re.refhost.1}"
}

:80 {
	import frame-embedding-headers

	file_server /* {
		root /static-service
	}
}

Variant C

Putting the config direct, not as a snippet:


:80 {
	@one-of-ours header_regexp refhost Referer "(https://(.*\.)?(domain1|domain2|domain3)\.com).*"

	header not @one-of-ours X-Frame-Options "DENY"
	header @one-of-ours X-Frame-Options "ALLOW-FROM {re.refhost.1}"

	file_server /* {
		root /static-service
	}
}

3. The problem I’m having:

I want to set X-Frame-Options to protect ancient browsers from click hijacking.

You can only set one X-Frame-Options header, with one ALLOW-FROM domain expression.

Hence, I want to echo the Referer Header’s Host.

I tried various things that failed on a syntax level, and distilled it to two versions of valid syntax (named matcher and literal matcher), neither of which works.

4. Error messages and/or full log output:

Variant A

No Referer header:
$ curl -v http://localhost:81
*   Trying 127.0.0.1:81...
* Connected to localhost (127.0.0.1) port 81 (#0)
> GET / HTTP/1.1
> Host: localhost:81
> User-Agent: curl/7.79.1
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< Accept-Ranges: bytes
< Content-Length: 108
< Content-Type: text/html; charset=utf-8
< Etag: "rlecry30"
< Last-Modified: Tue, 15 Nov 2022 16:07:10 GMT
< Server: Caddy
< Date: Wed, 16 Nov 2022 14:17:26 GMT
<
<html>
<body>
</body>
</html>
* Connection #0 to host localhost left intact

:x:

Non-matching Referer header:
curl -H "Referer: https://pepi.lacht" -v http://localhost:81
*   Trying 127.0.0.1:81...
* Connected to localhost (127.0.0.1) port 81 (#0)
> GET / HTTP/1.1
> Host: localhost:81
> User-Agent: curl/7.79.1
> Accept: */*
> Referer: https://pepi.lacht
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< Accept-Ranges: bytes
< Content-Length: 108
< Content-Type: text/html; charset=utf-8
< Etag: "rlecry30"
< Last-Modified: Tue, 15 Nov 2022 16:07:10 GMT
< Server: Caddy
< Date: Wed, 16 Nov 2022 14:24:05 GMT
<
<html>
<body>
</body>
</html>
* Connection #0 to host localhost left intact

Expected: X-Frame-Options: DENY; Actual: No header.

:x:

Matching Referer header:
$ curl -H "Referer: https://domain1.com" -v http://localhost:81
*   Trying 127.0.0.1:81...
* Connected to localhost (127.0.0.1) port 81 (#0)
> GET / HTTP/1.1
> Host: localhost:81
> User-Agent: curl/7.79.1
> Accept: */*
> Referer: https://domain1.com
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< Accept-Ranges: bytes
< Content-Length: 108
< Content-Type: text/html; charset=utf-8
< Etag: "rlecry30"
< Last-Modified: Tue, 15 Nov 2022 16:07:10 GMT
< Server: Caddy
< X-Frame-Options: ALLOW-FROM https://domain1.com
< Date: Wed, 16 Nov 2022 14:25:18 GMT
<
<html>
<body>
</body>
</html>
* Connection #0 to host localhost left intact

Expected: X-Frame-Options: ALLOW-FROM https://domain1.com
Actual: X-Frame-Options: ALLOW-FROM https://domain1.com

:white_check_mark:

Variant B

All three Calls give the same Output:

*   Trying 127.0.0.1:81...
* Connected to localhost (127.0.0.1) port 81 (#0)
> GET / HTTP/1.1
> Host: localhost:81
> User-Agent: curl/7.79.1
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< Accept-Ranges: bytes
< Content-Length: 108
< Content-Type: text/html; charset=utf-8
< Etag: "rlecry30"
< Last-Modified: Tue, 15 Nov 2022 16:07:10 GMT
< Server: Caddy
< X-Frame-Options: ALLOW-FROM {http.regexp.refhost.1}
< Date: Wed, 16 Nov 2022 14:32:41 GMT
<
<html>
<body>
</body>
</html>
* Connection #0 to host localhost left intact

I.e. the positive match seems to be taken no matter whether the Header is even present, but doesn’t fill the {re.…} expression even when it should match?!

:x:, :x: and :x:

Variant C

As expected behaved exactly like Variant A

5. What I already tried:

Variants A and B + a few that were rejected by validate up front.

6. Links to relevant resources:

I checked my RE with the Golang RE checker and they matched exactly as expected (incl. groups).

That’s not valid syntax. You can’t use not inline, and you can’t use a named matcher as an argument to another matcher.

Please review the request matcher syntax documentation, it shows what is valid.

That said, to solve your issue, I recommend you read this article:

1 Like

Thanks, just these two notes made it easy to fix, at the price of repeating the regex. I guess I can make the regex a snippet argument, I’ll have to check that. Baseline is that this – based on your hints – works, for people who might find this later:

(frame-embedding-headers) {
  header +Content-Security-Policy "frame-ancestors https://*.domain1.com https://*.domain2.com https://*.domain3.com;"

  @one-of-ours header_regexp refhost Referer "(https://(.*\.)?domain[123]\.com).*"
  @not-ours {
    not {
      header_regexp Referer "(https://(.*\.)?domain[123]\.com).*"
    }
  }

  header @not-ours X-Frame-Options "DENY"
  header @one-of-ours X-Frame-Options "ALLOW-FROM {re.refhost.1}
}

foo.domain1.com {
  import frame-embedding-headers
  …
}

…

Quick feedback/impression:

I did review the documentation just now. Unless I was looking in the wrong place (see links below), I still feel these issues are not clear from it:

First item says:

not <any other matcher>

This looks
a) inline, and
b) says any other matcher:

Says “All matchers that are not path or wildcard matchers must be named matchers. This is a matcher that is defined outside of any particular directive, and can be reused.”

This makes it sound like named matchers are matchers and can be used such. The rest of the named matcher section does not note that named matchers are not composable (e.g. with not).

Beyond the specific docs on named matchers & not, to my understanding, not being clear on the issue, I think it would be super useful if this was no issue.

The inline syntax not being general, and some types of matchers being composable and some not,
IMO hurts orthogonality/composability of the otherwise great and extremely compact syntax.

You can simplify this:

  @not-ours not header_regexp Referer "(https://(.*\.)?domain[123]\.com).*"

To clarify, what isn’t inline is matchers in general. You can only “inline” a path matcher token, or a named matcher token, to a directive.

But you can use single-line syntax for not when defining a named matcher.

1 Like