Add captcha
This commit is contained in:
1 parent
0d79b1fbc1
commit
e457717465
5 files changed
+249
-6
No files matched your search
+5
-1
@@ -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) |
|
||||
| `TELEGRAM_BOT_TOKEN` | *(empty)* | Telegram bot token for contact form notifications |
|
||||
| `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)
|
||||
|
||||
@@ -94,9 +97,10 @@ VILLAGE=Eenrum
|
||||
DOMAIN=hogelandlinux.nl
|
||||
TELEGRAM_BOT_TOKEN=your_bot_token_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
|
||||
|
||||
|
||||
@@ -17,6 +17,9 @@ services:
|
||||
- DOMAIN=hogelandlinux.nl # Set your actual domain here
|
||||
- TELEGRAM_BOT_TOKEN= # Set your Telegram bot token 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
|
||||
- BIRTH_DATE=1990-01-01
|
||||
- SON_BIRTH_DATE=2022-01-01
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
@@ -31,6 +32,9 @@ type Config struct {
|
||||
AboutName string
|
||||
BirthDate string
|
||||
SonBirthDate string
|
||||
CapchaKeySecret string
|
||||
CapchaSiteKey string
|
||||
CapchaInstance string
|
||||
}
|
||||
|
||||
// PageData holds data for template rendering
|
||||
@@ -59,8 +63,9 @@ type ContactForm struct {
|
||||
|
||||
// Server holds the application state
|
||||
type Server struct {
|
||||
config Config
|
||||
templates *template.Template
|
||||
config Config
|
||||
templates *template.Template
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
@@ -96,6 +101,13 @@ func NewServer() *Server {
|
||||
AboutName: getEnv("ABOUT_NAME", "Bdnugget"),
|
||||
BirthDate: getEnv("BIRTH_DATE", "1990-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
|
||||
@@ -129,8 +141,9 @@ func NewServer() *Server {
|
||||
}
|
||||
|
||||
return &Server{
|
||||
config: config,
|
||||
templates: templates,
|
||||
config: config,
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
if s.config.TelegramBotToken != "" && s.config.TelegramChatID != "" {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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
|
||||
func (s *Server) sendToTelegram(form ContactForm) error {
|
||||
// Format message
|
||||
|
||||
+155
@@ -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
|
||||
}
|
||||
@@ -121,6 +121,7 @@
|
||||
]
|
||||
}
|
||||
</script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/cap-widget@latest"></script>
|
||||
</head>
|
||||
<body>
|
||||
{{template "header" .}}
|
||||
@@ -231,7 +232,7 @@
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<cap-widget required data-cap-api-endpoint="{{.CapchaInstance}}/{{.CapchaSiteKey}}/"></cap-widget>
|
||||
<button type="submit" class="btn btn-primary">Verstuur bericht</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user