Skip to content

feat: add tarpit using http headers#126

Draft
nnathan wants to merge 1 commit into
JasonLovesDoggo:mainfrom
nnathan:header_tarpit
Draft

feat: add tarpit using http headers#126
nnathan wants to merge 1 commit into
JasonLovesDoggo:mainfrom
nnathan:header_tarpit

Conversation

@nnathan
Copy link
Copy Markdown

@nnathan nnathan commented Dec 30, 2025

This is an attempt to implement http header tarpit per discussion in #123.

This is implemented using the http.Hijacker, it writes the http/response code status header, then any subsequent custom headers specified in the defender config, both are immediately have flushed writes, followed successive tarpit headers interspersed by a custom delay specified in the config (defaults to 4 seconds).

I'm not sure if lifecycle of the connection is handled correctly; for example what if you set the timeout to 900s, and the connection disconnects after say 100s, does the handler have to wait for the timeout, or does the Go know to cancel the goroutine accordingly. However, the cost of holding the connection isn't such a big deal until timeout happens, but it is something that I was a bit iffy about.

Love to hear any feedback. I'm also not sure how to approach testing. Should a test of say a timeout of 5 seconds, with a 2 second per header, and throw in a few custom headers, and verify the transcript:

HTTP/1.1 200 OK
X-Custom: 1
X-Custom: 2
X-Custom: 3
X-Custom: 4
X-$RAND: $RAND
X-$RAND: $RAND

Example CaddyFile:

{
        order defender after header
        debug
}

http://127.0.0.1:1080 {
        defender header_tarpit {
                ranges all
                header_tarpit_config {
                        headers {
                                X-Testing fool
                        }
                        header_per_second 3
                        timeout 10s
                        response_code 403
                }
        }
}

And an example run:

Mac:caddy-defender $ date; curl -v http://127.0.0.1:1080/; date
Wed 31 Dec 2025 07:08:15 AEDT
*   Trying 127.0.0.1:1080...
* Connected to 127.0.0.1 (127.0.0.1) port 1080
> GET / HTTP/1.1
> Host: 127.0.0.1:1080
> User-Agent: curl/8.7.1
> Accept: */*
>
* Request completely sent off
< HTTP/1.1 403 Forbidden
< X-Testing: fool
< X-503ce4935b4478fe: 0b34a3661f481ab3
< X-053a04041beb5e0d: 65af1ca3fda1219b
< X-ae13efe8e0641b90: f69ac2467e9657c8
* Connection #0 to host 127.0.0.1 left intact
Wed 31 Dec 2025 07:08:25 AEDT

@nnathan
Copy link
Copy Markdown
Author

nnathan commented Dec 30, 2025

Damn, just tested with https and I get the following:

root@t:~# curl https://my-vps-server.net/
webserver doesn't support hijacking

I think I may need to approach this from a different way.

I think a TLS hijack is not possible since Caddy will handle the TLS connection setup from its side, but when we get to the handler, we don't have the TLS context to write TLS record frames with the data that we want to write. So there is a mismatch and Go's opinion here is that we can't do that.

I can't seem to find an approach to do this within a HTTP handler either. ResponseWriter.Write always calls ResponseWriter.WriteHeader, which means the headers are written immediately, so that Write only deals with writing body. If you try to do RW.Headers().{Write,Flush} they both will force a WriteHeader as well if one hasn't been sent. So this makes things kind of annoying.

@JasonLovesDoggo
Copy link
Copy Markdown
Owner

Damn, just tested with https and I get the following:

root@t:~# curl https://my-vps-server.net/
webserver doesn't support hijacking

I think I may need to approach this from a different way.

I think a TLS hijack is not possible since Caddy will handle the TLS connection setup from its side, but when we get to the handler, we don't have the TLS context to write TLS record frames with the data that we want to write. So there is a mismatch and Go's opinion here is that we can't do that.

I can't seem to find an approach to do this within a HTTP handler either. ResponseWriter.Write always calls ResponseWriter.WriteHeader, which means the headers are written immediately, so that Write only deals with writing body. If you try to do RW.Headers().{Write,Flush} they both will force a WriteHeader as well if one hasn't been sent. So this makes things kind of annoying.

Seems fine to use Hijacker in responders. See https://github.com/caddyserver/caddy/blob/4babe4b201eef3e9794851b74f734a03338f7a20/caddyhttp/proxy/reverseproxy.go#L241
Do note that http2 doesn't support http hijacking - see golang/go#46319

I'll try to take a look at this within the next few days.

@nnathan
Copy link
Copy Markdown
Author

nnathan commented Dec 31, 2025

Do note that http2 doesn't support http hijacking - see golang/go#46319

Thanks for this, I now have:

{
        order defender after header
        debug

        servers {
                protocols h1
        }
}

And it works as intended over https sites.

Unfortunately it has to be a global option it seems rather than a per site directive. But I'm happy with that.

@JasonLovesDoggo
Copy link
Copy Markdown
Owner

Do note that http2 doesn't support http hijacking - see golang/go#46319

Thanks for this, I now have:

{
        order defender after header
        debug

        servers {
                protocols h1
        }
}

And it works as intended over https sites.

Unfortunately it has to be a global option it seems rather than a per site directive. But I'm happy with that.

Amazing. I don't think http header tarpitting is possible on h2 given how it compresses frames and multiplexes streams over a single TCP connection - you can't hijack without breaking all other active requests on that connection.

For now, forcing protocols h1 globally works fine since tarpit is meant for hostile traffic anyway. Though if you can add a check in Provision() that fails on startup if HTTP/2 is enabled, so users get a clear error message instead of runtime failures. Something like:

if len(protocols) == 0 || contains(protocols, "h2") || contains(protocols, "h2c") {
    return fmt.Errorf("header_tarpit requires HTTP/1.1 only - add 'protocols h1' to your server block. See: https://caddy.community/t/help-force-http-1-1-on-a-wild-card/19978")
}

That way it's explicit and documented upfront. Besides that + docs this looks good to me.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants