Add a customizable HTTP/HTTPS block page for sinkholed queries
Serves a page explaining why a domain was blocked instead of leaving a sinkholed client with a dead connection. Binds its own HTTP/HTTPS listeners with self-signed, per-hostname TLS certs generated on the fly, re-evaluates the requesting client against the policy engine per request, and renders an HTML template editable from Settings with a live preview. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TTKpGMQzpfDsvedu1hvSUf
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
package blockpage
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func discardLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
func TestRenderFillsPlaceholders(t *testing.T) {
|
||||
out, err := Render(`<h1>{{.Domain}} blocked by {{.ListName}} ({{.PolicyName}})</h1>`)
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
if !strings.Contains(out, "ads.example-tracker.com blocked by Advertising (Default Protection)") {
|
||||
t.Errorf("unexpected output: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRejectsInvalidTemplate(t *testing.T) {
|
||||
if _, err := Render(`{{.Domain`); err == nil {
|
||||
t.Fatal("expected a parse error for unterminated action")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderEscapesAttackerControlledDomain(t *testing.T) {
|
||||
// html/template must escape a hostile-looking hostname; a browser-facing
|
||||
// error page has no business ever emitting attacker HTML unescaped.
|
||||
out, err := Render(`<p>{{.Domain}}</p>`)
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
if strings.Contains(out, "<script>") {
|
||||
t.Errorf("output contains an unescaped tag: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertStoreGeneratesMatchingSAN(t *testing.T) {
|
||||
store := newCertStore()
|
||||
cert, err := store.get(&tls.ClientHelloInfo{ServerName: "blocked.example.com"})
|
||||
if err != nil {
|
||||
t.Fatalf("get certificate: %v", err)
|
||||
}
|
||||
leaf, err := parseLeaf(cert)
|
||||
if err != nil {
|
||||
t.Fatalf("parse leaf: %v", err)
|
||||
}
|
||||
if len(leaf.DNSNames) != 1 || leaf.DNSNames[0] != "blocked.example.com" {
|
||||
t.Errorf("DNSNames = %v, want [blocked.example.com]", leaf.DNSNames)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertStoreCachesByHostname(t *testing.T) {
|
||||
store := newCertStore()
|
||||
a, err := store.get(&tls.ClientHelloInfo{ServerName: "a.example.com"})
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
b, err := store.get(&tls.ClientHelloInfo{ServerName: "a.example.com"})
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if a != b {
|
||||
t.Error("expected the same cached certificate for a repeated hostname")
|
||||
}
|
||||
|
||||
c, err := store.get(&tls.ClientHelloInfo{ServerName: "b.example.com"})
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if a == c {
|
||||
t.Error("expected a different certificate for a different hostname")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerFallsBackOnBrokenTemplate(t *testing.T) {
|
||||
s := &Server{log: discardLogger()}
|
||||
tmpl := s.compiled(`{{.Domain`)
|
||||
var b strings.Builder
|
||||
if err := tmpl.Execute(&b, pageData{}); err != nil {
|
||||
t.Fatalf("execute fallback: %v", err)
|
||||
}
|
||||
if !strings.Contains(b.String(), "misconfigured") {
|
||||
t.Errorf("expected the fallback page, got: %s", b.String())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user