mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-25 18:19:19 +00:00
* feat(webhooks): HTTP webhooks to trigger agents with HMAC auth and durable callbacks
Add multi-tenant HTTP webhook endpoints for agent triggering:
- /v1/webhooks/message: send messages to channels
- /v1/webhooks/llm: sync/async LLM prompts with HMAC-signed callbacks
- HMAC-256 + bearer token authentication
- Rate limiting and tenant isolation
- Durable callback worker with exponential backoff
- PG 000056 + SQLite schema v25 migrations
- Unit + integration tests, P0 tenant isolation invariants
- Channel media capability helpers for attachment routing
- Comprehensive webhook documentation and i18n strings
* fix(webhooks): address post-review findings (K1-K10)
Comprehensive post-merge fixes addressing 10 blocking code review issues
and 2 adversarial re-audit findings in webhook-agent-triggering feature:
K1: Fix auth middleware tenant context lookup sequencing — move
tenant context injection before authenticate() call to prevent
unscoped secret lookups.
K2: Canonicalize JSON payload format for jsonb compatibility across
PostgreSQL and SQLite — ensure consistent serialization without
whitespace variance to prevent hash mismatches.
K3: Add fail-closed JSON parsing in body hash extraction with explicit
error handling for malformed payloads before HMAC verification.
K4: Fix worker queue wedge by properly draining slot reservations
when delivery succeeds, preventing permanent slot occupancy.
K5: Implement lease-token optimistic concurrency control to prevent
duplicate webhook delivery under high concurrency or retry storms.
K6: Add AES-256-GCM encrypted secret storage at rest with fail-fast
skip-mount when GOCLAW_ENCRYPTION_KEY environment variable unset.
K7: Implement IP allowlist enforcement supporting both CIDR ranges
and exact IP matching with proper X-Forwarded-For parsing.
K8: Add HMAC replay nonce cache (5min expiry, non-blocking async flush)
to prevent request replay attacks on webhook handler.
K9: Fix invariant test schema selection — replace hardcoded assumption
with explicit schema name from config to support multi-schema testing.
K10: Consolidate rate limiters into single shared instance to prevent
per-endpoint limiter starvation and ensure fair rate limiting.
New database migrations:
- 000057: webhook_calls.lease_token for optimistic concurrency
- 000058: webhooks.encrypted_secret_key for AES-256-GCM encryption
New i18n keys: MsgWebhookIPDenied, MsgWebhookEncryptionUnavailable
(with English, Vietnamese, Chinese translations).
New modules:
- internal/http/webhooks_payload.go: JSON canonicalization + body hash
- internal/http/webhooks_nonce.go: Replay nonce cache implementation
- internal/http/webhooks_idempotency_test.go: Integration tests
Documentation updates:
- docs/webhooks.md: §13-14 security sections, encryption flow
- docs/00-architecture-overview.md: webhook subsystem security overview
- docs/codebase-summary.md: webhook security patterns
- docs/project-changelog.md: webhook fixes changelog
Test coverage: 53 webhook tests + 4 P0 invariant tests all passing.
No tenant isolation violations. All security gates enforced.
* docs(journals): webhook feature ship + fix cycle entries
* fix(webhooks): address Claude review findings
- webhooks_llm.go: remove misleading ptr() helper; use &completedAt
pattern for error-path audit rows (matches success path)
- webhooks_auth.go: wrap TouchLastUsed context in WithoutCancel so
background DB update isn't cancelled when HTTP response completes
- store GetByIDUnscoped (PG+SQLite): add NOT revoked / revoked = 0
filter for defense-in-depth parity with GetByHashUnscoped
- webhooks/sign.go: fix package doc — HMAC key is raw plaintext
secret bytes, not hex-decoded SHA-256
- webhooks_admin.go: check auth before encKey guard to avoid leaking
config state to unauthenticated callers
- webhooks_ratelimit.go: two-phase Load→LoadOrStore to avoid per-call
entry allocation on the hot path
* docs(webhooks): fix Sign() function doc to match actual key input
Function-level comment still referenced hex-decoded SecretHash after
the package-level doc was corrected. Align with actual caller usage
([]byte(rawSecret)).
* fix(webhooks): use WithoutCancel for worker execute DB updates
Terminal status writes in execute() ran through the worker main-loop
ctx, which is cancelled on graceful shutdown. If the outbound send
completed but the status update raced with shutdown, the row stayed
in 'running' and got re-delivered via reclaimStale. WithoutCancel
lets the DB write survive worker cancellation while preserving
propagated values (tenant ID, etc.).
* fix(webhooks): move tctx init before panic defer in worker execute
Panic recovery called updateRetry with raw ctx (no tenant ID), making
requireTenantID fail and the reset-to-retry DB write silently drop.
Row stayed 'running' until reclaimStale (~90s delay). Init tctx first
so defer closure captures tenant-scoped non-cancellable context.
* fix(webhooks): pass tenant-scoped tctx to invokeAgent in worker
execute() was passing the raw worker-loop ctx (no tenant ID) to
invokeAgent → router.Get → PGAgentStore.GetByID. GetByID reads
TenantIDFromContext which returned uuid.Nil, making every lookup
return 'agent not found'. Async LLM webhook calls silently failed
all retries. Pass tctx (already tenant-scoped + WithoutCancel) so
the router resolves the agent correctly.
* fix(tests): resolve integration test compile errors
- Remove duplicate contains() in mcp_grant_revoke_test.go (already
defined in tts_gemini_live_test.go)
- Update webhooks_admin_test.go RotateSecret call to match current
5-arg signature (newSecretHash, newPrefix, newEncryptedSecret)
* fix(webhooks): default nil scopes/ip_allowlist to empty slice in Create
PG columns are NOT NULL DEFAULT '{}'. Explicit NULL from pqStringArray(nil)
violated the constraint, breaking TestWebhookAdminCRUD/TenantIsolation.
Coerce nil slices to empty []string{} so the default applies at the DB layer.
* chore: trigger CI on digitopvn/goclaw fork
* ci: retrigger workflows
* fix(webhooks): renumber migrations to 000059-000061 for merge train
537 lines
17 KiB
Go
537 lines
17 KiB
Go
package http
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
|
"github.com/nextlevelbuilder/goclaw/internal/channels"
|
|
"github.com/nextlevelbuilder/goclaw/internal/security"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// ---- stub: channelDispatcher ----
|
|
|
|
// stubDispatcher implements channelDispatcher. Configured per-test.
|
|
type stubDispatcher struct {
|
|
// tenantsByName maps channel name → tenant UUID.
|
|
// uuid.Nil = legacy (no tenant scope). Use missingChannelName to simulate not found.
|
|
tenantsByName map[string]uuid.UUID
|
|
typeByName map[string]string
|
|
missingChannels map[string]bool // channels to report as non-existent
|
|
|
|
sentTo []bus.OutboundMessage // captured by SendToChannel
|
|
sentMedia []bus.OutboundMessage // captured by SendMediaToChannel
|
|
sendErr error // optional error to inject on send
|
|
}
|
|
|
|
func newStubDispatcher() *stubDispatcher {
|
|
return &stubDispatcher{
|
|
tenantsByName: make(map[string]uuid.UUID),
|
|
typeByName: make(map[string]string),
|
|
missingChannels: make(map[string]bool),
|
|
}
|
|
}
|
|
|
|
func (s *stubDispatcher) addChannel(name, chType string, tenantID uuid.UUID) {
|
|
s.tenantsByName[name] = tenantID
|
|
s.typeByName[name] = chType
|
|
}
|
|
|
|
func (s *stubDispatcher) ChannelTenantID(name string) (uuid.UUID, bool) {
|
|
if s.missingChannels[name] {
|
|
return uuid.Nil, false
|
|
}
|
|
tid, ok := s.tenantsByName[name]
|
|
return tid, ok
|
|
}
|
|
|
|
func (s *stubDispatcher) ChannelTypeForName(name string) string {
|
|
return s.typeByName[name]
|
|
}
|
|
|
|
func (s *stubDispatcher) SendToChannel(_ context.Context, channelName, chatID, content string) error {
|
|
if s.sendErr != nil {
|
|
return s.sendErr
|
|
}
|
|
s.sentTo = append(s.sentTo, bus.OutboundMessage{
|
|
Channel: channelName,
|
|
ChatID: chatID,
|
|
Content: content,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func (s *stubDispatcher) SendMediaToChannel(_ context.Context, channelName, chatID, content string, media []bus.MediaAttachment) error {
|
|
if s.sendErr != nil {
|
|
return s.sendErr
|
|
}
|
|
s.sentMedia = append(s.sentMedia, bus.OutboundMessage{
|
|
Channel: channelName,
|
|
ChatID: chatID,
|
|
Content: content,
|
|
Media: media,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// ---- stub: store.WebhookCallStore (message handler tests) ----
|
|
|
|
// msgCallStore records WebhookCallData rows created by the handler for assertion.
|
|
type msgCallStore struct {
|
|
created []*store.WebhookCallData
|
|
}
|
|
|
|
func (s *msgCallStore) Create(_ context.Context, c *store.WebhookCallData) error {
|
|
s.created = append(s.created, c)
|
|
return nil
|
|
}
|
|
func (s *msgCallStore) GetByID(_ context.Context, _ uuid.UUID) (*store.WebhookCallData, error) {
|
|
return nil, sql.ErrNoRows
|
|
}
|
|
func (s *msgCallStore) GetByIdempotency(_ context.Context, _ uuid.UUID, _ string) (*store.WebhookCallData, error) {
|
|
return nil, sql.ErrNoRows
|
|
}
|
|
func (s *msgCallStore) UpdateStatusCAS(_ context.Context, _ uuid.UUID, _ string, _ map[string]any) error {
|
|
return nil
|
|
}
|
|
func (s *msgCallStore) UpdateStatus(_ context.Context, _ uuid.UUID, _ map[string]any) error {
|
|
return nil
|
|
}
|
|
func (s *msgCallStore) ClaimNext(_ context.Context, _ uuid.UUID, _ time.Time) (*store.WebhookCallData, error) {
|
|
return nil, sql.ErrNoRows
|
|
}
|
|
func (s *msgCallStore) List(_ context.Context, _ store.WebhookCallListFilter) ([]store.WebhookCallData, error) {
|
|
return nil, nil
|
|
}
|
|
func (s *msgCallStore) DeleteOlderThan(_ context.Context, _ uuid.UUID, _ time.Time) (int64, error) {
|
|
return 0, nil
|
|
}
|
|
func (s *msgCallStore) ReclaimStale(_ context.Context, _ time.Time) (int64, error) {
|
|
return 0, nil
|
|
}
|
|
|
|
// ---- stub: store.WebhookStore (message handler tests — minimal no-op) ----
|
|
|
|
// msgWebhookStore is a no-op WebhookStore used when the handler under test
|
|
// doesn't exercise webhook store lookups (auth is bypassed in unit tests).
|
|
type msgWebhookStore struct{}
|
|
|
|
func (s *msgWebhookStore) Create(_ context.Context, _ *store.WebhookData) error { return nil }
|
|
func (s *msgWebhookStore) GetByID(_ context.Context, _ uuid.UUID) (*store.WebhookData, error) {
|
|
return nil, sql.ErrNoRows
|
|
}
|
|
func (s *msgWebhookStore) GetByHash(_ context.Context, _ string) (*store.WebhookData, error) {
|
|
return nil, sql.ErrNoRows
|
|
}
|
|
func (s *msgWebhookStore) List(_ context.Context, _ store.WebhookListFilter) ([]store.WebhookData, error) {
|
|
return nil, nil
|
|
}
|
|
func (s *msgWebhookStore) Update(_ context.Context, _ uuid.UUID, _ map[string]any) error {
|
|
return nil
|
|
}
|
|
func (s *msgWebhookStore) RotateSecret(_ context.Context, _ uuid.UUID, _, _, _ string) error {
|
|
return nil
|
|
}
|
|
func (s *msgWebhookStore) Revoke(_ context.Context, _ uuid.UUID) error { return nil }
|
|
func (s *msgWebhookStore) TouchLastUsed(_ context.Context, _ uuid.UUID) error { return nil }
|
|
func (s *msgWebhookStore) GetByHashUnscoped(_ context.Context, _ string) (*store.WebhookData, error) {
|
|
return nil, sql.ErrNoRows
|
|
}
|
|
func (s *msgWebhookStore) GetByIDUnscoped(_ context.Context, _ uuid.UUID) (*store.WebhookData, error) {
|
|
return nil, sql.ErrNoRows
|
|
}
|
|
|
|
// ---- stub: store.ChannelInstanceStore ----
|
|
|
|
type stubChannelInstanceStore struct {
|
|
inst *store.ChannelInstanceData
|
|
}
|
|
|
|
func (s *stubChannelInstanceStore) Create(_ context.Context, _ *store.ChannelInstanceData) error {
|
|
return nil
|
|
}
|
|
func (s *stubChannelInstanceStore) Get(_ context.Context, _ uuid.UUID) (*store.ChannelInstanceData, error) {
|
|
if s.inst != nil {
|
|
return s.inst, nil
|
|
}
|
|
return nil, sql.ErrNoRows
|
|
}
|
|
func (s *stubChannelInstanceStore) GetByName(_ context.Context, _ string) (*store.ChannelInstanceData, error) {
|
|
if s.inst != nil {
|
|
return s.inst, nil
|
|
}
|
|
return nil, sql.ErrNoRows
|
|
}
|
|
func (s *stubChannelInstanceStore) Update(_ context.Context, _ uuid.UUID, _ map[string]any) error {
|
|
return nil
|
|
}
|
|
func (s *stubChannelInstanceStore) Delete(_ context.Context, _ uuid.UUID) error { return nil }
|
|
func (s *stubChannelInstanceStore) ListEnabled(_ context.Context) ([]store.ChannelInstanceData, error) {
|
|
return nil, nil
|
|
}
|
|
func (s *stubChannelInstanceStore) ListAll(_ context.Context) ([]store.ChannelInstanceData, error) {
|
|
return nil, nil
|
|
}
|
|
func (s *stubChannelInstanceStore) ListAllInstances(_ context.Context) ([]store.ChannelInstanceData, error) {
|
|
return nil, nil
|
|
}
|
|
func (s *stubChannelInstanceStore) ListAllEnabled(_ context.Context) ([]store.ChannelInstanceData, error) {
|
|
return nil, nil
|
|
}
|
|
func (s *stubChannelInstanceStore) ListPaged(_ context.Context, _ store.ChannelInstanceListOpts) ([]store.ChannelInstanceData, error) {
|
|
return nil, nil
|
|
}
|
|
func (s *stubChannelInstanceStore) CountInstances(_ context.Context, _ store.ChannelInstanceListOpts) (int, error) {
|
|
return 0, nil
|
|
}
|
|
|
|
// ---- helper: build handler ----
|
|
|
|
// tenantA and tenantB are stable UUIDs for cross-tenant tests.
|
|
var (
|
|
tenantA = uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
|
tenantB = uuid.MustParse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
|
|
)
|
|
|
|
// buildHandler wires a WebhookMessageHandler with the given dispatcher stub.
|
|
func buildHandler(t *testing.T, disp channelDispatcher, calls *msgCallStore) *WebhookMessageHandler {
|
|
t.Helper()
|
|
if calls == nil {
|
|
calls = &msgCallStore{}
|
|
}
|
|
h := &WebhookMessageHandler{
|
|
channelMgr: disp,
|
|
channelInstances: &stubChannelInstanceStore{},
|
|
callStore: calls,
|
|
webhooks: &msgWebhookStore{},
|
|
limiter: newWebhookLimiter(0),
|
|
}
|
|
return h
|
|
}
|
|
|
|
// invokeHandle fires h.handle directly with the webhook injected into context.
|
|
func invokeHandle(t *testing.T, h *WebhookMessageHandler, webhook *store.WebhookData, body any) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
b, err := json.Marshal(body)
|
|
if err != nil {
|
|
t.Fatalf("marshal body: %v", err)
|
|
}
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/webhooks/message", bytes.NewReader(b))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
ctx := store.WithTenantID(req.Context(), webhook.TenantID)
|
|
ctx = WithWebhookData(ctx, webhook)
|
|
req = req.WithContext(ctx)
|
|
|
|
rr := httptest.NewRecorder()
|
|
h.handle(rr, req)
|
|
return rr
|
|
}
|
|
|
|
func newWebhook(tenantID uuid.UUID, channelID *uuid.UUID) *store.WebhookData {
|
|
return &store.WebhookData{
|
|
ID: store.GenNewID(),
|
|
TenantID: tenantID,
|
|
Kind: "message",
|
|
ChannelID: channelID,
|
|
}
|
|
}
|
|
|
|
// ---- tests ----
|
|
|
|
// TestWebhookMessage_PlainText_HappyPath verifies a text-only message delivers 200 with
|
|
// status="sent" and writes a done audit record.
|
|
func TestWebhookMessage_PlainText_HappyPath(t *testing.T) {
|
|
disp := newStubDispatcher()
|
|
disp.addChannel("tg-main", channels.TypeTelegram, tenantA)
|
|
|
|
calls := &msgCallStore{}
|
|
h := buildHandler(t, disp, calls)
|
|
wh := newWebhook(tenantA, nil)
|
|
|
|
rr := invokeHandle(t, h, wh, map[string]any{
|
|
"channel_name": "tg-main",
|
|
"chat_id": "123",
|
|
"content": "hello world",
|
|
})
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var resp webhookMessageResp
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if resp.Status != "sent" {
|
|
t.Errorf("want status=sent, got %q", resp.Status)
|
|
}
|
|
if resp.Warning != "" {
|
|
t.Errorf("want no warning, got %q", resp.Warning)
|
|
}
|
|
// Audit record must be done.
|
|
if len(calls.created) != 1 || calls.created[0].Status != "done" {
|
|
t.Errorf("want 1 done audit record, got %d records", len(calls.created))
|
|
}
|
|
// Text must have been dispatched.
|
|
if len(disp.sentTo) != 1 {
|
|
t.Errorf("want 1 SendToChannel call, got %d", len(disp.sentTo))
|
|
}
|
|
}
|
|
|
|
// TestWebhookMessage_CrossTenant_Deny validates the P0 isolation invariant:
|
|
// a webhook from tenantA must not be able to send through a channel owned by tenantB.
|
|
func TestWebhookMessage_CrossTenant_Deny(t *testing.T) {
|
|
disp := newStubDispatcher()
|
|
disp.addChannel("discord-b", channels.TypeDiscord, tenantB) // owned by tenantB
|
|
|
|
calls := &msgCallStore{}
|
|
h := buildHandler(t, disp, calls)
|
|
wh := newWebhook(tenantA, nil) // webhook belongs to tenantA
|
|
|
|
rr := invokeHandle(t, h, wh, map[string]any{
|
|
"channel_name": "discord-b",
|
|
"chat_id": "456",
|
|
"content": "cross-tenant attempt",
|
|
})
|
|
|
|
if rr.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
// Nothing must have been sent.
|
|
if len(disp.sentTo)+len(disp.sentMedia) > 0 {
|
|
t.Error("no message must be delivered on tenant mismatch")
|
|
}
|
|
// No done audit record.
|
|
for _, c := range calls.created {
|
|
if c.Status == "done" {
|
|
t.Errorf("unexpected done audit record on cross-tenant attempt")
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestWebhookMessage_SSRFBlock_RFC1918 validates that a RFC1918 media_url is rejected
|
|
// with 400 before any channel send.
|
|
func TestWebhookMessage_SSRFBlock_RFC1918(t *testing.T) {
|
|
disp := newStubDispatcher()
|
|
disp.addChannel("tg-main", channels.TypeTelegram, tenantA)
|
|
|
|
calls := &msgCallStore{}
|
|
h := buildHandler(t, disp, calls)
|
|
wh := newWebhook(tenantA, nil)
|
|
|
|
rr := invokeHandle(t, h, wh, map[string]any{
|
|
"channel_name": "tg-main",
|
|
"chat_id": "123",
|
|
"content": "text",
|
|
"media_url": "http://192.168.1.1/secret.jpg", // RFC1918 — blocked
|
|
})
|
|
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 for RFC1918 media_url, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
if len(disp.sentTo)+len(disp.sentMedia) > 0 {
|
|
t.Error("no message must be sent when media URL is SSRF-blocked")
|
|
}
|
|
// Must record a failed audit call.
|
|
if len(calls.created) == 0 || calls.created[0].Status != "failed" {
|
|
t.Errorf("expected failed audit record, got %+v", calls.created)
|
|
}
|
|
}
|
|
|
|
// TestWebhookMessage_MediaUnsupported_FallbackOn verifies that when the channel
|
|
// doesn't support media and fallback_to_text=true, a 200 is returned with warning
|
|
// and text-only delivery is performed (no media sent).
|
|
func TestWebhookMessage_MediaUnsupported_FallbackOn(t *testing.T) {
|
|
disp := newStubDispatcher()
|
|
disp.addChannel("zalo-main", channels.TypeZaloOA, tenantA) // zalo_oa: not media capable
|
|
|
|
calls := &msgCallStore{}
|
|
h := buildHandler(t, disp, calls)
|
|
wh := newWebhook(tenantA, nil)
|
|
|
|
// Allow loopback so httptest.Server passes SSRF validation.
|
|
security.SetAllowLoopbackForTest(true)
|
|
defer security.SetAllowLoopbackForTest(false)
|
|
|
|
mediaServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "image/jpeg")
|
|
w.Header().Set("Content-Length", "1024")
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer mediaServer.Close()
|
|
|
|
rr := invokeHandle(t, h, wh, map[string]any{
|
|
"channel_name": "zalo-main",
|
|
"chat_id": "789",
|
|
"content": "fallback text",
|
|
"media_url": mediaServer.URL + "/image.jpg",
|
|
"fallback_to_text": true,
|
|
})
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 with fallback, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
var resp webhookMessageResp
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if resp.Warning != "media_not_supported_fallback_text" {
|
|
t.Errorf("expected fallback warning, got %q", resp.Warning)
|
|
}
|
|
// Text must have been sent; no media dispatch.
|
|
if len(disp.sentTo) != 1 {
|
|
t.Errorf("expected 1 text send, got %d", len(disp.sentTo))
|
|
}
|
|
if len(disp.sentMedia) != 0 {
|
|
t.Errorf("expected no media send, got %d", len(disp.sentMedia))
|
|
}
|
|
}
|
|
|
|
// TestWebhookMessage_MediaUnsupported_FallbackOff verifies that when the channel
|
|
// doesn't support media and fallback_to_text is false (default), a 501 is returned.
|
|
func TestWebhookMessage_MediaUnsupported_FallbackOff(t *testing.T) {
|
|
disp := newStubDispatcher()
|
|
disp.addChannel("zalo-main", channels.TypeZaloOA, tenantA)
|
|
|
|
calls := &msgCallStore{}
|
|
h := buildHandler(t, disp, calls)
|
|
wh := newWebhook(tenantA, nil)
|
|
|
|
security.SetAllowLoopbackForTest(true)
|
|
defer security.SetAllowLoopbackForTest(false)
|
|
|
|
mediaServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "image/jpeg")
|
|
w.Header().Set("Content-Length", "512")
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer mediaServer.Close()
|
|
|
|
rr := invokeHandle(t, h, wh, map[string]any{
|
|
"channel_name": "zalo-main",
|
|
"chat_id": "789",
|
|
"content": "text",
|
|
"media_url": mediaServer.URL + "/image.jpg",
|
|
// fallback_to_text omitted → defaults false
|
|
})
|
|
|
|
if rr.Code != http.StatusNotImplemented {
|
|
t.Fatalf("expected 501, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
if len(disp.sentTo)+len(disp.sentMedia) > 0 {
|
|
t.Error("no message must be sent when media is unsupported and fallback is off")
|
|
}
|
|
if len(calls.created) == 0 || calls.created[0].Status != "failed" {
|
|
t.Errorf("expected failed audit record, got %+v", calls.created)
|
|
}
|
|
}
|
|
|
|
// ---- probeMediaURL unit tests ----
|
|
|
|
// TestProbeMediaURL_SSRFBlock verifies RFC1918 / link-local addresses are blocked.
|
|
func TestProbeMediaURL_SSRFBlock(t *testing.T) {
|
|
blocked := []string{
|
|
"http://127.0.0.1/secret",
|
|
"http://10.0.0.1/secret",
|
|
"http://192.168.1.1/secret",
|
|
"http://169.254.169.254/latest/meta-data/",
|
|
}
|
|
for _, u := range blocked {
|
|
t.Run(u, func(t *testing.T) {
|
|
_, err := probeMediaURL(u)
|
|
if err == nil {
|
|
t.Fatalf("expected SSRF block, got nil error")
|
|
}
|
|
var mve *mediaValidateError
|
|
if !errors.As(err, &mve) || mve.code != "ssrf" {
|
|
t.Errorf("expected ssrf error, got %T: %v", err, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestProbeMediaURL_MIMEDenied verifies non-allowlisted MIME types return mime_denied.
|
|
func TestProbeMediaURL_MIMEDenied(t *testing.T) {
|
|
security.SetAllowLoopbackForTest(true)
|
|
defer security.SetAllowLoopbackForTest(false)
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "text/html")
|
|
w.Header().Set("Content-Length", "100")
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
_, err := probeMediaURL(srv.URL + "/page.html")
|
|
if err == nil {
|
|
t.Fatal("expected error for denied MIME, got nil")
|
|
}
|
|
var mve *mediaValidateError
|
|
if !errors.As(err, &mve) || mve.code != "mime_denied" {
|
|
t.Errorf("expected mime_denied, got code=%q err=%v", mve.code, err)
|
|
}
|
|
}
|
|
|
|
// TestProbeMediaURL_TooLarge verifies Content-Length > 25 MB returns too_large.
|
|
func TestProbeMediaURL_TooLarge(t *testing.T) {
|
|
security.SetAllowLoopbackForTest(true)
|
|
defer security.SetAllowLoopbackForTest(false)
|
|
|
|
const tooBig = webhookMediaMaxBytes + 1 // 25 MB + 1 byte
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "image/jpeg")
|
|
w.Header().Set("Content-Length", "26214401")
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer srv.Close()
|
|
_ = tooBig
|
|
|
|
_, err := probeMediaURL(srv.URL + "/big.jpg")
|
|
if err == nil {
|
|
t.Fatal("expected error for oversized media, got nil")
|
|
}
|
|
var mve *mediaValidateError
|
|
if !errors.As(err, &mve) || mve.code != "too_large" {
|
|
t.Errorf("expected too_large, got code=%q err=%v", mve.code, err)
|
|
}
|
|
}
|
|
|
|
// TestProbeMediaURL_HappyPath verifies a valid probe returns ContentType and non-nil PinnedIP.
|
|
func TestProbeMediaURL_HappyPath(t *testing.T) {
|
|
security.SetAllowLoopbackForTest(true)
|
|
defer security.SetAllowLoopbackForTest(false)
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "image/png; charset=utf-8")
|
|
w.Header().Set("Content-Length", "2048")
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
result, err := probeMediaURL(srv.URL + "/photo.png")
|
|
if err != nil {
|
|
t.Fatalf("expected success, got %v", err)
|
|
}
|
|
if result.ContentType != "image/png" {
|
|
t.Errorf("expected image/png (params stripped), got %q", result.ContentType)
|
|
}
|
|
if result.PinnedIP == nil {
|
|
t.Error("expected non-nil pinned IP")
|
|
}
|
|
if !net.IP(result.PinnedIP).IsLoopback() {
|
|
t.Errorf("expected loopback pinned IP for httptest server, got %s", result.PinnedIP)
|
|
}
|
|
}
|