Subdomains separating public and private services

1. The problem I’m having:

I’m trying to separate out public and private services reverse-proxied behind Caddy. Public services should be accessible over the Internet, while private services should only be accessible via a VPN connection.

In the example configuration below, assume the following:

  • I’ve set public.example.com’s DNS entry to the public IP of the server (say 123.123.123.123)
  • I’ve set private.example.com’s DNS entry to the private VPN IP of the server (say 10.0.20.124)
  • All wildcard subdomains map to the subdomain one level above with CNAME records
  • All DNS entries have been published publicly

I am guessing that service C in the example configuration cannot be accessed through the Internet? Is there a way a malicious user could trick Caddy into exposing/serving service C over the public portion of the server? Say for example, the client makes a connection to public.example.com but somehow tricks Caddy into believing the requested host name is servicec.private.example.com?

2. Error messages and/or full log output:

N/A

3. Caddy version:

v2.6.4 h1:2hwYqiRwk1tf3VruhMpLcYTg+11fCdr8S3jhNAdnPy8=

4. How I installed and ran Caddy:

a. System environment:

Docker on unRAID 6.11.5

b. Command:

N/A, self-made Docker image deployed to server

c. Service/unit/compose file:

N/A

d. My complete Caddy config:

*.public.example.com {
        @servicea host servicea.public.example.com
        handle @servicea {
                reverse_proxy servicea:1234
        }
        @serviceb host serviceb.public.example.com
        handle @serviceb {
                reverse_proxy serviceb:443
        }
}
*.private.example.com {
        @serviceb host serviceb.private.example.com
        handle @serviceb {
                reverse_proxy serviceb:443
        }
        @servicec host servicec.private.example.com
        handle @servicec {
                reverse_proxy servicec:9999
        }
}

5. Links to relevant resources:

Malicious users can send the Host: serviceb.private.example.com header to any IP address they choose, so it’s still possible to access the private host names from the public IP address.

One way to do that with an unaltered browser is to simply map your private host names to your server’s public IP using hosts.txt or their local recursive DNS server. The first can be done with Notepad, so this doesn’t even require deep technical knowledge, so this just requires (a) knowing about hosts.txt (i.e. the ability to Google) and (b) knowing which host names your server is configured to respond to.

If the public IP is bound directly to the server (not via NAT mapping or something), use the bind directive to restrict your private host names to respond only on your private IP.

In other words, putting bind 10.0.20.124 in your *.private.example.com block should ensure they are not accessible unless the client connects to that IP address, but that only works if the public IP isn’t NAT-mapped to the private IP (because in that case all connections look like they come in on the private IP).

If you do have the public IP NAT-mapped to the private IP, you can just add another private-range IP to your server. That’s probably easiest with IPv6, which is required to support multiple IPs per interface. So add an ULA IP (any random IP in fd00::/8 will do, as long as it’s unique in your network) or use the link-local IPv6 (the one that starts with fe80:).

2 Likes

@fvbommel so I just tested it myself and it does seem like a malicious user could simply open up any private service by modifying the Host header.

Unfortunately, bind is not working for me because I’m running Caddy inside of Docker. When I try and bind to the VPN IP I get the following error:

Error: loading initial config: loading new config: http app module: start: listening on 10.0.20.124:443 The  bind: cannot assign requested address

Is there any other way to separate the services other than bind?

Edit: solved it by using the Matcher to disallow non-VPN IPs. See: Only allow certain IPs to access the server in reverse proxy - #2 by francislavoie

I’ll still check @fvbommel 's reply as the solution since I didn’t know HTTP Host headers could be forged in that manner. Thanks.

1 Like

I see you’ve marked my previous reply as a solution, but since I spent some time figuring this out I’ll post it anyway.

You can listen to different ports in the container, and have Docker bind to the interfaces for you, forwarding to the correct port depending on the interface.

A simple HTTP-only example follows. Replace $PUBLIC_IP and $PRIVATE_IP with the respective values (or define them in a .env file).

docker-compose.yaml:

version: "3.7"

services:
  caddy:
    image: caddy:2
    volumes:
      - ./caddy/Caddyfile:/etc/caddy/Caddyfile
      - ./caddy/data:/data
      - ./caddy/config:/config
    restart: unless-stopped
    ports:
      - "$PUBLIC_IP:80:80" # Bind IPv4 address to port 80
      - "$PRIVATE_IP:80:81" # Bind private address to port 81

  whoami-public:
    image: traefik/whoami
    hostname: whoami-public
    restart: unless-stopped

  whoami-private:
    image: traefik/whoami
    hostname: whoami-private
    restart: unless-stopped

caddy/Caddyfile:

# Public server listens on port 80
http://public:80 {
	reverse_proxy http://whoami-public
}

# Private server listens on port 81
http://private:81 {
	reverse_proxy http://whoami-private
}

@fvbommel I guess that would work if I flipped it around and made the public server listen on port 81, then had the router forward port 80 requests to 81. In my case I just wanted the simplicity of having Caddy listen on one port (and also my server OS, unRAID, doesn’t support Docker Compose to the best of my knowledge).

Also, I ended up using two Caddy instances, because unless Caddy is running in the host network mode it thinks all clients connect from the Docker internal IP. So I do the IP filtering first on the outer Caddy instance before passing it through to the internal Caddy server.

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