Implementing CORS whitelist in Caddy v2

I wanted to get some feedback on ways to create a cors whitelist. This seems like the most basic approach.

@origin1 {
  header Origin origin1.com
}
header @origin1 Access-Control-Allow-Origin "origin1.com"
header @origin1 Access-Control-Request-Method GET

@origin2 {
  header Origin origin1.com
}
header @origin2 Access-Control-Allow-Origin "origin2.com"
header @origin2 Access-Control-Request-Method GET

This seems like a good candidate for a plugin. I noticed that the admin takes an array of origins for allowing CORS.

Wondering what other Caddy users are doing.

The documentation implies that this should work, but it doesn’t. header is both a matcher and a directive… it’s a little confusing. Even if it worked I wouldn’t do it this way.

header Origin origin1.com header Access-Control-Allow-Origin origin1.com

With Caddy v2.1 I’d recommend this approach:

(cors) {
	@origin header Origin {args.0}
	header @origin Access-Control-Allow-Origin "{args.0}"
	header @origin Access-Control-Request-Method GET
}

origin1.com {
	import cors origin1.com
}

origin2.com {
	import cors origin2.com
}

This uses 2 new features:

  • single line named matcher syntax, which lets you omit the { } braces for the matcher if you only have one thing to match
  • import args (along with snippets which already existed), which let you use {args.*} as a placeholder where the numbers are the positional arguments given to the import directive.

The first beta for Caddy v2.1 was just released today: Release 2.1 beta 1 · caddyserver/caddy · GitHub

Frankly, I’m not sure what you’re referring to. That’s definitely invalid syntax. Could you clarify what you mean?

See the matcher docs here:

1 Like

header is a directive

header is a matcher

header <matcher> <key> <value>
header header Origin foo.com Access-Control-Allow-Origin foo.com

I know it’s invalid. I can’t explain what I was trying to point out.

The v2.1 solution looks cool. Is the PR that makes this possible definitely making it into v2.1?

Read the syntax for matchers:

In the Caddyfile, a matcher token immediately following the directive can limit that directive’s scope. The matcher token can be one of these forms:

  1. * to match all requests (wildcard; default).
  2. /path start with a forward slash to match a request path.
  3. @name to specify a named matcher .

For anything other than simple path matchers, you must use a named matcher in the Caddyfile.

It’s already available in today’s beta release! You can try it out right now!

Actually if you could write a semi-complete example of a Caddyfile where you’d use this kind of thing, that would be helpful.

I just thought of an edgecase that might apply for you that might cause some issues (i.e. if you import a snippet more than once in the same site block) but I don’t want to delve into the problem and the solution right now (I can already offer a workaround) unless it actually applies for your usecase.

I can post one. Is the issue related to my first attempt? Or to the v2.1 snippet with argument approach?

To the v2.1 approach — basically you’ll get errors if you try to import the same snippet more than once in the same site block, but that only matters if you need it more than once.

Here’s the example.

In the work I do, CORS is usually only necessary with APIs that I’m writing in Node.js, so I’d handle the CORS headers there. Implementing a CORS whitelist in Caddy is not a full on edge case, but it’s a bit of a rarity.

(cors) {
  @originmember {
    header Origin https://member.myawesomewebsite.com
  }
  header @originmember Access-Control-Allow-Origin "https://member.myawesomewebsite.com"
  @origincustomer {
    header Origin https://customer.myawesomewebsite.com
  }
  header @origincustomer Access-Control-Allow-Origin "https://customer.myawesomewebsite.com"
}
myawesomewebsite.com {
  root * /srv/public/
  file_server
  include cors
}

And that’s it. There’s no need in my case to include the snippet multiple times. I’m curious what the issue you spoke of would look like, and what the workaround is.

I also would like an opinion about whether the JSON API is capable of handling a CORS whitelist.

Also, is this a good case for a plugin?

Okay yeah, so with the “optimal” solution I was thinking of for your situation, it would look something along these lines:

(Note, it’s import, not include)

(cors) {
  @origin header Origin {args.0}
  header @origin Access-Control-Allow-Origin "{args.0}"
}
myawesomewebsite.com {
  root * /srv/public/
  file_server
  import cors https://member.myawesomewebsite.com
  import cors https://customer.myawesomewebsite.com
}

Unfortunately, this won’t work if you import more than once, because the named matcher @origin will already be defined with the first import, so it will fail on the second import.

The workaround is to disambiguate the named matcher. There’s a couple of ways to do this, both are kinda non-optimal and look wacky.

(cors) {
  @origin{args.0} header Origin {args.1}
  header @origin{args.0} Access-Control-Allow-Origin "{args.1}"
}
myawesomewebsite.com {
  root * /srv/public/
  file_server
  import cors 1 https://member.myawesomewebsite.com
  import cors 2 https://customer.myawesomewebsite.com
}

Here we add another argument to the import and use that as a suffix for the named matcher. This will work, but you need to add another argument on each import.

Another option which is a bit easier to use and read:

(cors) {
  @origin{args.0} header Origin {args.0}
  header @origin{args.0} Access-Control-Allow-Origin "{args.0}"
}
myawesomewebsite.com {
  root * /srv/public/
  file_server
  import cors https://member.myawesomewebsite.com
  import cors https://customer.myawesomewebsite.com
}

This just uses the domain itself as the suffix for the named matcher. This should work just fine because named matchers only end when a space character is encountered. But it’s super dumb to think about the named matcher being originhttps://member.myawesomewebsite.com internally :scream:

I’ve been brainstorming ways to make this nicer to read but it’s a tricky problem to solve.

Yeah, the Caddyfile is just an adapter that maps to JSON. You can see the adapted JSON for your Caddyfile with the caddy adapt command. With the API, you can manipulate that JSON on the fly. You’d be adding/removing route objects. See JSON Config Structure - Caddy Documentation

I dunno. I don’t think it’s necessary frankly, because everything you need can be done without a plugin, as far as I understand.

2 Likes

I get it. You have to make things that should be unique really be unique if you’re making a snippet. If you create a named matcher, for example, in a snippet, that named matcher would be defined twice, creating an error.

It seems like the solution to the problem is awareness. Maybe taking care to have a descriptive error message for the case would help. I have seen some libraries link to a Github issue in the error message. That has saved me a lot of time in the past.

This thread will also help someone else I’m sure.

Yeah. It’s tricky because the phase where named matchers are parsed is a totally different step than the import, and they have no knowledge of eachother. To give a better error message, they would need to know about eachother, and that’s a pretty significant change just to improve the error message. But I definitely agree, there’s plenty of room for improvement there.

Am I naive to think that something in the error thrown by duplicate named matchers would help? Maybe a “hey, you have duplicate named matchers, if it’s not immediately obvious why, read this…”

No fancy cross-talk between parsers for named matchers and import.

It’s a strange coincidence, we had to implement CORS on a website yesterday and I found this solution. After installing Caddy 2.1 b1, I can confirm it works great ! :+1:

When Caddy 2.1 is finalised, it should definitely be a Wiki entry.

Thanks for your help on this.

4 Likes

This topic was automatically closed after 30 days. New replies are no longer allowed.