Reverse proxy replace_status for doomed CORS preflights?

1. The problem I’m having:

I’m having trouble accessing request header info in a reverse_proxy response matcher. Is it possible to do so?
I’d like Caddy to rewrite the status code for OPTIONS requests when a reverse_proxy backend is down/misbehaving, replacing the 502 with 200 so the browser will continue on to make the actual doomed request, receiving the 502 in a manner javascript can see and handle properly rather than triggering the opaque CORS access-blocked behavior in the browser.

2. Error messages and/or full log output:

Well, I see expression cannot be used to match responses. I tried in v2.7.4 though anyway, hoping the feature was added since that post, but it has not:

2023/09/17 19:32:44.326	INFO	using provided configuration	{"config_file": "/etc/caddy/Caddyfile", "config_adapter": "caddyfile"}
Error: adapting config using caddyfile: parsing caddyfile tokens for 'handle': /etc/caddy/Caddyfile:24 - Error during parsing: parsing caddyfile tokens for 'reverse_proxy': /etc/caddy/Caddyfile:19 - Error during parsing: unrecognized response matcher expression, import chain: [''], import chain: ['']

3. Caddy version:

$ docker run --rm caddy:2.7.4 /usr/bin/caddy version
v2.7.4 h1:J8nisjdOxnYHXlorUKXY75Gr6iBfudfoGhrJ8t7/flI=

4. How I installed and ran Caddy:

a. System environment:

Docker, on Ubuntu 22.04.3, x86_64, Linux 6.2.0-32-generic

d. My complete Caddy config:

reverse_proxy some-backend:8000 {
  @avoidCorsConstipation {        
      status 502
      # nope: expression `{http.request.method} == 'OPTIONS'`
      # nope: method OPTIONS
  }
  replace_status @avoidCorsConstipation 200
}

5. Links to relevant resources:

You’ll have to use a request matcher before the reverse_proxy. Maybe something like this:

@options method OPTIONS
reverse_proxy @options some-backend:8000 {
  @avoidCorsConstipation status 502
  replace_status @avoidCorsConstipation 200
}

reverse_proxy some-backend:8000

I agree this isn’t ideal, I’m still thinking through the best way to implement this kind of functionality in response matchers. For now, only status and header (for response headers) are supported since that’s all we really see in the response (other than the body… but that’s a stream so matching safely is harder and that’s out of scope for this question :sweat_smile:).

The above looks like it would handle status code, but to feed the browser with an all-clear CORS response, Caddy would still need to access request data when intercepting the response, primarily for the Access-Control-Allow-Origin response header. (I forgot about the headers while asking, sorry–it’s not just status code.)

thinking through the best way to implement this kind of functionality in response matchers

Just speculating, but since the Caddy logger seems to carry some request headers through the response cycle, perhaps a copy_request_header directive could use a similar mechanism to carry that data along until the response begins? Eg:

handle /api/* {
  reverse_proxy some-backend:8000 {
    copy_request_header Method X-Req-Method
    copy_request_header Origin X-Req-Origin
      
    @avoidCorsConstipation {        
      status 502
      header X-Req-Method OPTIONS
    }
    handle_response @avoidCorsConstipation {
      header Access-Control-Allow-Origin { header.X-Req-Origin } 
      header Access-Control-Allow-Methods "POST, GET, OPTIONS, DELETE"
      header Access-Control-Allow-Headers "Some, Whitelist"
      header Access-Control-Max-Age 0

      respond 200
    }
      
    header_down -X-Req-Method
    header_down -X-Req-Origin    
  }
}

Yes you can do exactly that:

    handle_response @avoidCorsConstipation {
      header Access-Control-Allow-Origin {header.Origin}
      header Access-Control-Allow-Methods "POST, GET, OPTIONS, DELETE"
      header Access-Control-Allow-Headers "Some, Whitelist"
      header Access-Control-Max-Age 0

      respond 200
    }

Just don’t use spaces in the placeholder.

What I wrote earlier still applies. You need to use a request matcher to match the method.

Sounds great but now I can’t get a response intercept to work in any form, when the upstream backend is down/missing. Strange, I thought replace_status worked when I tried it but wonder now.

More precise steps:

docker volume create caddy_cors_temp

docker run --rm -p 3333:3333 \
  -v caddy_cors_temp:/data \
  -v $PWD/caddyfile-tryCors:/etc/caddy/Caddyfile \
  caddy:2.7.4 

Content of $PWD/caddyfile-tryCors:

{
	admin off
	auto_https disable_redirects
	debug
}

http://127.0.0.1:3333 {

  handle /moo/* {
    header CowSays Moooo
    respond 200
  }

  @corsPreflight {
    method OPTIONS
    path /api/*
  }

  handle @corsPreflight {
    reverse_proxy server-down:8000 {
      @avoidCorsConstipation status 502
      handle_response @avoidCorsConstipation {
        header Access-Control-Allow-Origin {header.Origin}
        header Access-Control-Allow-Methods "GET, POST, PUT, DELETE"
        header Access-Control-Allow-Headers "Some, Whitelist"
        header Access-Control-Max-Age 0

        respond 200
      }
    }
  }

  handle /api/* {
    reverse_proxy server-down:8000
  }
}

Trying with curl:

$ curl -I -X OPTIONS http://127.0.0.1:3333/moo/cow

HTTP/1.1 200 OK
Cowsays: Moooo
Server: Caddy
Date: Mon, 18 Sep 2023 23:39:07 GMT
Content-Length: 0

$ curl -I -X OPTIONS http://127.0.0.1:3333/api/meh

HTTP/1.1 502 Bad Gateway
Server: Caddy
Date: Mon, 18 Sep 2023 23:39:08 GMT
Content-Length: 0

I think I’ve hit my time limit on this, at least for a while. But if a bug, let me know if you’d like me to submit it as an issue.

Ah right – you need to use handle_errors to deal with proxy errors emitted by reverse_proxy.

handle_response only deals with responses actually written by the upstream. If the error is produced by Caddy failing to connect, the there’s no 502 response coming from the upstream, the response is produced by Caddy and not proxied.

Ah, very nice. Browser behavior without this is normally just a minor inconvenience or red herring during local development, but for my current project it’s a bigger issue. Thank you for the help!

For the next person, here’s the working caddyfile:

{
	admin off
	auto_https disable_redirects
	debug
}

http://127.0.0.1:3333 {

  handle_errors {
    @corsPreflightForDoomedRequest {
      method OPTIONS
      expression `{err.status_code}== 502`
      path /api/*
    }
    handle @corsPreflightForDoomedRequest {
      header Access-Control-Allow-Origin {header.Origin}
      header Access-Control-Allow-Methods "GET, POST, PUT, DELETE"
      header Access-Control-Allow-Headers "Some, Whitelist"
      header Access-Control-Max-Age 0

      respond 200
    }
  }

  handle /api/* {
    reverse_proxy server-down:8000
  }
}

$ curl -I -X OPTIONS --header "Origin: https://over.there" http://127.0.0.1:3333/api/meh

HTTP/1.1 200 OK
Access-Control-Allow-Headers: Some, Whitelist
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Origin: https://over.there
Access-Control-Max-Age: 0
Server: Caddy
Date: Tue, 19 Sep 2023 15:52:24 GMT
Content-Length: 0

1 Like

For fun you can make this a one liner since you’re already using an expression:

@corsPreflightForDoomedRequest `{err.status_code} == 502 && path('/api/*') && method('OPTIONS')`
1 Like