Conditional reverse proxy

1. The problem I’m having:

I have partners sending API calls to our API.
Sometimes we want to debug these calls on our local running computer, but we would like the system to be flexible.
The solution we imagine is to have the partner continue calling our back-end API URL but doing some reverse proxy stuff.
We’re using ngrok to open tunnels between our machines to a specific public static URL (NGROK_URL).

We discovered that when no tunnel are connected to ngrok, the NGROK_URL returns a 404 with a specific header Ngrok-Error-Code: ERR_NGROK_3200

Our caddy webserver is in a container, currently redirecting all the requests to an other container (called backend).

What we had in mind:
instead of redirecting everything to backed, try redirecting to NGROK_URL. If we receive an error with the above header, then we redirect the request to backend.

Ideally, we don’t want to redirect ALL endpoints, so there is a matching condition on this specific reverse_proxy.

The problem, it does not redirect to backend on NGROK error. The handle errors seems non functioning

I could try following this path, but it seems a bit over complicated: Conditional redirect with a reverse proxy

2. Error messages and/or full log output:

No error in itself, but here is an entry of access.log

{"level":"debug","ts":1729114419.3303804,"logger":"http.handlers.reverse_proxy","msg":"upstream roundtrip","upstream":"NGROK_URL:443","duration":0.043435274,"request":{"remote_ip":"xxx.xxx.xxx.xxx","remote_port":"36458","client_ip":"xxx.xxx.xxx.xxx","proto":"HTTP/2.0","method":"GET","host":"NGROK_URL","uri":"/docs","headers":{"Upgrade-Insecure-Requests":["1"],"Accept":["text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"],"Sec-Ch-Ua-Mobile":["?0"],"Priority":["u=0, i"],"Sec-Fetch-Site":["none"],"Cookie":["REDACTED"],"Cache-Control":["max-age=0"],"Sec-Ch-Ua-Platform":["\"Linux\""],"Sec-Fetch-Dest":["document"],"Sec-Ch-Ua":["\"Chromium\";v=\"129\", \"Not=A?Brand\";v=\"8\""],"User-Agent":["Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36"],"X-Forwarded-For":["109.190.181.174"],"Ngrok-Skip-Browser-Warning":["true"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Proto":["https"],"Sec-Fetch-Mode":["navigate"],"Accept-Language":["fr,en;q=0.9"],"Sec-Fetch-User":["?1"],"X-Forwarded-Host":["PUBLIC_BACK_END_FQDN"]},"tls":{"resumed":false,"version":772,"cipher_suite":4865,"proto":"h2","server_name":"PUBLIC_BACK_END_FQDN"}},"headers":{"Date":["Wed, 16 Oct 2024 21:33:39 GMT"],"Content-Type":["text/html"],"Ngrok-Error-Code":["ERR_NGROK_3200"],"Referrer-Policy":["no-referrer"]},"status":404}

3. Caddy version:

latest caddy docker image

4. How I installed and ran Caddy:

a. System environment:

docker

b. Command:

docker compose up

c. Service/unit/compose file:

services:
  caddy:
    image: caddy:2-alpine
    container_name: caddy
    restart: always
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - ./caddy_data:/data
      - ./caddy_config:/config
      - ./caddy_logs:/var/log/caddy
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"

  backend:
    build:
      context: .
      target: 'app_image'
    env_file: app_config
    container_name: backend
    logging:
      driver: local
      options:
        max-size: "100m"
        max-file: "10"
    restart: always
    volumes:
      - './app/:/app'
    environment:
      - LOG_LEVEL=info

d. My complete Caddy config:

{
	admin off
	email contact@customsbridge.fr
	log {
		output file /var/log/caddy/access.log {
			roll_size 200mb
			roll_keep 10
			roll_keep_for 365d
		}
    level DEBUG  # Set the log level to DEBUG for more verbose output
	}
}

# The caddy webserver will be listening on urls defined in env variable $SITE_ADDRESS
# This env variable is defined in the dockerfile at the root of the front code project, or defined in the init.sh script in deploy folder
{PUBLIC_BACK_END_FQDN} {

  # Match specific paths and proxy them to an external service
  @external_paths {
    path /docs* /redoc* /other_endpoint*
  }
  # Reverse proxy to external service and set necessary headers
  reverse_proxy @external_paths https://NGROK_URL {
    header_up Host NGROK_URL  # Rewrite the Host header for the upstream
    header_up ngrok-skip-browser-warning true  # Add custom header to skip ngrok browser warning
  }

  handle_errors {
    @ngrok_error {
      header Ngrok-Error-Code ERR_NGROK_3200  # Match Ngrok error in response header
    }
    reverse_proxy backend:80 #redirecting to backend in case no local dev computer is connected to Ngrok tunnel
  }


  # Default reverse proxy to backend for other routes
  reverse_proxy backend:80

	tls {
		protocols tls1.2 tls1.3
		ciphers TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256 TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256
	}

	encode zstd gzip

	header {
		Strict-Transport-Security max-age=63072000;
		X-Content-Type-Options nosniff
		X-Frame-Options DENY
		Content-Security-Policy "default-src 'self';
                             script-src 'report-sample' 'unsafe-inline' 'self';
                             style-src 'report-sample' 'unsafe-inline' 'self';
                             object-src 'none';
                             base-uri 'self';
                             connect-src 'self';
                             font-src 'self';
                             frame-src 'self';
                             img-src 'self' data:;
                             manifest-src 'self';
                             media-src 'self';
                             worker-src blob:;"

		X-XSS-Protection "1; mode=block"
		Permissions-Policy "accelerometer=(), autoplay=(), camera=(), cross-origin-isolated=(), display-capture=(), encrypted-media=(), fullscreen=(), geolocation=(), gyroscope=(), keyboard-map=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), screen-wake-lock=(), sync-xhr=(), usb=(), xr-spatial-tracking=()"

		Referrer-Policy no-referrer
		-Server
	}
}

5. Links to relevant resources:

Your config has messy indentation so it’s really difficult to follow. Please run caddy fmt -w to clean it up.

You have this in your global options. The log global option doesn’t configure access logs, it configures runtime logs. You probably should rename the file here.

You can use header_up Host {upstream_hostport} to avoid repeating the URL. See reverse_proxy (Caddyfile directive) — Caddy Documentation

Are you sure you need this? Caddy’s defaults are secure. Especially for protocols, re-stating the defaults is not useful because if later Caddy adds support for some theoretical TLS 1.4, your config would not support it, downgrading your server’s security.

Are you sure you need all this? Only set these if you understand what they do. These are typically application-layer concerns, so your app should be setting these appropriately based on the app’s needs.

Also, there’s no security benefit whatsoever to removing the Server header, it doesn’t reveal any useful information.

When the proxy upstream responds with a 4xx or 5xx status, that’s not an error, it’s still a valid HTTP response. handle_errors is not triggered for those, it only gets triggered for errors emitted within Caddy (e.g. failure to connect to the upstream, errors directive, etc etc).

What you’re looking for is handle_response inside of reverse_proxy. See reverse_proxy (Caddyfile directive) — Caddy Documentation and the examples at the bottom of the page.

Keep in mind though, if you’re making POST requests, this approach won’t work because the request body will have already been consumed by sending it to first upstream (the request body is a stream, it’s not buffered), so it will not make it upstream on the retry.

What you could do though is use forward_auth which doesn’t consume the body (under the hood it’s just a reverse_proxy with method GET set which tells it to not write the body upstream since GET should never have a body), and then depending on the result (ngrok error, or GET success) reverse_proxy again with the correct upstream.

4 Likes

Hello Francis.
I told my colleague “I’m sure I’ll get an answer quickly”, You seem to be sooooo reactive for an open source and free project, let me thank you ten times for taking such good care of us.

I’m under heavy load but I’ll answer your questions in a few days and try to explore the paths you proposed me.

5 Likes

Hello Francis.
So, I’ll do two answers, the first one to assess your remarks on my TLS and server config, and the second one for the proper problem.

  • log file. Thanks indeed, I renamed it for better understanding
  • upstream_hostport: Thanks for the tip
  • TLS: I used the Mozilla SSL configurator, intermediate option (because our partner needs TLS 1.2). in our front-end, the modern config is used, forcing TLS1.3
  • CSP, and other stuff: Simply trying to achieve best scores in mozilla observatory. Even though I know that it actualy does not demonstrate a strong IT/product security, for some clients, it is a reassuring thing a have a great grade on this platform given we don’t have yet ISO27001.
  • Removing the server Header: I case there’s a well known breach in either go or caddy, We though it would be interesting not to give any information about the webserver to the potential attacker. Of course watching for CVEs and frequently upgrading is the best practice - which we do.

Concerning the proper problem.
Thanks for your suggestion of forward_auth, indeed, the endpoints that will need to be directed to the back-end or the NGROK tunnel are actually POST calls. I then directly made some attempts with forward_auth

Here is my new Caddyfile (for simplicity’s sake I removed the log and TLS, headers stuff). I used this exact Caddyfile during my tests:

{PUBLIC_BACK_END_FQDN} {
        # Match specific paths to be handled
        @external_paths path /ping*

        route @external_paths {
                # Use forward_auth to try the NGROK tunnel without consuming the body
                forward_auth https://NGROK_URL {
                        uri {http.request.uri}
                        header_up Host {upstream_hostport} # Rewrite the Host header for the upstream
                        header_up ngrok-skip-browser-warning true # Add custom header to skip ngrok browser warning
                        copy_headers Ngrok-Error-Code # Copy the Ngrok-Error-Code header to the request

                        # On a successful response, copy response headers
                        @no-tunnel status 404
                        handle_response @no-tunnel {
                                reverse_proxy backend:80
                        }
                }

                # Match when authentication via NGROK fails (404). The use of the Ngrok-Error-Code header would be better.
                # @auth_error expression {http.auth.status_code} == 404

                # If NGROK attemp fails, it means there is probably no client connected, so we proxy the request to the backend
                # handle @auth_error {
                #         reverse_proxy backend:80
                # }

                # If no error occurs, proxy to NGROK
                # handle {
                #         reverse_proxy https://NGROK_URL {
                #                 header_up Host {upstream_hostport} # Rewrite the Host header for the upstream
                #                 header_up ngrok-skip-browser-warning true # Skip ngrok browser warning
                #         }
                # }
        }

        # Default reverse proxy to backend for other routes
        reverse_proxy backend:80
}

I tough I found the solution while adding the handle_response @no-tunnel in the forward_auth, but I actually discovered that this block only has an impact on the little GET that forward_auth is sending. So it doesn’t forward the REAL request when I want it to be

As you can see in the lower part of the file, I do have two commented handle blocks.
The @auth_error “matcher” actually never worked. Even when the tunnel is not available and the NGORK returns 404 in the auth request, I always get redirected to the NGROK url (through the second handle then I guess)

In the end, I still don’t succeed forwarding the inital request either in the tunnel (if auth success) or to the normal backend if it fails (status 404 of auth request and/or Ngrok-Error-Code ERR_NGROK_3200 header. Matching the header would be better than the status, but I can work with status).

So I discovered forward_auth but still don’t have any proper solution. Any tips/ ideas will be more than welcome

Don’t use that tool. Caddy’s defaults are strong and modern. It was extremely outdated for a long time (they just updated it this week ish though). There’s really no reason to touch TLS versions and ciphers unless you have a very specific need.

That should be handled in your app, really. Those are application-layer concerns, not really the webserver’s responsibility. If you were to move to a different reverse proxy than Caddy, best if you app already set the correct headers for itself.

There is no useful information at all in that header. An attacker simply knowing “it’s Caddy” tells them nothing useful. They can figure that out anyway via a variety of other means, including watching byte layout and timing patterns. Even then, 99.99% of the time they’ll just iterate through every known attack anyway. It does not save them any time at all to have the Server header there. Pure FUD.

I assume you’ll want to also check for that error code header, otherwise legit 404s from your app might be retried. You can match both status and header with that matcher, just use { } braces to use both (ANDed together)

Yeah like I said, forward_auth would just be to test “can I use that upstream” and if it returns a clean response (without the 404 + error header) then you can handle_response to actually make the real request to ngrok. So like

forward_auth https://NGROK_URL {
	header_up Host {upstream_hostport}
	header_up ngrok-skip-browser-warning true

	@no-tunnel {
		status 404
		header Ngrok-Error-Code ERR_NGROK_3200
	}
	handle_response @no-tunnel {
		reverse_proxy backend:80
	}

	handle_response {
		reverse_proxy https://NGROK_URL {
			header_up Host {upstream_hostport}
			header_up ngrok-skip-browser-warning true
		}
	}
}

