Making Caddy Invisible to Port Scanners

1. The problem I’m having:

I want to make caddy invisible to port scanners. On shodan, only port 80 is being shown as active, but on sites that check if my ports are open it says both 80 and 443 are open. I want to make it so that if a request is sent to anything other than a subdomain caddy just doesn’t do anything (to make it look like there’s nothing on the port). Currently I’m using abort on requests to :80 and :443 but that only makes shodan report “No data returned” while claiming only port 80 is open. I know security through obscurity is a bad idea, but this will at least take a huge target off my back.

2. Error messages and/or full log output:

n/a

3. Caddy version:

v2.9.0-beta.3

4. How I installed and ran Caddy:

Installed/ran caddy through docker-compose and Caddyfile

a. System environment:

Raspberry Pi 5 running latest version of Raspbian

b. Command:

docker compose up

c. Service/unit/compose file:

services:
  caddy:
    build: .
    restart: unless-stopped
    ports:
      - 80:80
      - 443:443
      - 443:443/udp
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - ./site:/srv
      - caddy_data:/data
      - caddy_config:/config
    networks:
      - caddyout
volumes:
  caddy_data: null
  caddy_config: null
networks:
  caddyout:
    external: true

d. My complete Caddy config:

{
     servers {
         trusted_proxies static 192.168.0.0/24
     }
}
(authentikgate) {
     header /* {
         -Server
     }
     reverse_proxy /outpost.goauthentik.io/* http://authentikserver:9000
     forward_auth http://authentikserver:9000 {
         uri /outpost.goauthentik.io/auth/caddy
         copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Entitlements X-Authentik-Email X-Authentik-Name X-Authentik-Uid X-Authentik-Jwt X-Authenti>
     }
}
internal.redacted.com {
     route /authtest {
         import authentikgate
         respond "You are authorized"
     }
     respond "Hello, world!"
}
freshrss.redacted.com {
     route {
         import authentikgate
         reverse_proxy freshrss:80
     }
     route /api* {
         reverse_proxy freshrss:80
     }
}
authorize.redacted.com {
     reverse_proxy authentikserver:9000
}
adguard.redacted.com {
     route {
         import authentikgate
         reverse_proxy adguardhome:81
     }
}
uploader.redacted.com {
     encode gzip
     route {
         import authentikgate
         reverse_proxy zipline:3000
     }
}
ai.redacted.com {
     route {
         import authentikgate
         reverse_proxy 192.168.0.39:3000 {
             header_up Host {host}
             header_up X-Real-IP {remote}
             header_up X-Forwarded-Proto {scheme}
             header_up X-Forwarded-For {remote}
         }
     }
}
jellyfin.redacted.com {
     route {
         import authentikgate
         reverse_proxy 192.168.0.39:8096
     }
}
:80, :443 {
     abort
}

5. Links to relevant resources:

You can’t hide it. For TLS SNI to be sent to Caddy, a TCP connection must first be established, with a SYN/ACK packet exchange. At that point, it’s already too late to hide port 443.

Similarly, for port 80, by the time the Host header is sent (so Caddy can decide which virtual host to serve), you already have a full TCP connection established.

A simple nmap TCP SYN or even a TCP connect() scan can reveal that something is listening on those ports.

You could consider using port-knocking techniques, where your firewall drops everything directed at a specific port unless it first receives requests (or “knocks”) in a predefined sequence of ports. However, that adds complexity, and regular users likely won’t know how to “knock” beforehand.

What you can do—and what I do—is exactly what the abort example in the link you shared demonstrates.

*.example.com {
    @foo host foo.example.com
    handle @foo {
        respond "This is foo!" 200
    }

    handle {
		# Unhandled domains fall through to here,
		# but we don't want to accept their requests
        abort
    }
}

I use a wildcard DNS entry to point a wildcard A record to my Caddy server. I also use a wildcard certificate, so all the virtual hosts currently in your Caddyfile wouldn’t appear in certificate transparency logs (CTL). When virtual hosts get their own certificates with their names on them, those names are logged in the CTL, and anyone can look them up.

By following the approach in the abort example, you at least minimize the breadcrumbs left behind as much as possible.

2 Likes

Thanks, I have a Unifi Dream Machine so I’ll see if there’s some way to drop connections that aren’t to a subdomain (as it’s fully capable of seeing where requests are supposed to go to)

Your UDM can see where the traffic is going because it simply observes the SNI field in the TLS handshake, which contains the unencrypted name of your virtual host. This makes it easy to sniff.

Unless your UDM is configured to make all its ports appear open to mask the actual open ones, it will be straightforward to detect that something is running on ports 80 and 443. This ties back to the reasons I explained in my previous comment.

1 Like