Add captcha

This commit is contained in:
bdnugget committed 2026-10-26 00:47:01 +01:00
1 parent 0d79b1fbc1
commit e457717465
5 files changed
+249 -6

No files matched your search

+5 -1
View File
@@ -25,6 +25,9 @@ The application supports the following environment variables:
| `DOMAIN` | `hogelandlinux.nl` | Your website domain (used for SEO and social media meta tags) | | `DOMAIN` | `hogelandlinux.nl` | Your website domain (used for SEO and social media meta tags) |
| `TELEGRAM_BOT_TOKEN` | *(empty)* | Telegram bot token for contact form notifications | | `TELEGRAM_BOT_TOKEN` | *(empty)* | Telegram bot token for contact form notifications |
| `TELEGRAM_CHAT_ID` | *(empty)* | Telegram chat ID where notifications will be sent | | `TELEGRAM_CHAT_ID` | *(empty)* | Telegram chat ID where notifications will be sent |
| `CAPCHA_SECRET` | *(empty)* | Secret key (`sk-...`) from the Cap dashboard, used to verify contact form submissions. **Required** — submissions are rejected when unset |
| `CAPCHA_SITE_KEY` | `113476d322` | Public Cap site key used by the widget |
| `CAPCHA_INSTANCE` | `https://capcha.hogelandlinux.nl` | Base URL of the self-hosted Cap instance |
## Telegram Integration Setup (Optional) ## Telegram Integration Setup (Optional)
@@ -94,9 +97,10 @@ VILLAGE=Eenrum
DOMAIN=hogelandlinux.nl DOMAIN=hogelandlinux.nl
TELEGRAM_BOT_TOKEN=your_bot_token_here TELEGRAM_BOT_TOKEN=your_bot_token_here
TELEGRAM_CHAT_ID=your_chat_id_here TELEGRAM_CHAT_ID=your_chat_id_here
CAPCHA_SECRET=sk-your_cap_secret_key
``` ```
**Note:** The Telegram variables are optional. If not set, the contact form will work but won't send notifications. **Note:** The Telegram variables are optional. If not set, the contact form will work but won't send notifications. `CAPCHA_SECRET` is required: without it the contact form rejects every submission.
### 5. Configure Domain ### 5. Configure Domain
+3
View File
@@ -17,6 +17,9 @@ services:
- DOMAIN=hogelandlinux.nl # Set your actual domain here - DOMAIN=hogelandlinux.nl # Set your actual domain here
- TELEGRAM_BOT_TOKEN= # Set your Telegram bot token here - TELEGRAM_BOT_TOKEN= # Set your Telegram bot token here
- TELEGRAM_CHAT_ID= # Set your Telegram chat ID here - TELEGRAM_CHAT_ID= # Set your Telegram chat ID here
- CAPCHA_SECRET= # Set your Cap secret key (sk-...) here
- CAPCHA_SITE_KEY=113476d322
- CAPCHA_INSTANCE=https://capcha.hogelandlinux.nl
- ABOUT_NAME=Bdnugget - ABOUT_NAME=Bdnugget
- BIRTH_DATE=1990-01-01 - BIRTH_DATE=1990-01-01
- SON_BIRTH_DATE=2022-01-01 - SON_BIRTH_DATE=2022-01-01
+84 -4
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"html/template" "html/template"
@@ -31,6 +32,9 @@ type Config struct {
AboutName string AboutName string
BirthDate string BirthDate string
SonBirthDate string SonBirthDate string
CapchaKeySecret string
CapchaSiteKey string
CapchaInstance string
} }
// PageData holds data for template rendering // PageData holds data for template rendering
@@ -59,8 +63,9 @@ type ContactForm struct {
// Server holds the application state // Server holds the application state
type Server struct { type Server struct {
config Config config Config
templates *template.Template templates *template.Template
httpClient *http.Client
} }
// Rate limiting // Rate limiting
@@ -96,6 +101,13 @@ func NewServer() *Server {
AboutName: getEnv("ABOUT_NAME", "Bdnugget"), AboutName: getEnv("ABOUT_NAME", "Bdnugget"),
BirthDate: getEnv("BIRTH_DATE", "1990-01-01"), BirthDate: getEnv("BIRTH_DATE", "1990-01-01"),
SonBirthDate: getEnv("SON_BIRTH_DATE", "2022-01-01"), SonBirthDate: getEnv("SON_BIRTH_DATE", "2022-01-01"),
CapchaKeySecret: getEnv("CAPCHA_SECRET", ""), // Set in Coolify env
CapchaSiteKey: getEnv("CAPCHA_SITE_KEY", "113476d322"),
CapchaInstance: getEnv("CAPCHA_INSTANCE", "https://capcha.hogelandlinux.nl"),
}
if config.CapchaKeySecret == "" {
log.Printf("Warning: CAPCHA_SECRET is not set; contact form submissions will be rejected")
} }
// Template Funcs // Template Funcs
@@ -129,8 +141,9 @@ func NewServer() *Server {
} }
return &Server{ return &Server{
config: config, config: config,
templates: templates, templates: templates,
httpClient: &http.Client{Timeout: 10 * time.Second},
} }
} }
@@ -231,6 +244,14 @@ func (s *Server) handleContactForm(w http.ResponseWriter, r *http.Request, data
return return
} }
// Verify the Cap token before any side effects (tokens are single-use)
if err := s.verifyCapcha(r.Context(), r.FormValue("cap-token")); err != nil {
log.Printf("Captcha verification failed for %s: %v", ip, err)
data.ErrorMessage = "De mens-verificatie is mislukt. Ververs de pagina en probeer het opnieuw, of stuur een mailtje."
s.renderTemplate(w, "contact.html", *data)
return
}
// Send to Telegram if configured // Send to Telegram if configured
if s.config.TelegramBotToken != "" && s.config.TelegramChatID != "" { if s.config.TelegramBotToken != "" && s.config.TelegramChatID != "" {
err := s.sendToTelegram(form) err := s.sendToTelegram(form)
@@ -253,6 +274,65 @@ func (s *Server) handleContactForm(w http.ResponseWriter, r *http.Request, data
s.renderTemplate(w, "contact.html", *data) s.renderTemplate(w, "contact.html", *data)
} }
// verifyCapcha verifies a Cap (trycap.dev) token with the configured instance.
// It returns an error when the token is missing, invalid, or cannot be verified.
func (s *Server) verifyCapcha(ctx context.Context, token string) error {
if token == "" {
return fmt.Errorf("missing cap-token")
}
if s.config.CapchaKeySecret == "" {
return fmt.Errorf("CAPCHA_SECRET is not configured")
}
verifyURL := strings.TrimRight(s.config.CapchaInstance, "/") + "/" +
strings.Trim(s.config.CapchaSiteKey, "/") + "/siteverify"
payload, err := json.Marshal(map[string]string{
"secret": s.config.CapchaKeySecret,
"response": token,
})
if err != nil {
return fmt.Errorf("failed to marshal captcha payload: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, verifyURL, bytes.NewReader(payload))
if err != nil {
return fmt.Errorf("failed to create captcha request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := s.httpClient.Do(req)
if err != nil {
return fmt.Errorf("failed to reach captcha service: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if err != nil {
return fmt.Errorf("failed to read captcha response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("captcha service returned status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var result struct {
Success bool `json:"success"`
Error string `json:"error"`
}
if err := json.Unmarshal(body, &result); err != nil {
return fmt.Errorf("failed to decode captcha response: %w", err)
}
if !result.Success {
if result.Error != "" {
return fmt.Errorf("captcha rejected: %s", result.Error)
}
return fmt.Errorf("captcha rejected")
}
return nil
}
// sendToTelegram sends the contact form data to Telegram // sendToTelegram sends the contact form data to Telegram
func (s *Server) sendToTelegram(form ContactForm) error { func (s *Server) sendToTelegram(form ContactForm) error {
// Format message // Format message
+155
View File
@@ -0,0 +1,155 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
func TestVerifyCapcha(t *testing.T) {
t.Run("valid token", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("method = %s, want POST", r.Method)
}
if r.URL.Path != "/113476d322/siteverify" {
t.Errorf("path = %s, want /113476d322/siteverify", r.URL.Path)
}
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
t.Errorf("content type = %s, want application/json", ct)
}
var payload map[string]string
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decoding payload: %v", err)
}
if payload["secret"] != "sk-test" {
t.Errorf("secret = %q, want sk-test", payload["secret"])
}
if payload["response"] != "token-123" {
t.Errorf("response = %q, want token-123", payload["response"])
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"success":true}`))
}))
defer srv.Close()
s := newTestServer()
s.config.CapchaInstance = srv.URL
s.config.CapchaSiteKey = "113476d322"
s.config.CapchaKeySecret = "sk-test"
if err := s.verifyCapcha(t.Context(), "token-123"); err != nil {
t.Fatalf("verifyCapcha returned error: %v", err)
}
})
t.Run("rejected token", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"success":false,"error":"invalid token"}`))
}))
defer srv.Close()
s := newTestServer()
s.config.CapchaInstance = srv.URL
s.config.CapchaKeySecret = "sk-test"
err := s.verifyCapcha(t.Context(), "bad-token")
if err == nil {
t.Fatal("verifyCapcha succeeded, want error")
}
if !strings.Contains(err.Error(), "invalid token") {
t.Errorf("error = %q, want it to contain the service error", err)
}
})
t.Run("missing token", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("captcha service should not be called without a token")
}))
defer srv.Close()
s := newTestServer()
s.config.CapchaInstance = srv.URL
s.config.CapchaKeySecret = "sk-test"
if err := s.verifyCapcha(t.Context(), ""); err == nil {
t.Fatal("verifyCapcha succeeded, want error")
}
})
t.Run("missing secret", func(t *testing.T) {
s := newTestServer()
s.config.CapchaKeySecret = ""
if err := s.verifyCapcha(t.Context(), "token-123"); err == nil {
t.Fatal("verifyCapcha succeeded, want error")
}
})
t.Run("service error", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "boom", http.StatusInternalServerError)
}))
defer srv.Close()
s := newTestServer()
s.config.CapchaInstance = srv.URL
s.config.CapchaKeySecret = "sk-test"
if err := s.verifyCapcha(t.Context(), "token-123"); err == nil {
t.Fatal("verifyCapcha succeeded, want error")
}
})
}
func TestContactFormRequiresCapToken(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"success":false,"error":"invalid token"}`))
}))
defer srv.Close()
s := newTestServer()
s.config.CapchaInstance = srv.URL
s.config.CapchaKeySecret = "sk-test"
s.config.TelegramBotToken = ""
s.config.TelegramChatID = ""
// Forged submission without a cap-token must be rejected.
body := url.Values{
"name": {"Test"},
"email": {"test@example.com"},
"message": {"Hallo"},
}
req := httptest.NewRequest(http.MethodPost, "/contact", strings.NewReader(body.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("X-Forwarded-For", "203.0.113.10")
rec := httptest.NewRecorder()
s.contactHandler(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
respBody := rec.Body.String()
if strings.Contains(respBody, "Bedankt voor uw bericht") {
t.Fatal("submission without a cap-token was accepted")
}
if !strings.Contains(respBody, "mens-verificatie is mislukt") {
t.Fatalf("expected verification error message, got: %s", respBody)
}
}
func newTestServer() *Server {
s := NewServer()
// Keep tests independent from any ambient Telegram configuration.
s.config.TelegramBotToken = ""
s.config.TelegramChatID = ""
return s
}
+2 -1
View File
@@ -121,6 +121,7 @@
] ]
} }
</script> </script>
<script src="https://cdn.jsdelivr.net/npm/cap-widget@latest"></script>
</head> </head>
<body> <body>
{{template "header" .}} {{template "header" .}}
@@ -231,7 +232,7 @@
<label for="message">Bericht *</label> <label for="message">Bericht *</label>
<textarea id="message" name="message" rows="5" required placeholder="Vertel iets over uw situatie en hoe ik u kan helpen...">{{.FormData.Message}}</textarea> <textarea id="message" name="message" rows="5" required placeholder="Vertel iets over uw situatie en hoe ik u kan helpen...">{{.FormData.Message}}</textarea>
</div> </div>
<cap-widget required data-cap-api-endpoint="{{.CapchaInstance}}/{{.CapchaSiteKey}}/"></cap-widget>
<button type="submit" class="btn btn-primary">Verstuur bericht</button> <button type="submit" class="btn btn-primary">Verstuur bericht</button>
</form> </form>
</div> </div>