Something like that.

2 Likes

Thanks a lot, I can feel we’re close to it.

I’ve used your code:

The switch from backend to tunnel work well depending on the tunnel availability (@no-tunnel), however I have a strange behaviour while getting the answer from the tunnel.

The endpoint is ping (GET), which gives a “pong” answer.
When the tunnel is not here, and then caddy forward to backend, I do have the proper answer, with “pong” body.

However when I go trough the tunnel (second handler) I do get a 200 on my ping call, but the answer body is empty.
Since the tunnel termination is the docker container running on my machine, I did try the direct ping call, I get pong. I also tried to call the tunnel url, I also get a pong.
Then I just watched the logs of Ngrok, and it seems he sees the complete answer:

So based of that observation, it seems Caddy is stripping out the answer’s body. I’ve been trying tens of things since you answered me, but I don’t understand in which world Caddy would strip the body in the decond handler, and not in the first one …

uhhhhhh might need to do this (add the handle_response + copy_response in it)

forward_auth https://NGROK_URL {
	header_up Host {upstream_hostport}
	header_up ngrok-skip-browser-warning true

	@no-tunnel {
		status 404
		header Ngrok-Error-Code ERR_NGROK_3200
	}
	handle_response @no-tunnel {
		reverse_proxy backend:80 {
			handle_response {
				copy_response
			}
		}
	}

	handle_response {
		reverse_proxy https://NGROK_URL {
			header_up Host {upstream_hostport}
			header_up ngrok-skip-browser-warning true
			handle_response {
				copy_response
			}
		}
	}
}

