Files
warmbly/internal/api/handler/tls_authorize.go
T

167 lines
4.9 KiB
Go

package handler
import (
"net/http"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/warmbly/warmbly/internal/config"
)
// AuthorizeTLSDomain answers Caddy's on-demand TLS `ask` probe.
//
// GET /tls/authorize?domain=<host> -> 204 issue | 403 refuse
//
// A self-hosted instance's reverse proxy has a site block per hostname it was
// installed with, so a workspace that verifies a custom tracking domain
// (t.acme.com CNAME -> track.selfhost.com) had no certificate for it and every
// tracked link, and every opt-out link riding the same host, failed TLS.
// Verification proves DNS, not that the host can terminate it. This is the
// other half: Caddy asks here before obtaining a certificate for a name it has
// never seen, and the answer is yes exactly for the names this instance
// actually serves.
//
// Unauthenticated on purpose. Caddy has no credential to present, and the
// answer discloses nothing: the CNAME that makes a domain interesting already
// points at this machine in public DNS.
//
// Deliberately gated on *verified* only. An unverified domain is never built
// into a link, so nothing legitimate ever asks for it, and verification is a
// DNS check that completes without a certificate, so gating this way cannot
// deadlock against issuance.
func (h *Handler) AuthorizeTLSDomain(c *gin.Context) {
host := config.NormalizeTrackingHost(c.Query("domain"))
if host == "" || !plausibleHostname(host) {
c.Status(http.StatusForbidden)
return
}
// This instance's own recipient-facing hosts. They normally have their own
// site block, but answering for them keeps the endpoint's answer equal to
// "does this instance serve that name" rather than a subset of it.
if host == config.TrackingHostname() || host == config.FormsHostname() {
c.Status(http.StatusNoContent)
return
}
if allow, ok := tlsDomainCache.get(host); ok {
writeTLSDecision(c, allow)
return
}
if h.CustomDomains == nil {
c.Status(http.StatusForbidden)
return
}
allow, err := h.CustomDomains.IsVerified(c.Request.Context(), host)
if err != nil {
// Refusing is the safe direction and is not sticky: Caddy retries on
// the next handshake, and a cached "no" from a transient database
// error would outlive the error itself.
c.Status(http.StatusForbidden)
return
}
tlsDomainCache.put(host, allow)
writeTLSDecision(c, allow)
}
func writeTLSDecision(c *gin.Context, allow bool) {
if allow {
c.Status(http.StatusNoContent)
return
}
c.Status(http.StatusForbidden)
}
// plausibleHostname rejects what cannot be a domain name before any database
// work. Most of what a scanner puts in SNI dies here.
func plausibleHostname(host string) bool {
if len(host) > 253 || !strings.Contains(host, ".") {
return false
}
for _, label := range strings.Split(host, ".") {
if label == "" || len(label) > 63 {
return false
}
for i, r := range label {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
continue
}
// A hyphen is legal inside a label, never at either end.
if r == '-' && i > 0 && i < len(label)-1 {
continue
}
return false
}
}
return true
}
// tlsDomainCache keeps the gate off the database on the repeat handshakes a
// single name produces. There is deliberately no miss budget on top of it: the
// TLS handshake Caddy is already performing costs more than this indexed
// lookup, so a budget would not be the thing that saves the instance, and it
// would give anyone spraying SNI a way to stop a real customer's first
// certificate from ever being issued.
var tlsDomainCache = &domainCache{
entries: map[string]domainDecision{},
allowed: 10 * time.Minute,
refused: time.Minute,
max: 8192,
}
type domainDecision struct {
allow bool
expires time.Time
}
type domainCache struct {
mu sync.Mutex
entries map[string]domainDecision
// A refusal is held briefly and an approval for longer: a domain that has
// just verified should get its certificate on the next handshake rather
// than after the customer waits out a cached no.
allowed time.Duration
refused time.Duration
max int
}
func (d *domainCache) get(host string) (bool, bool) {
d.mu.Lock()
defer d.mu.Unlock()
e, ok := d.entries[host]
if !ok || time.Now().After(e.expires) {
return false, false
}
return e.allow, true
}
func (d *domainCache) put(host string, allow bool) {
ttl := d.refused
if allow {
ttl = d.allowed
}
d.mu.Lock()
defer d.mu.Unlock()
if len(d.entries) >= d.max {
now := time.Now()
for k, e := range d.entries {
if now.After(e.expires) {
delete(d.entries, k)
}
}
// Still full means the cache is being sprayed rather than used. Drop
// it whole: bounded memory matters more than the hit rate, and the
// names worth keeping are re-learned on their next handshake.
if len(d.entries) >= d.max {
d.entries = map[string]domainDecision{}
}
}
d.entries[host] = domainDecision{allow: allow, expires: time.Now().Add(ttl)}
}