406 lines
14 KiB
Go
406 lines
14 KiB
Go
package endpoint
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"net"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
type sent struct {
|
|
from string
|
|
to []string
|
|
msg string
|
|
}
|
|
|
|
func setup(t *testing.T, extraSite string) (*Server, *[]sent, string) {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
siteDir := filepath.Join(dir, "site")
|
|
os.MkdirAll(filepath.Join(siteDir, "forms"), 0o755)
|
|
os.WriteFile(filepath.Join(siteDir, "site.yaml"), []byte("name: Example\nurl: https://example.org\n"), 0o644)
|
|
os.WriteFile(filepath.Join(siteDir, "forms", "contact.yaml"), []byte(`to: [email protected]
|
|
subject: "Contact from {{ .name }}"
|
|
reply_to: email
|
|
store: true
|
|
fields:
|
|
- { name: name, required: true }
|
|
- { name: email, type: email, required: true }
|
|
- { name: message, type: textarea, required: true }
|
|
`), 0o644)
|
|
pub := filepath.Join(dir, "public")
|
|
os.MkdirAll(filepath.Join(pub, "articles"), 0o755)
|
|
os.WriteFile(filepath.Join(pub, "articles", "search.json"), []byte(`[
|
|
{"u":"/articles/a/","t":"Mail servers at home","d":"2026-10-01","g":[["mail","/tags/mail/"]],"x":"Running a mail server. Mail mail mail."},
|
|
{"u":"/articles/b/","t":"Kubernetes","d":"2026-09-01","x":"Nothing about it."},
|
|
{"u":"/articles/c/","t":"Notes","d":"2026-08-01","x":"A mail server, once."}]`), 0o644)
|
|
cfg := `listen: 127.0.0.1:0
|
|
trusted_proxies: [127.0.0.1/32]
|
|
smtp: { host: 127.0.0.1, port: 2525, from: "Website <[email protected]>", tls: none }
|
|
rate_limit: { count: 3, window: 1m }
|
|
sites:
|
|
- { dir: site, public: public, store: store }
|
|
` + extraSite
|
|
os.WriteFile(filepath.Join(dir, "endpoint.yaml"), []byte(cfg), 0o644)
|
|
t.Setenv("HOTDOG_FORM_SECRET", strings.Repeat("k", 40))
|
|
s, err := New(filepath.Join(dir, "endpoint.yaml"), os.Stderr)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var mails []sent
|
|
var mu sync.Mutex
|
|
s.mail.send = func(addr, from string, to []string, msg []byte) error {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
mails = append(mails, sent{from, to, string(msg)})
|
|
return nil
|
|
}
|
|
return s, &mails, dir
|
|
}
|
|
|
|
func token(t *testing.T, s *Server) string {
|
|
t.Helper()
|
|
req := httptest.NewRequest("GET", "http://example.org/_hotdog/forms/contact/token", nil)
|
|
rec := httptest.NewRecorder()
|
|
s.ServeHTTP(rec, req)
|
|
var d map[string]string
|
|
json.NewDecoder(rec.Body).Decode(&d)
|
|
return d["token"]
|
|
}
|
|
|
|
func post(s *Server, v url.Values, origin, accept string, ip string) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequest("POST", "http://example.org/_hotdog/forms/contact", strings.NewReader(v.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
if origin != "" {
|
|
req.Header.Set("Origin", origin)
|
|
}
|
|
if accept != "" {
|
|
req.Header.Set("Accept", accept)
|
|
}
|
|
if ip != "" {
|
|
req.RemoteAddr = ip + ":5000"
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
s.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
func TestSubmitFlow(t *testing.T) {
|
|
s, mails, dir := setup(t, "")
|
|
start := time.Now()
|
|
s.now = func() time.Time { return start }
|
|
tok := token(t, s)
|
|
good := url.Values{"name": {"Ada"}, "email": {"[email protected]"}, "message": {"Hello"}, "_t": {tok}, "_website": {""}}
|
|
|
|
if rec := post(s, good, "https://example.org", "application/json", "203.0.113.1"); rec.Code != 403 || !strings.Contains(rec.Body.String(), "retoken") {
|
|
t.Fatalf("instant submission: %d %s", rec.Code, rec.Body)
|
|
}
|
|
s.now = func() time.Time { return start.Add(5 * time.Second) }
|
|
if rec := post(s, good, "https://evil.example", "application/json", "203.0.113.2"); rec.Code != 403 {
|
|
t.Fatalf("cross-site post: %d", rec.Code)
|
|
}
|
|
if rec := post(s, good, "", "application/json", "203.0.113.2"); rec.Code != 403 {
|
|
t.Fatalf("post without Origin: %d", rec.Code)
|
|
}
|
|
forged := url.Values{}
|
|
for k, v := range good {
|
|
forged[k] = v
|
|
}
|
|
forged.Set("_t", "v1.1."+strings.Repeat("A", 43))
|
|
if rec := post(s, forged, "https://example.org", "application/json", "203.0.113.3"); rec.Code != 403 {
|
|
t.Fatalf("forged token: %d", rec.Code)
|
|
}
|
|
if rec := post(s, good, "https://www.example.org", "application/json", "203.0.113.4"); rec.Code != 200 {
|
|
t.Fatalf("good submission: %d %s", rec.Code, rec.Body)
|
|
}
|
|
if len(*mails) != 1 {
|
|
t.Fatalf("%d mails sent", len(*mails))
|
|
}
|
|
m := (*mails)[0]
|
|
for _, want := range []string{"To: <[email protected]>", "Reply-To: <[email protected]>", "Subject: Contact from Ada", "Name: Ada"} {
|
|
if !strings.Contains(m.msg, want) {
|
|
t.Errorf("mail is missing %q:\n%s", want, m.msg)
|
|
}
|
|
}
|
|
stored, err := os.ReadFile(filepath.Join(dir, "store", "contact.jsonl"))
|
|
if err != nil || !strings.Contains(string(stored), `"name":"Ada"`) {
|
|
t.Errorf("not stored: %v %s", err, stored)
|
|
}
|
|
if fi, _ := os.Stat(filepath.Join(dir, "store", "contact.jsonl")); fi.Mode().Perm() != 0o600 {
|
|
t.Errorf("store file is %v, want 0600", fi.Mode().Perm())
|
|
}
|
|
|
|
// The trap field: answered as success, nothing sent.
|
|
trap := url.Values{}
|
|
for k, v := range good {
|
|
trap[k] = v
|
|
}
|
|
trap.Set("_website", "http://spam.example")
|
|
if rec := post(s, trap, "https://example.org", "application/json", "203.0.113.5"); rec.Code != 200 || len(*mails) != 1 {
|
|
t.Fatalf("trap field: %d, %d mails", rec.Code, len(*mails))
|
|
}
|
|
// Header injection through a single-line field.
|
|
inj := url.Values{}
|
|
for k, v := range good {
|
|
inj[k] = v
|
|
}
|
|
inj.Set("name", "Ada\r\nBcc: [email protected]")
|
|
if rec := post(s, inj, "https://example.org", "application/json", "203.0.113.6"); rec.Code != 400 || len(*mails) != 1 {
|
|
t.Fatalf("header injection: %d, %d mails", rec.Code, len(*mails))
|
|
}
|
|
// Without JavaScript: an HTML page, not JSON.
|
|
if rec := post(s, inj, "https://example.org", "text/html", "203.0.113.7"); rec.Code != 400 || !strings.Contains(rec.Header().Get("Content-Type"), "text/html") {
|
|
t.Fatalf("html reply: %d %s", rec.Code, rec.Header().Get("Content-Type"))
|
|
}
|
|
}
|
|
|
|
func TestRateLimitAndProxy(t *testing.T) {
|
|
s, _, _ := setup(t, "")
|
|
start := time.Now()
|
|
s.now = func() time.Time { return start }
|
|
tok := token(t, s)
|
|
s.now = func() time.Time { return start.Add(5 * time.Second) }
|
|
v := url.Values{"name": {"Ada"}, "email": {"[email protected]"}, "message": {"Hi"}, "_t": {tok}}
|
|
codes := []int{}
|
|
for i := 0; i < 4; i++ {
|
|
// From the trusted proxy, for the same visitor.
|
|
req := httptest.NewRequest("POST", "http://example.org/_hotdog/forms/contact", strings.NewReader(v.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.Header.Set("Origin", "https://example.org")
|
|
req.Header.Set("X-Forwarded-For", "198.51.100.9")
|
|
req.RemoteAddr = "127.0.0.1:4000"
|
|
rec := httptest.NewRecorder()
|
|
s.ServeHTTP(rec, req)
|
|
codes = append(codes, rec.Code)
|
|
}
|
|
if codes[3] != 429 || codes[0] != 200 {
|
|
t.Fatalf("rate limit codes %v", codes)
|
|
}
|
|
// A client that isn't the proxy can't pick its own address.
|
|
req := httptest.NewRequest("POST", "http://example.org/", nil)
|
|
req.RemoteAddr = "203.0.113.50:1"
|
|
req.Header.Set("X-Forwarded-For", "1.2.3.4")
|
|
if ip := s.clientIP(req); ip != "203.0.113.50" {
|
|
t.Fatalf("spoofed X-Forwarded-For believed: %s", ip)
|
|
}
|
|
}
|
|
|
|
func TestSearchAndHosts(t *testing.T) {
|
|
s, _, _ := setup(t, "")
|
|
req := httptest.NewRequest("GET", "http://example.org/_hotdog/search/articles?q=mail+server", nil)
|
|
rec := httptest.NewRecorder()
|
|
s.ServeHTTP(rec, req)
|
|
var d struct {
|
|
Total int
|
|
Results []struct{ U, Snippet string }
|
|
}
|
|
json.NewDecoder(rec.Body).Decode(&d)
|
|
if d.Total != 2 || d.Results[0].U != "/articles/a/" || !strings.Contains(d.Results[0].Snippet, "mail server") {
|
|
t.Fatalf("search: %d %+v", rec.Code, d)
|
|
}
|
|
for _, path := range []string{"/_hotdog/search/../../etc", "/_hotdog/search/nope"} {
|
|
rec := httptest.NewRecorder()
|
|
s.ServeHTTP(rec, httptest.NewRequest("GET", "http://example.org"+path+"?q=x", nil))
|
|
if rec.Code != 404 {
|
|
t.Errorf("%s: %d", path, rec.Code)
|
|
}
|
|
}
|
|
rec = httptest.NewRecorder()
|
|
s.ServeHTTP(rec, httptest.NewRequest("GET", "http://other.example/_hotdog/forms/contact/token", nil))
|
|
if rec.Code != 404 {
|
|
t.Errorf("unknown host: %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestSecretRequired(t *testing.T) {
|
|
dir := t.TempDir()
|
|
os.WriteFile(filepath.Join(dir, "endpoint.yaml"), []byte("sites: []\n"), 0o644)
|
|
t.Setenv("HOTDOG_FORM_SECRET", "short")
|
|
if _, err := New(filepath.Join(dir, "endpoint.yaml"), os.Stderr); err == nil {
|
|
t.Fatal("started without a proper secret")
|
|
}
|
|
}
|
|
|
|
// A real SMTP exchange with a small server that records what it receives.
|
|
func TestSMTPDelivery(t *testing.T) {
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer ln.Close()
|
|
got := make(chan string, 1)
|
|
go func() {
|
|
c, err := ln.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer c.Close()
|
|
r := bufio.NewReader(c)
|
|
say := func(s string) { c.Write([]byte(s + "\r\n")) }
|
|
say("220 test")
|
|
var data strings.Builder
|
|
inData := false
|
|
for {
|
|
line, err := r.ReadString('\n')
|
|
if err != nil {
|
|
return
|
|
}
|
|
if inData {
|
|
if line == ".\r\n" {
|
|
inData = false
|
|
say("250 ok")
|
|
got <- data.String()
|
|
continue
|
|
}
|
|
data.WriteString(line)
|
|
continue
|
|
}
|
|
switch cmd := strings.ToUpper(strings.TrimSpace(line)); {
|
|
case strings.HasPrefix(cmd, "EHLO"), strings.HasPrefix(cmd, "HELO"):
|
|
say("250 test")
|
|
case strings.HasPrefix(cmd, "MAIL"), strings.HasPrefix(cmd, "RCPT"):
|
|
say("250 ok")
|
|
case cmd == "DATA":
|
|
inData = true
|
|
say("354 go")
|
|
case cmd == "QUIT":
|
|
say("221 bye")
|
|
return
|
|
default:
|
|
say("502 no")
|
|
}
|
|
}
|
|
}()
|
|
s, _, _ := setup(t, "")
|
|
port := ln.Addr().(*net.TCPAddr).Port
|
|
s.mail = newMailer(SMTPConfig{Host: "127.0.0.1", Port: port, From: "[email protected]", TLS: "none"})
|
|
f := s.sites["example.org"].forms["contact"]
|
|
sub, problems := f.Check(map[string][]string{"name": {"Zoë"}, "email": {"[email protected]"}, "message": {"Grüße"}})
|
|
if problems != nil {
|
|
t.Fatal(problems)
|
|
}
|
|
if err := s.mail.deliver("Example", sub); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
select {
|
|
case msg := <-got:
|
|
if !strings.Contains(msg, "Subject: =?utf-8?q?Contact_from_Zo=C3=AB?=") || !strings.Contains(msg, "Gr=C3=BC=C3=9Fe") {
|
|
t.Errorf("message encoding wrong:\n%s", msg)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("no mail arrived")
|
|
}
|
|
}
|
|
|
|
func TestSubmissionsViewer(t *testing.T) {
|
|
t.Setenv("VIEWER_TOKEN", strings.Repeat("v", 40))
|
|
s, _, _ := setup(t, "viewer: { listen: 127.0.0.1:8182, token_env: VIEWER_TOKEN }\n")
|
|
start := time.Now()
|
|
s.now = func() time.Time { return start }
|
|
for i, name := range []string{"Ada", "Grace"} {
|
|
s.now = func() time.Time { return start }
|
|
tok := token(t, s)
|
|
s.now = func() time.Time { return start.Add(time.Duration(5+i) * time.Second) }
|
|
v := url.Values{"name": {name}, "email": {strings.ToLower(name) + "@example.net"}, "message": {"Hello from " + name}, "_t": {tok}, "_website": {""}}
|
|
if rec := post(s, v, "https://example.org", "application/json", "203.0.113.9"); rec.Code != 200 {
|
|
t.Fatalf("submit %s: %d %s", name, rec.Code, rec.Body)
|
|
}
|
|
}
|
|
h, addr, err := s.Viewer()
|
|
if err != nil || h == nil || addr != "127.0.0.1:8182" {
|
|
t.Fatalf("viewer: %v %v %s", h, err, addr)
|
|
}
|
|
get := func(path, tok string) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequest("GET", "http://"+addr+path, nil)
|
|
if tok != "" {
|
|
req.Header.Set("Authorization", "Bearer "+tok)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
if rec := get("/sites/example.org/forms/contact", ""); rec.Code != 401 {
|
|
t.Errorf("no token: %d", rec.Code)
|
|
}
|
|
if rec := get("/sites/example.org/forms/contact", strings.Repeat("x", 40)); rec.Code != 401 {
|
|
t.Errorf("wrong token: %d", rec.Code)
|
|
}
|
|
rec := get("/sites/example.org/forms/contact", strings.Repeat("v", 40))
|
|
var d struct {
|
|
Total int
|
|
Fields []map[string]string
|
|
Submissions []struct {
|
|
Time string
|
|
Values map[string]string
|
|
}
|
|
}
|
|
json.NewDecoder(rec.Body).Decode(&d)
|
|
if rec.Code != 200 || d.Total != 2 || len(d.Submissions) != 2 || d.Submissions[0].Values["name"] != "Grace" || len(d.Fields) != 3 {
|
|
t.Fatalf("read: %d %+v", rec.Code, d)
|
|
}
|
|
if rec := get("/sites/example.org/forms/contact?limit=1&offset=1", strings.Repeat("v", 40)); !strings.Contains(rec.Body.String(), `"Ada"`) || strings.Contains(rec.Body.String(), `"Grace"`) {
|
|
t.Errorf("paging: %s", rec.Body)
|
|
}
|
|
// The public endpoint knows nothing of it.
|
|
req := httptest.NewRequest("GET", "http://example.org/sites/example.org/forms/contact", nil)
|
|
req.Header.Set("Authorization", "Bearer "+strings.Repeat("v", 40))
|
|
pub := httptest.NewRecorder()
|
|
s.ServeHTTP(pub, req)
|
|
if pub.Code == 200 {
|
|
t.Error("the public endpoint served submissions")
|
|
}
|
|
// A public address is refused.
|
|
if err := (ViewerConfig{Listen: "0.0.0.0:8182", TokenEnv: "X"}).check(); err == nil {
|
|
t.Error("viewer on 0.0.0.0 accepted")
|
|
}
|
|
if err := (ViewerConfig{Listen: "203.0.113.5:8182", TokenEnv: "X"}).check(); err == nil {
|
|
t.Error("viewer on a public address accepted")
|
|
}
|
|
if err := (ViewerConfig{Listen: "10.0.0.5:8182", TokenEnv: "X"}).check(); err != nil {
|
|
t.Errorf("private address refused: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSourceLink(t *testing.T) {
|
|
s, _, _ := setup(t, "")
|
|
rec := httptest.NewRecorder()
|
|
s.ServeHTTP(rec, httptest.NewRequest("GET", "http://anything.example/_hotdog/source", nil))
|
|
if rec.Code != 302 || !strings.HasPrefix(rec.Header().Get("Location"), "https://git.coffeylabs.org/coffey-labs/hotdog-cms") {
|
|
t.Errorf("source: %d %s", rec.Code, rec.Header().Get("Location"))
|
|
}
|
|
s.cfg.Source = "https://git.example.org/me/hotdog-fork"
|
|
rec = httptest.NewRecorder()
|
|
s.ServeHTTP(rec, httptest.NewRequest("GET", "http://example.org/_hotdog/source", nil))
|
|
if rec.Header().Get("Location") != "https://git.example.org/me/hotdog-fork" {
|
|
t.Errorf("operator's source: %s", rec.Header().Get("Location"))
|
|
}
|
|
}
|
|
|
|
func TestRecipients(t *testing.T) {
|
|
sc := SiteConfig{Recipients: []string{"[email protected]", "@team.example.org"}}
|
|
for addr, want := range map[string]bool{"[email protected]": true, "[email protected]": true, "[email protected]": true, "[email protected]": false, "[email protected]": false, "[email protected]": false} {
|
|
if sc.allowed(addr) != want {
|
|
t.Errorf("%s: want %v", addr, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLimitKeyAndTerms(t *testing.T) {
|
|
if limitKey("2001:db8:1:2:aaaa::1") != limitKey("2001:db8:1:2:bbbb::9") || limitKey("2001:db8:1:3::1") == limitKey("2001:db8:1:2::1") {
|
|
t.Error("IPv6 isn't counted by /64")
|
|
}
|
|
if limitKey("203.0.113.7") != "203.0.113.7" {
|
|
t.Error("IPv4 changed")
|
|
}
|
|
if n := len(queryTerms("aa bb cc dd ee ff gg hh ii jj kk aa aa")); n != maxTerms {
|
|
t.Errorf("%d terms", n)
|
|
}
|
|
}
|