It’s because the parent forward_auth sets some stuff in the request context, then the followup reverse_proxy still thinks it’s inside of a handle_response (cause it is, but not its own context) so it has the behaviour of skipping the response body :man_facepalming:

1 Like

Thanks for the snap answer.
So, I added these, and it change nothing. I still have the same behavior:
I do have the response body with the first handle_response @no-tunnel
I don’t have the response body with the second handle_response.

If too nasty and you need more context to dig why it’s doing this, I can set up a github project with all the context (with a server and the tunnel) so that you can test ? let me know

Bah. I’ll need to play around with it later, I’m sure I can replicate it, should be easy enough.

2 Likes

Wonderful.
let me know if I can be of any assistance.
I will need to find a solution within two weeks, if I cannot get things sorted out with caddy, I’ll try to find an other solution without caddy.

Sorry for the delay on this :see_no_evil: I sorta forgot about this thread and it fell off my radar since last week.

Okay I got it working I think. This config is the testcase basically.

:8881 {
	reverse_proxy :8882 {
		method GET
		header_up ngrok-skip-browser-warning true

		@no-tunnel {
			status 404
			header Ngrok-Error-Code ERR_NGROK_3200
		}
		handle_response @no-tunnel {
			reverse_proxy :8883
		}

		handle_response {
			reverse_proxy :8882 {
				header_up ngrok-skip-browser-warning true
			}
		}
	}
}

:8882 {
	handle /nope {
		header Ngrok-Error-Code ERR_NGROK_3200
		respond 404
	}
	respond "Hello 8882 (ngrok): {http.request.body}" 200
}

:8883 {
	respond "Hello 8883: {http.request.body}" 200
}

I think the key is not using forward_auth cause it has its own assumptions, but instead just using reverse_proxy with method GET and then it should work?

Requests to test:

$ curl -v http://localhost:8881 --data 'HERES A PAYLOAD'
Hello 8882 (ngrok): HERES A PAYLOAD

$ curl -v http://localhost:8881/nope --data 'HERES A PAYLOAD'
Hello 8883: HERES A PAYLOAD

So when you hit /nope it “acts like ngrok is dead” and instead serves from :8883.

That is what you want to do, correct?

2 Likes

Hello Francis.

That solves completely the issue. Thanks a lot !

1 Like