fix(security): harden upstream critical surfaces (#32)

* fix(security): harden upstream critical surfaces

Refs #30

* fix(security): close pre-landing review gaps

Refs #30

* fix(security): close official release blockers
This commit is contained in:
Duy /zuey/ authored and GitHub committed 2026-05-20 16:33:49 +07:00
1 parent 2a48e62c6b
commit 532ff91d8e
59 files changed
+2194 -397

No files matched your search

+3
View File
@@ -5,6 +5,9 @@
# LLM provider API keys: configure via the web dashboard setup wizard.
# --- Gateway (required) ---
# Required for Docker/external binds. Run ./prepare-env.sh to generate.
# Local loopback-only development may opt into empty-token mode with:
# GOCLAW_ALLOW_INSECURE_NO_AUTH=1
GOCLAW_GATEWAY_TOKEN=
GOCLAW_ENCRYPTION_KEY=
POSTGRES_PASSWORD=
+4
View File
@@ -35,6 +35,10 @@ All notable changes to GoClaw are documented here. For full documentation, see [
### Fixed
- **Upstream critical security remediation** — hardens gateway no-token fallback,
Feishu/Lark and Pancake webhooks, sandbox path/write handling, tenant-admin
checks for mutable HTTP surfaces, and Lite hook schema migration verification.
- **SecureCLI runtime npm binaries** — binary discovery and credentialed exec now
resolve tools installed under the GoClaw runtime directories, including
`{runtimeDir}/npm-global/bin`, and support single-binary npm package aliases
+11 -6
View File
@@ -17,24 +17,24 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/cache"
"github.com/nextlevelbuilder/goclaw/internal/channels"
"github.com/nextlevelbuilder/goclaw/internal/consolidation"
"github.com/nextlevelbuilder/goclaw/internal/eventbus"
kg "github.com/nextlevelbuilder/goclaw/internal/knowledgegraph"
"github.com/nextlevelbuilder/goclaw/internal/channels/discord"
"github.com/nextlevelbuilder/goclaw/internal/channels/facebook"
"github.com/nextlevelbuilder/goclaw/internal/channels/pancake"
"github.com/nextlevelbuilder/goclaw/internal/channels/feishu"
"github.com/nextlevelbuilder/goclaw/internal/channels/pancake"
slackchannel "github.com/nextlevelbuilder/goclaw/internal/channels/slack"
"github.com/nextlevelbuilder/goclaw/internal/channels/telegram"
"github.com/nextlevelbuilder/goclaw/internal/channels/whatsapp"
"github.com/nextlevelbuilder/goclaw/internal/channels/zalo"
zalopersonal "github.com/nextlevelbuilder/goclaw/internal/channels/zalo/personal"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/consolidation"
"github.com/nextlevelbuilder/goclaw/internal/edition"
"github.com/nextlevelbuilder/goclaw/internal/eventbus"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/gateway/methods"
"github.com/nextlevelbuilder/goclaw/internal/hooks"
httpapi "github.com/nextlevelbuilder/goclaw/internal/http"
kg "github.com/nextlevelbuilder/goclaw/internal/knowledgegraph"
mcpbridge "github.com/nextlevelbuilder/goclaw/internal/mcp"
"github.com/nextlevelbuilder/goclaw/internal/media"
"github.com/nextlevelbuilder/goclaw/internal/providers"
@@ -84,6 +84,10 @@ func runGateway() {
slog.Error("failed to load config", "error", err)
os.Exit(1)
}
if err := config.ValidateGatewayAuth(cfg.Gateway); err != nil {
slog.Error("unsafe gateway auth configuration", "error", err)
os.Exit(1)
}
// Edition override: explicit GOCLAW_EDITION takes precedence over auto-detection.
// Auto-detection happens later in setupStoresAndTracing (sqlite → lite).
@@ -325,8 +329,8 @@ func runGateway() {
agentRouter: agentRouter,
toolsReg: toolsReg,
skillsLoader: skillsLoader,
enrichProgress: enrichProgress,
enrichWorker: enrichWorker,
enrichProgress: enrichProgress,
enrichWorker: enrichWorker,
workspace: workspace,
dataDir: dataDir,
domainBus: domainBus,
@@ -339,6 +343,7 @@ func runGateway() {
mcpToolLister = mcpMgr
}
httpapi.InitGatewayToken(cfg.Gateway.Token)
httpapi.InitGatewayNoAuthFallbackAllowed(config.GatewayNoAuthFallbackAllowed(cfg.Gateway))
exportTokenStore := httpapi.InitExportTokenStore()
defer exportTokenStore.Stop()
agentsH, skillsH, tracesH, mcpH, channelInstancesH, providersH, builtinToolsH, pendingMessagesH, teamEventsH, secureCLIH, secureCLIGrantH, mcpUserCredsH := wireHTTP(pgStores, cfg.Agents.Defaults.Workspace, dataDir, bundledSkillsDir, msgBus, toolsReg, providerRegistry, modelReg, permPE.IsOwner, gatewayAddr, mcpToolLister)
+2 -2
View File
@@ -286,7 +286,7 @@ func (d *gatewayDeps) wireHTTPHandlersOnServer(
d.server.SetFilesHandler(httpapi.NewFilesHandler(d.workspace, d.dataDir))
// Storage file management — browse/delete files under the resolved workspace directory.
d.server.SetStorageHandler(httpapi.NewStorageHandler(d.workspace))
d.server.SetStorageHandler(httpapi.NewStorageHandler(d.workspace, d.pgStores.Tenants))
// Media upload endpoint — accepts multipart file uploads, returns temp path + MIME type.
d.server.SetMediaUploadHandler(httpapi.NewMediaUploadHandler())
@@ -336,7 +336,7 @@ func (d *gatewayDeps) wireHTTPHandlersOnServer(
// Per-tenant TTS config endpoint — allows tenant admins to configure TTS.
if d.pgStores.SystemConfigs != nil && d.pgStores.ConfigSecrets != nil {
d.server.SetTTSConfigHandler(httpapi.NewTTSConfigHandler(d.pgStores.SystemConfigs, d.pgStores.ConfigSecrets))
d.server.SetTTSConfigHandler(httpapi.NewTTSConfigHandler(d.pgStores.SystemConfigs, d.pgStores.ConfigSecrets, d.pgStores.Tenants))
}
// Workstations API — Standard edition only.
+1 -1
View File
@@ -44,7 +44,7 @@ services:
- GOCLAW_HOST=0.0.0.0
- GOCLAW_PORT=18790
- GOCLAW_CONFIG=/app/data/config.json
- GOCLAW_GATEWAY_TOKEN=${GOCLAW_GATEWAY_TOKEN:-}
- GOCLAW_GATEWAY_TOKEN=${GOCLAW_GATEWAY_TOKEN:?run ./prepare-env.sh or set GOCLAW_GATEWAY_TOKEN}
- GOCLAW_ENCRYPTION_KEY=${GOCLAW_ENCRYPTION_KEY:-}
- GOCLAW_SKILLS_DIR=/app/data/skills
# Debug
+3 -3
View File
@@ -978,7 +978,7 @@ Team activity and audit trail.
## 20. Secure CLI Credentials
CLI authentication credentials for secure command execution. Requires **admin role** (full gateway token or empty gateway token in dev/single-user mode).
CLI authentication credentials for secure command execution. Requires **admin role** (gateway token or empty-token local/dev fallback).
| Method | Path | Description |
|--------|------|-------------|
@@ -1014,7 +1014,7 @@ CLI authentication credentials for secure command execution. Requires **admin ro
## 21. Runtime & Packages Management
Manage system (apk), Python (pip), and Node (npm) package installation in the GoClaw runtime container. These endpoints do not inspect host-level runtimes. Requires authentication. When `GOCLAW_GATEWAY_TOKEN` is empty (dev/single-user mode), all users get admin role and can manage packages.
Manage system (apk), Python (pip), and Node (npm) package installation in the GoClaw runtime container. These endpoints do not inspect host-level runtimes. Requires authentication. Empty-token admin access is limited to loopback local development or explicit `GOCLAW_ALLOW_INSECURE_NO_AUTH=1`; external binds require `GOCLAW_GATEWAY_TOKEN`.
### List Installed Packages
@@ -1178,7 +1178,7 @@ Workspace file management.
| `GET` | `/v1/storage/files` | List files with depth limiting |
| `GET` | `/v1/storage/files/{path...}` | Read file (JSON or raw) |
| `POST` | `/v1/storage/files` | Upload file (admin) |
| `DELETE` | `/v1/storage/files/{path...}` | Delete file/directory |
| `DELETE` | `/v1/storage/files/{path...}` | Delete file/directory (admin) |
| `PUT` | `/v1/storage/move` | Move/rename file (admin) |
| `GET` | `/v1/storage/size` | Stream storage size (Server-Sent Events, cached 60 min) |
+15 -4
View File
@@ -32,6 +32,15 @@ Or in WebSocket `connect`:
The gateway token is compared using **constant-time comparison** (`crypto/subtle.ConstantTimeCompare`) in both HTTP and WebSocket auth paths to prevent timing attacks. The comparison reveals no information about where the provided token first differs from the expected token.
Externally reachable deployments must configure a gateway token. If `gateway.token` / `GOCLAW_GATEWAY_TOKEN` is empty while the gateway binds to `0.0.0.0`, `::`, or a non-loopback address, startup fails before the health endpoint reports ready.
Empty-token compatibility is only for local development:
- bind `GOCLAW_HOST` to loopback (`127.0.0.1`, `localhost`, or `::1`), or
- set `GOCLAW_ALLOW_INSECURE_NO_AUTH=1` explicitly.
The explicit opt-in applies to both HTTP and WebSocket. Do not use it on shared hosts, Docker ports exposed outside the machine, or production deployments.
---
## 2. API Keys
@@ -100,7 +109,7 @@ GoClaw tries authentication methods in this priority order:
1. **Gateway token** (exact match via constant-time comparison) → `RoleAdmin` or `RoleOwner` for configured owner IDs
2. **API key** (SHA-256 hash lookup in `api_keys` table) → role from scopes
3. **Browser pairing** (sender ID must be paired with "browser" device type) → `RoleOperator` (HTTP only; requires `X-GoClaw-Sender-Id` header)
4. **No auth configured** (backward compatibility: if no gateway token is set) → full-access dev mode
4. **No auth configured and local/dev mode explicitly allowed** → full-access dev mode
5. **No valid auth found** → `401 Unauthorized`
### HTTP Request Flow
@@ -116,13 +125,15 @@ flowchart TD
G -->|Yes| H[Derive role from scopes]
G -->|No| I{Gateway token configured?}
I -->|Yes| J[401 Unauthorized]
I -->|No| K[Full-access backward compat]
I -->|No| K{Local/dev fallback allowed?}
K -->|No| J
K -->|Yes| O[Full-access backward compat]
C -->|Check paired device| L{Device paired?}
L -->|Yes| M[RoleOperator]
L -->|No| J
E --> N[Authenticate request]
H --> N
K --> N
O --> N
M --> N
```
@@ -164,7 +175,7 @@ On successful API key authentication, `last_used_at` is updated asynchronously (
### Backward Compatibility
If no gateway token is configured (`gateway.token` is empty in `config.json`), unauthenticated requests run in backward-compatibility full-access mode. This enables self-hosted deployments without strict authentication. Once a gateway token is configured, all requests must authenticate or use browser pairing.
If no gateway token is configured (`gateway.token` is empty in `config.json`), unauthenticated requests run in backward-compatibility full-access mode only for loopback local development or when `GOCLAW_ALLOW_INSECURE_NO_AUTH=1` is set. Once a gateway token is configured, all requests must authenticate or use browser pairing.
---
+1 -1
View File
@@ -158,7 +158,7 @@ GoClaw determines the tenant from the credentials used to connect:
| **API key** (tenant-bound) | Auto from key's `tenant_id` | Normal SaaS integration |
| **API key** (system-level) + `X-GoClaw-Tenant-Id` | Header value (UUID or slug), while keeping the key's original role | Cross-tenant tools |
| **Browser pairing** | Master tenant by default, or a membership-validated tenant hint | Dashboard operators |
| **No credentials** | Master tenant | Dev/single-user mode |
| **No credentials** | Master tenant | Loopback local development or explicit `GOCLAW_ALLOW_INSECURE_NO_AUTH=1` only |
**Owner IDs:** Configured via `GOCLAW_OWNER_IDS` env var (comma-separated). Only owners get cross-tenant access with the gateway token. Default: `system`.
+1 -3
View File
@@ -119,7 +119,6 @@ Parity enforced by `ui/web/src/__tests__/i18n-tts-key-parity.test.ts` (vitest).
---
<<<<<<< HEAD
## Image Generation
Native `image_generation` support in the Codex provider (`POST /codex/responses`) + passthrough in the OpenAI-compat path.
@@ -138,7 +137,7 @@ Native `image_generation` support in the Codex provider (`POST /codex/responses`
**Persistence:** `internal/agent/media.go persistAssistantImages()` writes final images to `{workspace}/media/{sha256}.{ext}`, returns `MediaRef` entries, clears inline `Images[]`. Idempotent on hash. Invoked from `pipeline.FinalizeStage` via `Deps.PersistAssistantImages` callback.
**Web UI:** Download filename resolver (`imageGenDownloadName`) in `ui/web/src/components/chat/media-gallery.tsx`. Image generation works automatically when the agent has the `create_image` tool — no user-facing toggle.
=======
## Webhook Subsystem
External systems invoke agents or send channel messages via webhooks without gateway tokens.
@@ -188,7 +187,6 @@ Raw webhook secret encrypted at rest via AES-256-GCM using `GOCLAW_ENCRYPTION_KE
All webhook calls logged with canonical `{"body_hash":"<sha256-hex>","meta":{...}}` shape in `webhook_calls.request_payload` (JSON).
Used by idempotency checker to detect body mismatches on replay.
>>>>>>> a83f4090 (fix(webhooks): address post-review findings (K1-K10))
---
+35 -10
View File
@@ -13,6 +13,8 @@ import (
"strings"
)
const maxWebhookBodyBytes = 1 << 20
// --- Event types (replacing larkim.P2MessageReceiveV1) ---
// MessageEvent is the parsed structure of a Feishu im.message.receive_v1 event.
@@ -42,9 +44,9 @@ type EventSender struct {
}
type EventMessage struct {
MessageID string `json:"message_id"`
RootID string `json:"root_id"`
ParentID string `json:"parent_id"`
MessageID string `json:"message_id"`
RootID string `json:"root_id"`
ParentID string `json:"parent_id"`
// ThreadID is the definitive "this message lives inside a thread" signal
// per Lark docs. Unlike RootID (which is populated on ANY reply — including
// plain quote replies), ThreadID is only present when the message is in an
@@ -59,8 +61,8 @@ type EventMessage struct {
}
type EventMention struct {
Key string `json:"key"`
ID struct {
Key string `json:"key"`
ID struct {
OpenID string `json:"open_id"`
UserID string `json:"user_id"`
UnionID string `json:"union_id"`
@@ -75,9 +77,9 @@ type EventMention struct {
// Schema v1.0 uses flat structure, v2.0 uses header+event.
type webhookEvent struct {
// v2.0 fields
Schema string `json:"schema"`
Header json.RawMessage `json:"header"`
Event json.RawMessage `json:"event"`
Schema string `json:"schema"`
Header json.RawMessage `json:"header"`
Event json.RawMessage `json:"event"`
// v1.0 fields (also used for URL verification challenge)
Type string `json:"type"`
@@ -97,11 +99,15 @@ func NewWebhookHandler(verificationToken, encryptKey string, onMessage func(even
return
}
body, err := io.ReadAll(r.Body)
body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodyBytes+1))
if err != nil {
http.Error(w, "read body failed", http.StatusBadRequest)
return
}
if len(body) > maxWebhookBodyBytes {
http.Error(w, "body too large", http.StatusRequestEntityTooLarge)
return
}
// Try to decrypt if encrypted
var envelope webhookEvent
@@ -129,11 +135,22 @@ func NewWebhookHandler(verificationToken, encryptKey string, onMessage func(even
// URL verification challenge
if envelope.Type == "url_verification" {
if verificationToken == "" || envelope.Token != verificationToken {
slog.Warn("security.feishu_webhook_url_verification_rejected")
w.WriteHeader(http.StatusOK)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"challenge": envelope.Challenge})
return
}
if encryptKey != "" && envelope.Encrypt == "" {
slog.Warn("security.feishu_webhook_plaintext_rejected")
w.WriteHeader(http.StatusOK)
return
}
// Parse as message event
var event MessageEvent
@@ -144,8 +161,13 @@ func NewWebhookHandler(verificationToken, encryptKey string, onMessage func(even
}
// Verify token if configured
if verificationToken == "" && encryptKey == "" {
slog.Warn("security.feishu_webhook_missing_verification")
w.WriteHeader(http.StatusOK)
return
}
if verificationToken != "" && event.Header.Token != verificationToken {
slog.Warn("feishu webhook token mismatch")
slog.Warn("security.feishu_webhook_token_mismatch")
w.WriteHeader(http.StatusOK)
return
}
@@ -181,6 +203,9 @@ func decryptEvent(encryptedBase64, key string) ([]byte, error) {
// IV is first 16 bytes
iv := ciphertext[:aes.BlockSize]
ciphertext = ciphertext[aes.BlockSize:]
if len(ciphertext) == 0 || len(ciphertext)%aes.BlockSize != 0 {
return nil, fmt.Errorf("ciphertext length not block aligned")
}
mode := cipher.NewCBCDecrypter(block, iv)
mode.CryptBlocks(ciphertext, ciphertext)
+104 -1
View File
@@ -63,7 +63,7 @@ func buildWebhookRequest(body string) *http.Request {
func TestWebhookHandler_URLVerification(t *testing.T) {
called := false
h := NewWebhookHandler("", "", func(_ *MessageEvent) { called = true })
h := NewWebhookHandler("test-tok", "", func(_ *MessageEvent) { called = true })
body := `{"type":"url_verification","token":"test-tok","challenge":"abc123"}`
w := httptest.NewRecorder()
@@ -84,6 +84,24 @@ func TestWebhookHandler_URLVerification(t *testing.T) {
}
}
func TestWebhookHandler_URLVerificationRequiresMatchingToken(t *testing.T) {
h := NewWebhookHandler("expected-token", "", func(_ *MessageEvent) {
t.Fatal("onMessage must not be called for url_verification")
})
body := `{"type":"url_verification","token":"wrong-token","challenge":"abc123"}`
w := httptest.NewRecorder()
h.ServeHTTP(w, buildWebhookRequest(body))
if w.Code != http.StatusOK {
t.Errorf("status: got %d, want 200", w.Code)
}
var resp map[string]string
if err := json.NewDecoder(w.Body).Decode(&resp); err == nil && resp["challenge"] != "" {
t.Fatalf("must not return challenge for mismatched token, got %q", resp["challenge"])
}
}
// --- Method not allowed ---
func TestWebhookHandler_MethodNotAllowed(t *testing.T) {
@@ -179,6 +197,72 @@ func TestWebhookHandler_TokenMatch_Dispatches(t *testing.T) {
}
}
func TestWebhookHandler_MissingVerificationTokenDoesNotDispatchMessage(t *testing.T) {
dispatched := make(chan *MessageEvent, 1)
h := NewWebhookHandler("", "", func(e *MessageEvent) { dispatched <- e })
env := map[string]any{
"schema": "2.0",
"header": map[string]any{
"event_id": "evt_missing_token",
"event_type": "im.message.receive_v1",
"token": "",
"app_id": "cli_test",
"tenant_key": "test-tenant-1",
},
"event": map[string]any{
"sender": map[string]any{},
"message": map[string]any{"message_id": "om_1", "chat_id": "oc_1"},
},
}
body, _ := json.Marshal(env)
w := httptest.NewRecorder()
h.ServeHTTP(w, buildWebhookRequest(string(body)))
if w.Code != http.StatusOK {
t.Errorf("status: got %d, want 200", w.Code)
}
select {
case <-dispatched:
t.Fatal("onMessage must not be called when verification token is missing")
case <-time.After(100 * time.Millisecond):
}
}
func TestWebhookHandler_EncryptKeyRejectsPlaintextEvent(t *testing.T) {
dispatched := make(chan *MessageEvent, 1)
h := NewWebhookHandler("", "encrypt-key", func(e *MessageEvent) { dispatched <- e })
env := map[string]any{
"schema": "2.0",
"header": map[string]any{
"event_id": "evt_plaintext",
"event_type": "im.message.receive_v1",
"token": "",
"app_id": "cli_test",
"tenant_key": "test-tenant-1",
},
"event": map[string]any{
"sender": map[string]any{},
"message": map[string]any{"message_id": "om_1", "chat_id": "oc_1"},
},
}
body, _ := json.Marshal(env)
w := httptest.NewRecorder()
h.ServeHTTP(w, buildWebhookRequest(string(body)))
if w.Code != http.StatusOK {
t.Errorf("status: got %d, want 200", w.Code)
}
select {
case <-dispatched:
t.Fatal("onMessage must not be called for plaintext event when encrypt key is configured")
case <-time.After(100 * time.Millisecond):
}
}
// --- Non-message event type ---
func TestWebhookHandler_NonMessageEvent_Ignored(t *testing.T) {
@@ -217,6 +301,18 @@ func TestWebhookHandler_InvalidJSON(t *testing.T) {
}
}
func TestWebhookHandler_RejectsOversizedBody(t *testing.T) {
h := NewWebhookHandler("", "", func(_ *MessageEvent) {
t.Fatal("onMessage must not be called for oversized body")
})
w := httptest.NewRecorder()
h.ServeHTTP(w, buildWebhookRequest(strings.Repeat("x", maxWebhookBodyBytes+1)))
if w.Code != http.StatusRequestEntityTooLarge {
t.Errorf("status: got %d, want 413", w.Code)
}
}
// --- Encrypted event ---
func TestWebhookHandler_EncryptedEvent_Decrypted(t *testing.T) {
@@ -281,6 +377,13 @@ func TestDecryptEvent_InvalidBase64(t *testing.T) {
}
}
func TestDecryptEvent_RejectsNonBlockMultipleCiphertext(t *testing.T) {
payload := base64.StdEncoding.EncodeToString([]byte("12345678901234567"))
if _, err := decryptEvent(payload, "key"); err == nil {
t.Fatal("expected error for non-block-multiple ciphertext")
}
}
func TestDecryptEvent_TooShort(t *testing.T) {
// Valid base64 but shorter than AES block size (16 bytes)
short := base64.StdEncoding.EncodeToString([]byte("short"))
+1 -2
View File
@@ -150,7 +150,7 @@ func (ch *Channel) Start(ctx context.Context) error {
if ch.webhookSecret == "" {
slog.Warn("security.pancake_webhook_no_secret",
"page_id", ch.pageID,
"note", "webhook_secret not configured; incoming webhook requests will not be authenticated")
"note", "webhook_secret not configured; incoming webhook requests will be ignored until configured")
}
// Without HMAC, any actor reaching the webhook endpoint can trigger Pancake API calls.
@@ -370,4 +370,3 @@ func (ch *Channel) maxMessageLength() int {
return 2000
}
}
@@ -44,9 +44,10 @@ func TestMessageHandlerSkipsRecentOutboundEchoWithHTMLFormatting(t *testing.T) {
func TestWebhookRouterSkipsNonInboxConversationEvents(t *testing.T) {
msgBus := bus.New()
target := &Channel{
BaseChannel: channels.NewBaseChannel(channels.TypePancake, msgBus, nil),
pageID: "page-123",
platform: "facebook",
BaseChannel: channels.NewBaseChannel(channels.TypePancake, msgBus, nil),
pageID: "page-123",
platform: "facebook",
webhookSecret: "test-secret",
}
router := &webhookRouter{
instances: map[string]*Channel{
@@ -74,6 +75,7 @@ func TestWebhookRouterSkipsNonInboxConversationEvents(t *testing.T) {
}`
req := httptest.NewRequest(http.MethodPost, "/channels/pancake/webhook", strings.NewReader(body))
signTestPancakeRequest(req, body, target.webhookSecret)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
@@ -92,9 +94,10 @@ func TestWebhookRouterSkipsNonInboxConversationEvents(t *testing.T) {
func TestWebhookRouterPrefersMessageSenderOverConversationSender(t *testing.T) {
msgBus := bus.New()
target := &Channel{
BaseChannel: channels.NewBaseChannel(channels.TypePancake, msgBus, nil),
pageID: "page-123",
platform: "facebook",
BaseChannel: channels.NewBaseChannel(channels.TypePancake, msgBus, nil),
pageID: "page-123",
platform: "facebook",
webhookSecret: "test-secret",
}
router := &webhookRouter{
instances: map[string]*Channel{
@@ -127,6 +130,7 @@ func TestWebhookRouterPrefersMessageSenderOverConversationSender(t *testing.T) {
}`
req := httptest.NewRequest(http.MethodPost, "/channels/pancake/webhook", strings.NewReader(body))
signTestPancakeRequest(req, body, target.webhookSecret)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
+100 -11
View File
@@ -3,6 +3,9 @@ package pancake
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
@@ -537,12 +540,18 @@ func buildWebhookBody(pageID, convID, convType, senderID, msgID, content, postID
pageID, conv, msgID, content)
}
func signTestPancakeRequest(req *http.Request, body, secret string) {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(body))
req.Header.Set("X-Pancake-Signature", "sha256="+hex.EncodeToString(mac.Sum(nil)))
}
// newTestRouter creates an isolated webhookRouter with a registered channel.
func newTestRouter(t *testing.T, cfg pancakeInstanceConfig) (*webhookRouter, *Channel, *bus.MessageBus) {
t.Helper()
msgBus := bus.New()
cfg.PageID = "page-test"
creds := pancakeCreds{APIKey: "k", PageAccessToken: "t"}
creds := pancakeCreds{APIKey: "k", PageAccessToken: "t", WebhookSecret: "test-secret"}
ch, err := New(cfg, creds, msgBus, nil)
if err != nil {
t.Fatalf("New: %v", err)
@@ -557,10 +566,12 @@ func newTestRouter(t *testing.T, cfg pancakeInstanceConfig) (*webhookRouter, *Ch
func TestWebhookRouterRoutesCommentEvent(t *testing.T) {
cfg := pancakeInstanceConfig{}
cfg.Features.CommentReply = true
router, _, msgBus := newTestRouter(t, cfg)
router, ch, msgBus := newTestRouter(t, cfg)
ch.webhookSecret = "test-secret"
body := buildWebhookBody("page-test", "conv-1", "COMMENT", "user-1", "msg-1", "hello", "")
req := httptest.NewRequest(http.MethodPost, webhookPath, strings.NewReader(body))
signTestPancakeRequest(req, body, ch.webhookSecret)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
@@ -590,7 +601,7 @@ func TestWebhookRouterRoutesWebhookPageID(t *testing.T) {
msgBus := bus.New()
cfg.PageID = "pancake-internal-id"
creds := pancakeCreds{APIKey: "k", PageAccessToken: "t"}
creds := pancakeCreds{APIKey: "k", PageAccessToken: "t", WebhookSecret: "test-secret"}
ch, err := New(cfg, creds, msgBus, nil)
if err != nil {
t.Fatalf("New: %v", err)
@@ -611,6 +622,7 @@ func TestWebhookRouterRoutesWebhookPageID(t *testing.T) {
// Webhook arrives with Facebook native page ID — must route to the channel.
body := buildWebhookBody("fb-native-id", "conv-1", "COMMENT", "user-1", "msg-1", "hello", "")
req := httptest.NewRequest(http.MethodPost, webhookPath, strings.NewReader(body))
signTestPancakeRequest(req, body, ch.webhookSecret)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
@@ -632,10 +644,12 @@ func TestWebhookRouterRoutesWebhookPageID(t *testing.T) {
func TestWebhookRouterRoutesInboxEvent(t *testing.T) {
cfg := pancakeInstanceConfig{}
cfg.Features.InboxReply = true
router, _, msgBus := newTestRouter(t, cfg)
router, ch, msgBus := newTestRouter(t, cfg)
ch.webhookSecret = "test-secret"
body := buildWebhookBody("page-test", "conv-1", "INBOX", "user-1", "msg-2", "inbox msg", "")
req := httptest.NewRequest(http.MethodPost, webhookPath, strings.NewReader(body))
signTestPancakeRequest(req, body, ch.webhookSecret)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
@@ -651,6 +665,77 @@ func TestWebhookRouterRoutesInboxEvent(t *testing.T) {
}
}
func TestWebhookRouterMissingSecretDoesNotDispatch(t *testing.T) {
cfg := pancakeInstanceConfig{}
cfg.Features.InboxReply = true
router, ch, msgBus := newTestRouter(t, cfg)
ch.webhookSecret = ""
body := buildWebhookBody("page-test", "conv-1", "INBOX", "user-1", "msg-2", "inbox msg", "")
req := httptest.NewRequest(http.MethodPost, webhookPath, strings.NewReader(body))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected provider-safe 200, got %d", w.Code)
}
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
if _, ok := msgBus.ConsumeInbound(ctx); ok {
t.Fatal("expected no dispatch when webhook secret is missing")
}
}
func TestWebhookRouterSignatureMismatchDoesNotDispatch(t *testing.T) {
cfg := pancakeInstanceConfig{}
cfg.Features.InboxReply = true
router, ch, msgBus := newTestRouter(t, cfg)
body := buildWebhookBody("page-test", "conv-1", "INBOX", "user-1", "msg-2", "inbox msg", "")
req := httptest.NewRequest(http.MethodPost, webhookPath, strings.NewReader(body))
signTestPancakeRequest(req, body, "wrong-secret")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected provider-safe 200, got %d", w.Code)
}
if ch.webhookSecret == "" {
t.Fatal("test setup error: expected configured webhook secret")
}
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
if _, ok := msgBus.ConsumeInbound(ctx); ok {
t.Fatal("expected no dispatch on signature mismatch")
}
}
func TestWebhookRouterDuplicateSignedBodyDoesNotDispatchTwice(t *testing.T) {
cfg := pancakeInstanceConfig{}
cfg.Features.InboxReply = true
router, ch, msgBus := newTestRouter(t, cfg)
body := buildWebhookBody("page-test", "conv-1", "INBOX", "user-1", "", "inbox msg", "")
for i := 0; i < 2; i++ {
req := httptest.NewRequest(http.MethodPost, webhookPath, strings.NewReader(body))
signTestPancakeRequest(req, body, ch.webhookSecret)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("request %d status = %d, want 200", i+1, w.Code)
}
}
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
if _, ok := msgBus.ConsumeInbound(ctx); !ok {
t.Fatal("expected first signed webhook to dispatch")
}
if _, ok := msgBus.ConsumeInbound(ctx); ok {
t.Fatal("expected duplicate signed webhook body to be skipped")
}
}
func TestWebhookRouterSkipsUnknownType(t *testing.T) {
cfg := pancakeInstanceConfig{}
cfg.Features.CommentReply = true
@@ -675,10 +760,12 @@ func TestWebhookRouterSkipsUnknownType(t *testing.T) {
func TestWebhookRouterCommentNormalizesPostID(t *testing.T) {
cfg := pancakeInstanceConfig{}
cfg.Features.CommentReply = true
router, _, msgBus := newTestRouter(t, cfg)
router, ch, msgBus := newTestRouter(t, cfg)
ch.webhookSecret = "test-secret"
body := buildWebhookBody("page-test", "conv-1", "COMMENT", "user-1", "msg-4", "hello", "post-123")
req := httptest.NewRequest(http.MethodPost, webhookPath, strings.NewReader(body))
signTestPancakeRequest(req, body, ch.webhookSecret)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
@@ -697,9 +784,9 @@ func TestWebhookRouterCommentNormalizesPostID(t *testing.T) {
// multiCaptureTransport records multiple requests (for first-inbox tests).
type multiCaptureTransport struct {
reqs []*http.Request
reqs []*http.Request
bodies [][]byte
mu sync.Mutex
mu sync.Mutex
}
func (t *multiCaptureTransport) RoundTrip(req *http.Request) (*http.Response, error) {
@@ -726,7 +813,7 @@ func newChannelWithMultiCapture(t *testing.T, cfg pancakeInstanceConfig) (*Chann
transport := &multiCaptureTransport{}
msgBus := bus.New()
cfg.PageID = "page-123"
creds := pancakeCreds{APIKey: "k", PageAccessToken: "t"}
creds := pancakeCreds{APIKey: "k", PageAccessToken: "t", WebhookSecret: "test-secret"}
ch, err := New(cfg, creds, msgBus, nil)
if err != nil {
t.Fatalf("New: %v", err)
@@ -1054,7 +1141,7 @@ func TestCommentFlowEndToEnd(t *testing.T) {
transport := &multiCaptureTransport{}
msgBus := bus.New()
cfg.PageID = "page-e2e"
creds := pancakeCreds{APIKey: "k", PageAccessToken: "t"}
creds := pancakeCreds{APIKey: "k", PageAccessToken: "t", WebhookSecret: "test-secret"}
ch, err := New(cfg, creds, msgBus, nil)
if err != nil {
t.Fatalf("New: %v", err)
@@ -1067,6 +1154,7 @@ func TestCommentFlowEndToEnd(t *testing.T) {
// Step 1: POST comment webhook.
body := buildWebhookBody("page-e2e", "conv-e2e", "COMMENT", "user-e2e", "msg-e2e", "great product!", "")
req := httptest.NewRequest(http.MethodPost, webhookPath, strings.NewReader(body))
signTestPancakeRequest(req, body, ch.webhookSecret)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
@@ -1088,8 +1176,8 @@ func TestCommentFlowEndToEnd(t *testing.T) {
// Step 4: Send outbound reply.
outMsg := bus.OutboundMessage{
ChatID: inMsg.ChatID,
Content: "thank you!",
ChatID: inMsg.ChatID,
Content: "thank you!",
Metadata: inMsg.Metadata,
}
if err := ch.Send(context.Background(), outMsg); err != nil {
@@ -1122,6 +1210,7 @@ func TestCommentFlowEndToEnd(t *testing.T) {
// Step 6: Second comment from same sender — stateless: another DM fires.
body2 := buildWebhookBody("page-e2e", "conv-e2e", "COMMENT", "user-e2e", "msg-e2e-2", "another comment", "")
req2 := httptest.NewRequest(http.MethodPost, webhookPath, strings.NewReader(body2))
signTestPancakeRequest(req2, body2, ch.webhookSecret)
w2 := httptest.NewRecorder()
router.ServeHTTP(w2, req2)
+24 -10
View File
@@ -34,6 +34,11 @@ func verifyHMAC(body []byte, secret, signature string) bool {
return hmac.Equal(got, expected)
}
func webhookReplayKey(body []byte) string {
sum := sha256.Sum256(body)
return "webhook:" + hex.EncodeToString(sum[:])
}
// --- Global webhook router for multi-page support ---
// webhookRouter routes incoming Pancake webhook events to the correct channel instance by page_id.
@@ -176,16 +181,25 @@ func (r *webhookRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) {
return
}
// HMAC signature verification — skip if webhook_secret not configured.
if target.webhookSecret != "" {
sig := req.Header.Get("X-Pancake-Signature")
if !verifyHMAC(body, target.webhookSecret, sig) {
slog.Warn("security.pancake_webhook_signature_mismatch",
"page_id", pageID,
"remote_addr", req.RemoteAddr)
w.WriteHeader(http.StatusOK)
return
}
if target.webhookSecret == "" {
slog.Warn("security.pancake_webhook_missing_secret",
"page_id", pageID,
"remote_addr", req.RemoteAddr)
w.WriteHeader(http.StatusOK)
return
}
sig := req.Header.Get("X-Pancake-Signature")
if !verifyHMAC(body, target.webhookSecret, sig) {
slog.Warn("security.pancake_webhook_signature_mismatch",
"page_id", pageID,
"remote_addr", req.RemoteAddr)
w.WriteHeader(http.StatusOK)
return
}
if target.isDup(webhookReplayKey(body)) {
slog.Info("pancake: duplicate webhook skipped", "page_id", pageID)
w.WriteHeader(http.StatusOK)
return
}
// Build normalized MessagingData from actual Pancake payload.
+50 -1
View File
@@ -5,6 +5,8 @@ import (
"encoding/json"
"fmt"
"log/slog"
"net"
"net/netip"
"os"
"path/filepath"
"strconv"
@@ -13,6 +15,54 @@ import (
"github.com/titanous/json5"
)
const GatewayAllowInsecureNoAuthEnv = "GOCLAW_ALLOW_INSECURE_NO_AUTH"
// GatewayNoAuthFallbackAllowed reports whether empty-token gateway auth may
// run in local/dev compatibility mode.
func GatewayNoAuthFallbackAllowed(g GatewayConfig) bool {
if strings.TrimSpace(g.Token) != "" {
return false
}
if insecureNoAuthOptIn() {
return true
}
return isLoopbackGatewayHost(g.Host)
}
// ValidateGatewayAuth fails configurations that would expose the gateway
// without any bearer token.
func ValidateGatewayAuth(g GatewayConfig) error {
if strings.TrimSpace(g.Token) != "" || GatewayNoAuthFallbackAllowed(g) {
return nil
}
return fmt.Errorf("gateway token is required when GOCLAW_HOST=%q; set GOCLAW_GATEWAY_TOKEN or explicit %s=1 for local development only", g.Host, GatewayAllowInsecureNoAuthEnv)
}
func insecureNoAuthOptIn() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv(GatewayAllowInsecureNoAuthEnv))) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
func isLoopbackGatewayHost(host string) bool {
host = strings.TrimSpace(host)
if host == "" {
return false
}
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
host = strings.Trim(host, "[]")
if strings.EqualFold(host, "localhost") {
return true
}
addr, err := netip.ParseAddr(host)
return err == nil && addr.IsLoopback()
}
// Default returns a Config with sensible defaults.
func Default() *Config {
return &Config{
@@ -285,7 +335,6 @@ func (c *Config) applyEnvOverrides() {
}
}
// Save writes the config to a JSON file.
func Save(path string, cfg *Config) error {
cfg.mu.RLock()
+33
View File
@@ -116,6 +116,39 @@ func TestLoad_EnvVarOverrides_InvalidPort(t *testing.T) {
}
}
func TestValidateGatewayAuthRejectsExternalNoToken(t *testing.T) {
cfg := Default()
cfg.Gateway.Host = "0.0.0.0"
cfg.Gateway.Token = ""
t.Setenv(GatewayAllowInsecureNoAuthEnv, "")
if err := ValidateGatewayAuth(cfg.Gateway); err == nil {
t.Fatal("expected external bind with empty gateway token to fail")
}
}
func TestValidateGatewayAuthAllowsLoopbackNoToken(t *testing.T) {
cfg := Default()
cfg.Gateway.Host = "127.0.0.1"
cfg.Gateway.Token = ""
t.Setenv(GatewayAllowInsecureNoAuthEnv, "")
if err := ValidateGatewayAuth(cfg.Gateway); err != nil {
t.Fatalf("loopback no-token mode should be allowed: %v", err)
}
}
func TestValidateGatewayAuthAllowsExplicitInsecureOptIn(t *testing.T) {
cfg := Default()
cfg.Gateway.Host = "0.0.0.0"
cfg.Gateway.Token = ""
t.Setenv(GatewayAllowInsecureNoAuthEnv, "1")
if err := ValidateGatewayAuth(cfg.Gateway); err != nil {
t.Fatalf("explicit insecure opt-in should allow no-token mode: %v", err)
}
}
// --- Env var for API keys ---
func TestLoad_EnvVarAPIKeys(t *testing.T) {
+3 -2
View File
@@ -10,6 +10,7 @@ import (
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/cache"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/edition"
httpapi "github.com/nextlevelbuilder/goclaw/internal/http"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
@@ -233,7 +234,7 @@ func (r *MethodRouter) handleConnect(ctx context.Context, client *Client, req *p
}
// Path 2: No token configured → operator (backward compat)
if configToken == "" {
if configToken == "" && config.GatewayNoAuthFallbackAllowed(r.server.cfg.Gateway) {
client.role = permissions.RoleOperator
client.authenticated = true
client.userID = params.UserID
@@ -260,7 +261,7 @@ func (r *MethodRouter) handleConnect(ctx context.Context, client *Client, req *p
if paired {
client.role = permissions.RoleOperator
client.authenticated = true
client.userID = params.UserID
client.userID = params.UserID
client.pairedSenderID = params.SenderID
client.pairedChannel = "browser"
tid, errCode := r.resolveTenantHint(ctx, params.TenantHint, params.UserID)
+64
View File
@@ -0,0 +1,64 @@
package gateway
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
func TestHandleConnectRejectsNoTokenExternalBind(t *testing.T) {
cfg := config.Default()
cfg.Gateway.Host = "0.0.0.0"
cfg.Gateway.Token = ""
t.Setenv(config.GatewayAllowInsecureNoAuthEnv, "")
server := NewServer(cfg, nil, nil, nil)
client := NewClient(nil, server, "203.0.113.10")
req := &protocol.RequestFrame{ID: "req-1", Method: protocol.MethodConnect}
server.router.Handle(context.Background(), client, req)
if client.authenticated {
t.Fatal("expected unauthenticated client for external no-token connect")
}
if client.role != "" {
t.Fatalf("role = %q, want empty", client.role)
}
select {
case raw := <-client.send:
var resp protocol.ResponseFrame
if err := json.Unmarshal(raw, &resp); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if resp.Error == nil || resp.Error.Code != protocol.ErrUnauthorized {
t.Fatalf("response error = %#v, want unauthorized", resp.Error)
}
case <-time.After(500 * time.Millisecond):
t.Fatal("expected unauthorized response")
}
}
func TestHandleConnectAllowsExplicitInsecureNoTokenOptIn(t *testing.T) {
cfg := config.Default()
cfg.Gateway.Host = "0.0.0.0"
cfg.Gateway.Token = ""
t.Setenv(config.GatewayAllowInsecureNoAuthEnv, "1")
server := NewServer(cfg, nil, nil, nil)
client := NewClient(nil, server, "127.0.0.1")
req := &protocol.RequestFrame{ID: "req-1", Method: protocol.MethodConnect}
server.router.Handle(context.Background(), client, req)
if !client.authenticated {
t.Fatal("expected authenticated client with explicit insecure opt-in")
}
if client.role != permissions.RoleOperator {
t.Fatalf("role = %q, want operator", client.role)
}
}
+9 -2
View File
@@ -79,6 +79,7 @@ func extractAgentID(r *http.Request, model string) string {
// --- Package-level API key cache for shared auth ---
var pkgGatewayToken string
var pkgNoAuthFallbackAllowed = true
var pkgAPIKeyCache *apiKeyCache
var pkgPairingStore store.PairingStore
var pkgTenantCache *tenantCache
@@ -90,6 +91,12 @@ func InitGatewayToken(token string) {
pkgGatewayToken = token
}
// InitGatewayNoAuthFallbackAllowed controls the legacy empty-token local/dev
// fallback after startup config validation.
func InitGatewayNoAuthFallbackAllowed(allowed bool) {
pkgNoAuthFallbackAllowed = allowed
}
// InitAPIKeyCache initializes the shared API key cache with TTL and pubsub invalidation.
// Must be called once during server startup before handling requests.
func InitAPIKeyCache(s store.APIKeyStore, mb *bus.MessageBus) {
@@ -233,8 +240,8 @@ func resolveAuthWithBearer(r *http.Request, bearer string) authResult {
slog.Warn("security.http_pairing_auth_failed", "sender_id", senderID, "ip", r.RemoteAddr)
}
}
// No auth configured → admin (no token = dev/single-user mode, full access)
if pkgGatewayToken == "" {
// No auth configured → admin only when startup allowed local/dev fallback.
if pkgGatewayToken == "" && pkgNoAuthFallbackAllowed {
return authResult{Role: permissions.RoleAdmin, Authenticated: true, TenantID: store.MasterTenantID}
}
return authResult{}
+21
View File
@@ -36,6 +36,13 @@ func setupTestToken(t *testing.T, token string) {
t.Cleanup(func() { pkgGatewayToken = old })
}
func setupTestNoAuthFallback(t *testing.T, allowed bool) {
t.Helper()
old := pkgNoAuthFallbackAllowed
pkgNoAuthFallbackAllowed = allowed
t.Cleanup(func() { pkgNoAuthFallbackAllowed = old })
}
func setupTestTenantStore(t *testing.T, ts store.TenantStore) {
t.Helper()
old := pkgTenantCache
@@ -221,6 +228,7 @@ func TestResolveAuth_WrongToken(t *testing.T) {
func TestResolveAuth_NoAuthConfigured(t *testing.T) {
setupTestCache(t, nil)
setupTestNoAuthFallback(t, true)
r := httptest.NewRequest("GET", "/v1/agents", nil)
@@ -233,6 +241,19 @@ func TestResolveAuth_NoAuthConfigured(t *testing.T) {
}
}
func TestResolveAuth_NoAuthConfiguredDisallowed(t *testing.T) {
setupTestCache(t, nil)
setupTestToken(t, "")
setupTestNoAuthFallback(t, false)
r := httptest.NewRequest("GET", "/v1/agents", nil)
auth := resolveAuth(r)
if auth.Authenticated {
t.Fatal("expected unauthenticated when no-token fallback is disabled")
}
}
func TestResolveAuth_APIKeyReadScope(t *testing.T) {
// We need to hash the token the same way crypto.HashAPIKey does
// For testing, we'll inject directly into the cache
+165 -91
View File
@@ -25,6 +25,8 @@ type FilesHandler struct {
dataDir string // data directory root for tenant path validation
}
var filesAfterOpenHookForTest func(string)
// NewFilesHandler creates a handler that serves files by absolute path.
// workspace is the root directory used for fallback generated file search.
// dataDir is used for tenant path validation (files must be within tenant's dirs).
@@ -54,39 +56,15 @@ func (h *FilesHandler) handleSign(w http.ResponseWriter, r *http.Request) {
return
}
// Validate path is within workspace or dataDir before signing.
// Defense-in-depth: prevents signing tokens for arbitrary system files.
absPath := filepath.Clean(body.Path)
if !filepath.IsAbs(absPath) {
// Windows drive letter path (e.g. "C:\...") — keep as-is, consistent with handleServe.
if len(absPath) >= 2 && absPath[1] == ':' {
// already absolute on Windows
} else {
absPath = filepath.Clean("/" + absPath)
}
}
sep := string(filepath.Separator)
if (h.workspace == "" || (!strings.HasPrefix(absPath, h.workspace+sep) && absPath != h.workspace)) &&
(h.dataDir == "" || (!strings.HasPrefix(absPath, h.dataDir+sep) && absPath != h.dataDir)) {
slog.Warn("security.files_sign_path_denied", "path", absPath, "workspace", h.workspace, "data_dir", h.dataDir)
absPath := absoluteFilePath(body.Path)
file, _, _, ok := h.openValidatedFile(authedReq, absPath, false)
if !ok {
http.Error(w, `{"error":"path outside allowed directories"}`, http.StatusForbidden)
return
}
_ = file.Close()
// Multi-tenant (RBAC): additionally restrict to the requesting tenant's dirs.
// Prevents tenant A from signing a URL for tenant B's files.
if edition.Current().RBACEnabled {
tenantData := config.TenantDataDir(h.dataDir, store.TenantIDFromContext(authedReq.Context()), store.TenantSlugFromContext(authedReq.Context()))
tenantWs := config.TenantWorkspace(h.workspace, store.TenantIDFromContext(authedReq.Context()), store.TenantSlugFromContext(authedReq.Context()))
if (!strings.HasPrefix(absPath, tenantData+sep) && absPath != tenantData) &&
(!strings.HasPrefix(absPath, tenantWs+sep) && absPath != tenantWs) {
slog.Warn("security.files_sign_tenant_denied", "path", absPath, "tenant_data", tenantData, "tenant_ws", tenantWs)
http.Error(w, `{"error":"path outside allowed directories"}`, http.StatusForbidden)
return
}
}
urlPath := "/v1/files/" + strings.TrimPrefix(filepath.Clean(body.Path), "/")
urlPath := fileURLPath(absPath)
ft := SignFileToken(urlPath, FileSigningKey(), FileTokenTTL)
writeJSON(w, http.StatusOK, map[string]string{
"url": urlPath + "?ft=" + ft,
@@ -139,69 +117,20 @@ func (h *FilesHandler) handleServe(w http.ResponseWriter, r *http.Request) {
return
}
// URL path is the absolute path with leading "/" stripped (e.g. "app/.goclaw/workspace/file.png")
// Windows drive letter: "C:/Users/..." → use directly without prepending "/"
var absPath string
if len(urlPath) >= 2 && urlPath[1] == ':' {
absPath = filepath.Clean(urlPath)
} else {
absPath = filepath.Clean("/" + urlPath)
}
absPath := absoluteFilePath(urlPath)
// Block access to sensitive system directories
for _, prefix := range deniedFilePrefixes {
if strings.HasPrefix(absPath, prefix) {
slog.Warn("security.files_denied_path", "path", absPath)
http.Error(w, i18n.T(locale, i18n.MsgInvalidPath), http.StatusForbidden)
return
}
if hasDeniedFilePrefix(absPath) {
slog.Warn("security.files_denied_path", "path", absPath)
http.Error(w, i18n.T(locale, i18n.MsgInvalidPath), http.StatusForbidden)
return
}
// Defense-in-depth: validate workspace/dataDir boundary even for signed file tokens.
// The token cryptographically binds the URL path, but we also verify the resolved
// absolute path stays within allowed directories to limit blast radius of any
// bug in the signing flow.
if r.URL.Query().Get("ft") != "" {
sep := string(filepath.Separator)
inWorkspace := h.workspace != "" && (strings.HasPrefix(absPath, h.workspace+sep) || absPath == h.workspace)
inDataDir := h.dataDir != "" && (strings.HasPrefix(absPath, h.dataDir+sep) || absPath == h.dataDir)
if !inWorkspace && !inDataDir {
slog.Warn("security.files_ft_path_denied", "path", absPath, "workspace", h.workspace, "data_dir", h.dataDir)
http.NotFound(w, r)
return
}
}
// Path isolation: validate file path is within allowed directories.
if r.URL.Query().Get("ft") == "" {
allowed := false
// Always allow files within workspace root and data dir root.
// These are the two top-level directories that contain all user files.
sep := string(filepath.Separator)
if h.workspace != "" && (strings.HasPrefix(absPath, h.workspace+sep) || absPath == h.workspace) {
allowed = true
}
if !allowed && h.dataDir != "" && (strings.HasPrefix(absPath, h.dataDir+sep) || absPath == h.dataDir) {
allowed = true
}
// Multi-tenant (standard edition): additionally restrict to tenant-scoped subdirectories.
if allowed && edition.Current().RBACEnabled {
tenantData := config.TenantDataDir(h.dataDir, store.TenantIDFromContext(r.Context()), store.TenantSlugFromContext(r.Context()))
tenantWs := h.tenantWorkspace(r)
if !strings.HasPrefix(absPath, tenantData+sep) &&
!strings.HasPrefix(absPath, tenantWs+sep) &&
absPath != tenantData && absPath != tenantWs {
allowed = false
}
}
if !allowed {
slog.Warn("security.files_path_denied", "path", absPath, "workspace", h.workspace, "data_dir", h.dataDir)
http.NotFound(w, r)
return
}
signed := r.URL.Query().Get("ft") != ""
if !h.lexicallyAllowsFilePath(r, absPath, signed) {
slog.Warn("security.files_path_denied", "path", absPath, "workspace", h.workspace, "data_dir", h.dataDir)
http.NotFound(w, r)
return
}
info, err := os.Stat(absPath)
@@ -221,7 +150,7 @@ func (h *FilesHandler) handleServe(w http.ResponseWriter, r *http.Request) {
// For ft= signed requests, the path is cryptographically bound — no fallback search.
// Searching the global workspace could cross tenant boundaries if a same-basename
// file exists in another tenant's directory.
if r.URL.Query().Get("ft") != "" {
if signed {
http.NotFound(w, r)
return
}
@@ -237,6 +166,13 @@ func (h *FilesHandler) handleServe(w http.ResponseWriter, r *http.Request) {
return
}
}
file, realPath, fileInfo, ok := h.openValidatedFile(r, absPath, signed)
if !ok {
http.NotFound(w, r)
return
}
defer file.Close()
absPath = realPath
// Set Content-Type from extension
ext := filepath.Ext(absPath)
@@ -250,7 +186,146 @@ func (h *FilesHandler) handleServe(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filepath.Base(absPath)))
}
http.ServeFile(w, r, absPath)
http.ServeContent(w, r, filepath.Base(absPath), fileInfo.ModTime(), file)
}
func absoluteFilePath(path string) string {
absPath := filepath.Clean(path)
if filepath.IsAbs(absPath) {
return absPath
}
// Windows drive letter path (e.g. "C:\...") is absolute to this handler.
if len(absPath) >= 2 && absPath[1] == ':' {
return absPath
}
return filepath.Clean(string(filepath.Separator) + absPath)
}
func fileURLPath(absPath string) string {
return "/v1/files/" + strings.TrimPrefix(filepath.Clean(absPath), string(filepath.Separator))
}
func hasDeniedFilePrefix(path string) bool {
cleaned := filepath.Clean(path)
for _, prefix := range deniedFilePrefixes {
root := filepath.Clean(prefix)
if pathWithinDir(cleaned, root) {
return true
}
}
return false
}
func configuredFileRoot(root string) string {
if root == "" {
return ""
}
return filepath.Clean(root)
}
func canonicalFileRoots(roots []string) []string {
out := make([]string, 0, len(roots))
for _, root := range roots {
if root = configuredFileRoot(root); root != "" {
out = append(out, evalSymlinkOrClean(root))
}
}
return out
}
func (h *FilesHandler) requestFileRoots(r *http.Request, signed bool, absPath string) []string {
if signed {
return []string{
inferredScopedFileRoot(h.workspace, absPath),
inferredScopedFileRoot(h.dataDir, absPath),
}
}
if edition.Current().RBACEnabled {
return []string{
config.TenantWorkspace(h.workspace, store.TenantIDFromContext(r.Context()), store.TenantSlugFromContext(r.Context())),
config.TenantDataDir(h.dataDir, store.TenantIDFromContext(r.Context()), store.TenantSlugFromContext(r.Context())),
}
}
return []string{h.workspace, h.dataDir}
}
func inferredScopedFileRoot(base, absPath string) string {
base = configuredFileRoot(base)
if base == "" || !pathWithinDir(filepath.Clean(absPath), base) {
return ""
}
tenantsRoot := filepath.Join(base, "tenants")
if !pathWithinDir(filepath.Clean(absPath), tenantsRoot) || filepath.Clean(absPath) == tenantsRoot {
return base
}
rel, err := filepath.Rel(tenantsRoot, filepath.Clean(absPath))
if err != nil {
return ""
}
first, _, _ := strings.Cut(rel, string(filepath.Separator))
if first == "" || first == "." || first == ".." {
return ""
}
return filepath.Join(tenantsRoot, first)
}
func filePathWithinAnyRoot(path string, roots []string) bool {
for _, root := range roots {
if root != "" && pathWithinDir(filepath.Clean(path), filepath.Clean(root)) {
return true
}
}
return false
}
func (h *FilesHandler) lexicallyAllowsFilePath(r *http.Request, absPath string, signed bool) bool {
return filePathWithinAnyRoot(absPath, h.requestFileRoots(r, signed, absPath))
}
func (h *FilesHandler) openValidatedFile(r *http.Request, absPath string, signed bool) (*os.File, string, os.FileInfo, bool) {
file, err := os.Open(absPath)
if err != nil {
return nil, "", nil, false
}
if filesAfterOpenHookForTest != nil {
filesAfterOpenHookForTest(absPath)
}
realPath, err := filepath.EvalSymlinks(absPath)
if err != nil {
slog.Warn("security.files_path_unresolved", "path", absPath, "error", err)
_ = file.Close()
return nil, "", nil, false
}
realPath = filepath.Clean(realPath)
if hasDeniedFilePrefix(realPath) {
slog.Warn("security.files_realpath_denied", "path", absPath, "resolved", realPath)
_ = file.Close()
return nil, "", nil, false
}
roots := canonicalFileRoots(h.requestFileRoots(r, signed, absPath))
if !filePathWithinAnyRoot(realPath, roots) {
slog.Warn("security.files_realpath_escape", "path", absPath, "resolved", realPath, "roots", roots)
_ = file.Close()
return nil, "", nil, false
}
realInfo, err := os.Stat(realPath)
if err != nil {
_ = file.Close()
return nil, "", nil, false
}
fileInfo, err := file.Stat()
if err != nil {
_ = file.Close()
slog.Warn("security.files_open_race", "path", realPath, "error", err)
return nil, "", nil, false
}
if fileInfo.IsDir() || realInfo.IsDir() || !os.SameFile(realInfo, fileInfo) {
_ = file.Close()
slog.Warn("security.files_open_race", "path", realPath)
return nil, "", nil, false
}
return file, realPath, fileInfo, true
}
// tenantWorkspace resolves the workspace scoped to the requesting tenant.
@@ -339,4 +414,3 @@ func isNumeric(s string) bool {
}
return len(s) > 0
}
+89
View File
@@ -5,6 +5,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)
@@ -141,6 +142,94 @@ func TestFilesHandleServe_FileOutsideAllDirs_WithToken_Returns404(t *testing.T)
}
}
func TestFilesHandleServe_SignedSymlinkEscape_Returns404(t *testing.T) {
h, workspace := makeTestFilesHandler(t)
outsideDir := t.TempDir()
target := filepath.Join(outsideDir, "secret.txt")
if err := os.WriteFile(target, []byte("secret"), 0644); err != nil {
t.Fatal(err)
}
linkPath := filepath.Join(workspace, "link.txt")
if err := os.Symlink(target, linkPath); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
urlPath := "/v1/files/" + strings.TrimPrefix(filepath.Clean(linkPath), "/")
ft := SignFileToken(urlPath, FileSigningKey(), FileTokenTTL)
mux := http.NewServeMux()
mux.HandleFunc("GET /v1/files/{path...}", h.handleServe)
req := httptest.NewRequest(http.MethodGet, urlPath+"?ft="+ft, nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code == http.StatusOK {
t.Fatal("signed symlink escaping workspace should not be served")
}
}
func TestFilesHandleServe_OpenThenSwapToSymlinkEscape_Returns404(t *testing.T) {
h, workspace := makeTestFilesHandler(t)
outsideDir := t.TempDir()
secretPath := filepath.Join(outsideDir, "secret.txt")
if err := os.WriteFile(secretPath, []byte("secret"), 0644); err != nil {
t.Fatal(err)
}
filePath := filepath.Join(workspace, "race.txt")
if err := os.WriteFile(filePath, []byte("allowed"), 0644); err != nil {
t.Fatal(err)
}
filesAfterOpenHookForTest = func(opened string) {
if opened != filePath {
return
}
_ = os.Remove(filePath)
_ = os.Symlink(secretPath, filePath)
}
defer func() { filesAfterOpenHookForTest = nil }()
urlPath := "/v1/files/" + strings.TrimPrefix(filepath.Clean(filePath), "/")
ft := SignFileToken(urlPath, FileSigningKey(), FileTokenTTL)
mux := http.NewServeMux()
mux.HandleFunc("GET /v1/files/{path...}", h.handleServe)
req := httptest.NewRequest(http.MethodGet, urlPath+"?ft="+ft, nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code == http.StatusOK {
t.Fatal("file swapped to escaping symlink after open should not be served")
}
if strings.Contains(w.Body.String(), "secret") {
t.Fatal("response leaked swapped outside file content")
}
}
func TestFilesHandleSign_SymlinkEscape_ReturnsForbidden(t *testing.T) {
setupTestToken(t, "")
setupTestNoAuthFallback(t, true)
h, workspace := makeTestFilesHandler(t)
outsideDir := t.TempDir()
target := filepath.Join(outsideDir, "secret.txt")
if err := os.WriteFile(target, []byte("secret"), 0644); err != nil {
t.Fatal(err)
}
linkPath := filepath.Join(workspace, "link.txt")
if err := os.Symlink(target, linkPath); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/v1/files/sign", strings.NewReader(`{"path":`+strconv.Quote(linkPath)+`}`))
w := httptest.NewRecorder()
h.handleSign(w, req)
if w.Code == http.StatusOK {
t.Fatal("sign endpoint should reject symlinks escaping allowed roots")
}
}
// ---- handleServe: empty path ----
func TestFilesHandleServe_EmptyPath_Returns400(t *testing.T) {
+1 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "GoClaw Gateway API",
"description": "PostgreSQL multi-tenant AI agent gateway with WebSocket RPC + HTTP API.\n\n## Authentication\n\nAll endpoints require a Bearer token in the `Authorization` header:\n\n```\nAuthorization: Bearer <gateway-token-or-api-key>\n```\n\nYou can use either the **gateway token** (grants admin access) or an **API key** created via the API Keys endpoints (grants scoped access).\n\nIf no token is configured on the server, authentication is disabled (backward compatibility).\n\n## Common Headers\n\n| Header | Description |\n|--------|-------------|\n| `X-GoClaw-User-Id` | External user ID for multi-tenant context |\n| `X-GoClaw-Agent-Id` | Target agent ID (alternative to model prefix) |\n| `X-GoClaw-Tenant-Id` | Tenant scope — UUID or slug (gateway token / cross-tenant API keys) |\n| `Accept-Language` | Locale for error messages (`en`, `vi`, `zh`) |\n\n## WebSocket Protocol\n\nConnect via `POST /ws` (upgrade). Protocol v3 uses frame types: `req`, `res`, `event`.\nFirst request must be `connect` with `{\"token\": \"...\", \"user_id\": \"...\", \"locale\": \"en\"}`.",
"description": "PostgreSQL multi-tenant AI agent gateway with WebSocket RPC + HTTP API.\n\n## Authentication\n\nAll endpoints require a Bearer token in the `Authorization` header:\n\n```\nAuthorization: Bearer <gateway-token-or-api-key>\n```\n\nYou can use either the **gateway token** (grants admin access) or an **API key** created via the API Keys endpoints (grants scoped access).\n\nIf no gateway token is configured, empty-token admin access is limited to loopback local development or explicit `GOCLAW_ALLOW_INSECURE_NO_AUTH=1`; externally reachable deployments require `GOCLAW_GATEWAY_TOKEN`.\n\n## Common Headers\n\n| Header | Description |\n|--------|-------------|\n| `X-GoClaw-User-Id` | External user ID for multi-tenant context |\n| `X-GoClaw-Agent-Id` | Target agent ID (alternative to model prefix) |\n| `X-GoClaw-Tenant-Id` | Tenant scope — UUID or slug (gateway token / cross-tenant API keys) |\n| `Accept-Language` | Locale for error messages (`en`, `vi`, `zh`) |\n\n## WebSocket Protocol\n\nConnect via `POST /ws` (upgrade). Protocol v3 uses frame types: `req`, `res`, `event`.\nFirst request must be `connect` with `{\"token\": \"...\", \"user_id\": \"...\", \"locale\": \"en\"}`.",
"version": "0.2.0",
"contact": {
"name": "GoClaw",
+137 -35
View File
@@ -34,30 +34,48 @@ type sizeCacheEntry struct {
type StorageHandler struct {
baseDir string // global data dir (resolved absolute path to ~/.goclaw/)
tenants store.TenantStore
// sizeCache caches the total storage size per tenant for 60 minutes.
sizeCache sync.Map // tenantBaseDir (string) → *sizeCacheEntry
}
// NewStorageHandler creates a handler for workspace storage management.
func NewStorageHandler(baseDir string) *StorageHandler {
return &StorageHandler{baseDir: baseDir}
func NewStorageHandler(baseDir string, tenants ...store.TenantStore) *StorageHandler {
h := &StorageHandler{baseDir: baseDir}
if len(tenants) > 0 {
h.tenants = tenants[0]
}
return h
}
// RegisterRoutes registers storage management routes on the given mux.
func (h *StorageHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /v1/storage/files", h.auth(h.handleList))
mux.HandleFunc("GET /v1/storage/files/{path...}", h.auth(h.handleRead))
mux.HandleFunc("DELETE /v1/storage/files/{path...}", h.auth(h.handleDelete))
mux.HandleFunc("DELETE /v1/storage/files/{path...}", requireAuth(permissions.RoleAdmin, h.requireTenantAdmin(h.handleDelete)))
mux.HandleFunc("GET /v1/storage/size", h.auth(h.handleSize))
mux.HandleFunc("POST /v1/storage/files", requireAuth(permissions.RoleAdmin, h.handleUpload))
mux.HandleFunc("PUT /v1/storage/move", requireAuth(permissions.RoleAdmin, h.handleMove))
mux.HandleFunc("POST /v1/storage/files", requireAuth(permissions.RoleAdmin, h.requireTenantAdmin(h.handleUpload)))
mux.HandleFunc("PUT /v1/storage/move", requireAuth(permissions.RoleAdmin, h.requireTenantAdmin(h.handleMove)))
}
func (h *StorageHandler) auth(next http.HandlerFunc) http.HandlerFunc {
return requireAuth("", next)
}
func (h *StorageHandler) requireTenantAdmin(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if pkgGatewayToken == "" && store.TenantIDFromContext(r.Context()) == store.MasterTenantID {
next(w, r)
return
}
if !requireTenantAdmin(w, r, h.tenants) {
return
}
next(w, r)
}
}
// tenantBaseDir resolves the data directory scoped to the requesting tenant.
// Master tenant returns the global baseDir (backward compat).
func (h *StorageHandler) tenantBaseDir(r *http.Request) string {
@@ -102,6 +120,75 @@ func (h *StorageHandler) isHiddenPath(r *http.Request, rel string) bool {
return strings.EqualFold(topLevelPath(rel), "tenants")
}
func pathWithinDir(path, dir string) bool {
rel, err := filepath.Rel(dir, path)
if err != nil {
return false
}
return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
}
func evalSymlinkOrClean(path string) string {
realPath, err := filepath.EvalSymlinks(path)
if err == nil {
return filepath.Clean(realPath)
}
return filepath.Clean(path)
}
func (h *StorageHandler) isHiddenRealPath(r *http.Request, base, realPath string) bool {
if store.TenantIDFromContext(r.Context()) != store.MasterTenantID {
return false
}
realTenantRoot, err := filepath.EvalSymlinks(filepath.Join(base, "tenants"))
if err != nil {
return false
}
return pathWithinDir(filepath.Clean(realPath), filepath.Clean(realTenantRoot))
}
func (h *StorageHandler) validateExistingStoragePath(r *http.Request, base, absPath string) bool {
realBase := evalSymlinkOrClean(base)
realPath, err := filepath.EvalSymlinks(absPath)
if err != nil {
return false
}
realPath = filepath.Clean(realPath)
if !pathWithinDir(realPath, realBase) {
slog.Warn("security.storage_symlink_escape", "resolved", realPath, "base", realBase)
return false
}
if h.isHiddenRealPath(r, base, realPath) {
slog.Warn("security.storage_hidden_symlink_path", "resolved", realPath, "base", realBase)
return false
}
return true
}
func (h *StorageHandler) validateStorageParent(r *http.Request, base, parent string) bool {
realBase := evalSymlinkOrClean(base)
current := filepath.Clean(parent)
for {
if realParent, err := filepath.EvalSymlinks(current); err == nil {
realParent = filepath.Clean(realParent)
if !pathWithinDir(realParent, realBase) {
slog.Warn("security.storage_parent_escape", "resolved", realParent, "base", realBase)
return false
}
if h.isHiddenRealPath(r, base, realParent) {
slog.Warn("security.storage_hidden_parent", "resolved", realParent, "base", realBase)
return false
}
return true
}
next := filepath.Dir(current)
if next == current {
return false
}
current = next
}
}
// handleList lists files and directories under ~/.goclaw/ with depth limiting.
// Query params:
// - ?path= scopes the listing to a subtree
@@ -349,6 +436,10 @@ func (h *StorageHandler) handleRead(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)})
return
}
if !h.validateExistingStoragePath(r, readBase, absPath) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgFileNotFound)})
return
}
data, err := os.ReadFile(absPath)
if err != nil {
@@ -413,6 +504,10 @@ func (h *StorageHandler) handleDelete(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "path", relPath)})
return
}
if !h.validateExistingStoragePath(r, delBase, absPath) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "path", relPath)})
return
}
if info.Mode()&os.ModeSymlink != 0 {
// Remove symlink itself, not target
@@ -494,41 +589,46 @@ func (h *StorageHandler) handleUpload(w http.ResponseWriter, r *http.Request) {
}
}
if !h.validateStorageParent(r, base, targetDir) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)})
return
}
if err := os.MkdirAll(targetDir, 0750); err != nil {
slog.Error("storage.upload_mkdir_failed", "dir", targetDir, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "failed to create directory")})
return
}
diskPath := filepath.Join(targetDir, origName)
// Symlink escape check on resolved path.
realTarget, _ := filepath.EvalSymlinks(targetDir)
if realTarget == "" {
realTarget = targetDir
}
realBase, _ := filepath.EvalSymlinks(base)
if realBase == "" {
realBase = base
}
if !strings.HasPrefix(realTarget, realBase) {
slog.Warn("security.storage_upload_symlink_escape", "target", realTarget, "base", realBase)
if !h.validateStorageParent(r, base, targetDir) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)})
return
}
// Write file.
out, err := os.Create(diskPath)
diskPath := filepath.Join(targetDir, origName)
out, err := os.CreateTemp(targetDir, ".upload-*")
if err != nil {
slog.Error("storage.upload_create_failed", "path", diskPath, "error", err)
slog.Error("storage.upload_create_failed", "dir", targetDir, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "failed to save file")})
return
}
defer out.Close()
tmpPath := out.Name()
defer os.Remove(tmpPath)
written, err := io.Copy(out, file)
if err != nil {
os.Remove(diskPath)
out.Close()
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "failed to save file")})
return
}
if err := out.Close(); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "failed to save file")})
return
}
if !h.validateStorageParent(r, base, targetDir) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)})
return
}
if err := os.Rename(tmpPath, diskPath); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "failed to save file")})
return
}
@@ -588,15 +688,18 @@ func (h *StorageHandler) handleMove(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgFileNotFound)})
return
}
baseReal, _ := filepath.EvalSymlinks(base)
if baseReal == "" {
baseReal = base
}
if !strings.HasPrefix(srcReal, baseReal+string(filepath.Separator)) {
baseReal := evalSymlinkOrClean(base)
srcReal = filepath.Clean(srcReal)
if !pathWithinDir(srcReal, baseReal) {
slog.Warn("security.storage_move_src_escape", "resolved", srcReal, "base", baseReal)
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)})
return
}
if h.isHiddenRealPath(r, base, srcReal) {
slog.Warn("security.storage_move_hidden_src", "resolved", srcReal, "base", baseReal)
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)})
return
}
// Resolve and validate destination path.
destAbs := filepath.Join(base, filepath.Clean(toRel))
@@ -606,12 +709,7 @@ func (h *StorageHandler) handleMove(w http.ResponseWriter, r *http.Request) {
}
// Ensure destination parent exists.
destDir := filepath.Dir(destAbs)
destDirReal, _ := filepath.EvalSymlinks(destDir)
if destDirReal == "" {
destDirReal = destDir
}
if !strings.HasPrefix(destDirReal+string(filepath.Separator), baseReal+string(filepath.Separator)) {
slog.Warn("security.storage_move_dest_escape", "resolved", destDirReal, "base", baseReal)
if !h.validateStorageParent(r, base, destDir) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)})
return
}
@@ -620,6 +718,10 @@ func (h *StorageHandler) handleMove(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "failed to create directory")})
return
}
if !h.validateStorageParent(r, base, destDir) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)})
return
}
// Prevent overwriting existing file.
if _, err := os.Stat(destAbs); err == nil {
+211
View File
@@ -3,6 +3,7 @@ package http
import (
"context"
"encoding/json"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
@@ -88,6 +89,52 @@ func TestStorageReadTenantRootReturnsNotFoundForMaster(t *testing.T) {
}
}
func TestStorageReadRejectsSymlinkedTenantParentForMaster(t *testing.T) {
baseDir := t.TempDir()
tenantSecret := filepath.Join(baseDir, "tenants", "tenant-a", "secret.txt")
writeStorageTestFile(t, tenantSecret, "tenant-secret")
if err := os.Symlink(filepath.Join(baseDir, "tenants"), filepath.Join(baseDir, "tenant-link")); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
handler := NewStorageHandler(baseDir)
req := httptest.NewRequest("GET", "/v1/storage/files/tenant-link/tenant-a/secret.txt", nil)
req = req.WithContext(store.WithTenantID(context.Background(), store.MasterTenantID))
req.SetPathValue("path", "tenant-link/tenant-a/secret.txt")
w := httptest.NewRecorder()
handler.handleRead(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", w.Code)
}
if strings.Contains(w.Body.String(), "tenant-secret") {
t.Fatal("response leaked tenant secret through symlinked parent")
}
}
func TestStorageDeleteRejectsSymlinkedTenantParentForMaster(t *testing.T) {
baseDir := t.TempDir()
tenantSecret := filepath.Join(baseDir, "tenants", "tenant-a", "secret.txt")
writeStorageTestFile(t, tenantSecret, "tenant-secret")
if err := os.Symlink(filepath.Join(baseDir, "tenants"), filepath.Join(baseDir, "tenant-link")); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
handler := NewStorageHandler(baseDir)
req := httptest.NewRequest(http.MethodDelete, "/v1/storage/files/tenant-link/tenant-a/secret.txt", nil)
req = req.WithContext(store.WithTenantID(context.Background(), store.MasterTenantID))
req.SetPathValue("path", "tenant-link/tenant-a/secret.txt")
w := httptest.NewRecorder()
handler.handleDelete(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", w.Code)
}
if _, err := os.Stat(tenantSecret); err != nil {
t.Fatalf("tenant secret should not be deleted through symlinked parent: %v", err)
}
}
func TestStorageSizeExcludesTenantRootForMaster(t *testing.T) {
baseDir := t.TempDir()
writeStorageTestFile(t, filepath.Join(baseDir, "master.txt"), "12345")
@@ -221,3 +268,167 @@ func TestStorageMoveInvalidatesSizeCache(t *testing.T) {
t.Fatal("expected size cache entry to be invalidated after move")
}
}
func TestStorageMoveRejectsSymlinkedTenantDestinationParent(t *testing.T) {
baseDir := t.TempDir()
writeStorageTestFile(t, filepath.Join(baseDir, "from.txt"), "abc")
writeStorageTestFile(t, filepath.Join(baseDir, "tenants", "tenant-a", ".keep"), "")
if err := os.Symlink(filepath.Join(baseDir, "tenants", "tenant-a"), filepath.Join(baseDir, "tenant-link")); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
handler := NewStorageHandler(baseDir)
req := httptest.NewRequest(http.MethodPut, "/v1/storage/move?from=from.txt&to=tenant-link/moved.txt", nil)
req = req.WithContext(store.WithTenantID(context.Background(), store.MasterTenantID))
w := httptest.NewRecorder()
handler.handleMove(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", w.Code)
}
if _, err := os.Stat(filepath.Join(baseDir, "from.txt")); err != nil {
t.Fatalf("source should remain after rejected move: %v", err)
}
if _, err := os.Stat(filepath.Join(baseDir, "tenants", "tenant-a", "moved.txt")); !os.IsNotExist(err) {
t.Fatalf("destination should not be created through symlinked parent, err=%v", err)
}
}
func TestStorageUploadRejectsSymlinkedTenantDestinationParent(t *testing.T) {
baseDir := t.TempDir()
writeStorageTestFile(t, filepath.Join(baseDir, "tenants", "tenant-a", ".keep"), "")
if err := os.Symlink(filepath.Join(baseDir, "tenants", "tenant-a"), filepath.Join(baseDir, "tenant-link")); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
handler := NewStorageHandler(baseDir)
req := newStorageUploadRequest(t, "/v1/storage/files?path=tenant-link", "file", "x.txt", "data")
req = req.WithContext(store.WithTenantID(context.Background(), store.MasterTenantID))
w := httptest.NewRecorder()
handler.handleUpload(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", w.Code)
}
if _, err := os.Stat(filepath.Join(baseDir, "tenants", "tenant-a", "x.txt")); !os.IsNotExist(err) {
t.Fatalf("upload should not write through symlinked parent, err=%v", err)
}
}
func TestStorageUploadReplacesLeafSymlinkWithoutFollowingTarget(t *testing.T) {
baseDir := t.TempDir()
tenantSecret := filepath.Join(baseDir, "tenants", "tenant-a", "secret.txt")
writeStorageTestFile(t, tenantSecret, "tenant-secret")
leaf := filepath.Join(baseDir, "x.txt")
if err := os.Symlink(tenantSecret, leaf); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
handler := NewStorageHandler(baseDir)
req := newStorageUploadRequest(t, "/v1/storage/files", "file", "x.txt", "replacement")
req = req.WithContext(store.WithTenantID(context.Background(), store.MasterTenantID))
w := httptest.NewRecorder()
handler.handleUpload(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
tenantData, err := os.ReadFile(tenantSecret)
if err != nil {
t.Fatalf("read tenant secret: %v", err)
}
if string(tenantData) != "tenant-secret" {
t.Fatalf("tenant secret overwritten through leaf symlink: %q", tenantData)
}
info, err := os.Lstat(leaf)
if err != nil {
t.Fatalf("lstat uploaded leaf: %v", err)
}
if info.Mode()&os.ModeSymlink != 0 {
t.Fatal("upload should replace the leaf symlink itself")
}
uploaded, err := os.ReadFile(leaf)
if err != nil {
t.Fatalf("read uploaded file: %v", err)
}
if string(uploaded) != "replacement" {
t.Fatalf("uploaded content = %q, want replacement", uploaded)
}
}
func TestStorageMutationsRequireTenantAdmin(t *testing.T) {
setupTestToken(t, "gateway-token")
setupTestNoAuthFallback(t, false)
ts := newMockTenantStore()
tenantID := uuid.New()
ts.addTenant(tenantID, "acme")
ts.setUserRole(tenantID, "viewer-user", store.TenantRoleViewer)
ts.setUserRole(tenantID, "admin-user", store.TenantRoleAdmin)
setupTestTenantStore(t, ts)
baseDir := t.TempDir()
writeStorageTestFile(t, filepath.Join(baseDir, "tenants", "acme", "from.txt"), "abc")
handler := NewStorageHandler(baseDir, ts)
mux := http.NewServeMux()
handler.RegisterRoutes(mux)
viewerUpload := newStorageUploadRequest(t, "/v1/storage/files", "file", "x.txt", "data")
viewerUpload.Header.Set("Authorization", "Bearer gateway-token")
viewerUpload.Header.Set("X-GoClaw-User-Id", "viewer-user")
viewerUpload.Header.Set("X-GoClaw-Tenant-Id", "acme")
viewerUploadRR := httptest.NewRecorder()
mux.ServeHTTP(viewerUploadRR, viewerUpload)
if viewerUploadRR.Code != http.StatusForbidden {
t.Fatalf("viewer upload status = %d, want 403", viewerUploadRR.Code)
}
viewerMove := httptest.NewRequest(http.MethodPut, "/v1/storage/move?from=from.txt&to=to.txt", nil)
viewerMove.Header.Set("Authorization", "Bearer gateway-token")
viewerMove.Header.Set("X-GoClaw-User-Id", "viewer-user")
viewerMove.Header.Set("X-GoClaw-Tenant-Id", "acme")
viewerMoveRR := httptest.NewRecorder()
mux.ServeHTTP(viewerMoveRR, viewerMove)
if viewerMoveRR.Code != http.StatusForbidden {
t.Fatalf("viewer move status = %d, want 403", viewerMoveRR.Code)
}
viewerDelete := httptest.NewRequest(http.MethodDelete, "/v1/storage/files/from.txt", nil)
viewerDelete.Header.Set("Authorization", "Bearer gateway-token")
viewerDelete.Header.Set("X-GoClaw-User-Id", "viewer-user")
viewerDelete.Header.Set("X-GoClaw-Tenant-Id", "acme")
viewerDeleteRR := httptest.NewRecorder()
mux.ServeHTTP(viewerDeleteRR, viewerDelete)
if viewerDeleteRR.Code != http.StatusForbidden {
t.Fatalf("viewer delete status = %d, want 403", viewerDeleteRR.Code)
}
adminUpload := newStorageUploadRequest(t, "/v1/storage/files", "file", "admin.txt", "data")
adminUpload.Header.Set("Authorization", "Bearer gateway-token")
adminUpload.Header.Set("X-GoClaw-User-Id", "admin-user")
adminUpload.Header.Set("X-GoClaw-Tenant-Id", "acme")
adminUploadRR := httptest.NewRecorder()
mux.ServeHTTP(adminUploadRR, adminUpload)
if adminUploadRR.Code != http.StatusOK {
t.Fatalf("tenant admin upload status = %d, want 200: %s", adminUploadRR.Code, adminUploadRR.Body.String())
}
}
func newStorageUploadRequest(t *testing.T, target, field, filename, content string) *http.Request {
t.Helper()
var body strings.Builder
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile(field, filename)
if err != nil {
t.Fatalf("CreateFormFile: %v", err)
}
if _, err := part.Write([]byte(content)); err != nil {
t.Fatalf("write multipart content: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("close multipart writer: %v", err)
}
req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(body.String()))
req.Header.Set("Content-Type", writer.FormDataContentType())
return req
}
+24 -6
View File
@@ -22,17 +22,35 @@ import (
type TTSConfigHandler struct {
systemConfigs store.SystemConfigStore
configSecrets store.ConfigSecretsStore
tenants store.TenantStore
}
// NewTTSConfigHandler creates a handler for per-tenant TTS config.
func NewTTSConfigHandler(sc store.SystemConfigStore, cs store.ConfigSecretsStore) *TTSConfigHandler {
return &TTSConfigHandler{systemConfigs: sc, configSecrets: cs}
func NewTTSConfigHandler(sc store.SystemConfigStore, cs store.ConfigSecretsStore, tenants ...store.TenantStore) *TTSConfigHandler {
h := &TTSConfigHandler{systemConfigs: sc, configSecrets: cs}
if len(tenants) > 0 {
h.tenants = tenants[0]
}
return h
}
// RegisterRoutes wires TTS config endpoints onto mux with RoleAdmin auth.
func (h *TTSConfigHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /v1/tts/config", requireAuth(permissions.RoleAdmin, h.handleGet))
mux.HandleFunc("POST /v1/tts/config", requireAuth(permissions.RoleAdmin, h.handleSave))
mux.HandleFunc("GET /v1/tts/config", requireAuth(permissions.RoleAdmin, h.requireTenantAdmin(h.handleGet)))
mux.HandleFunc("POST /v1/tts/config", requireAuth(permissions.RoleAdmin, h.requireTenantAdmin(h.handleSave)))
}
func (h *TTSConfigHandler) requireTenantAdmin(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if pkgGatewayToken == "" && store.TenantIDFromContext(r.Context()) == store.MasterTenantID {
next(w, r)
return
}
if !requireTenantAdmin(w, r, h.tenants) {
return
}
next(w, r)
}
}
// ttsConfigResponse is the response for GET /v1/tts/config.
@@ -60,8 +78,8 @@ type ttsProviderConfigResponse struct {
GroupID string `json:"group_id,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
Rate string `json:"rate,omitempty"`
Speakers string `json:"speakers,omitempty"` // JSON-encoded []SpeakerVoice (Gemini multi-speaker)
Params map[string]any `json:"params,omitempty"` // provider-specific params blob
Speakers string `json:"speakers,omitempty"` // JSON-encoded []SpeakerVoice (Gemini multi-speaker)
Params map[string]any `json:"params,omitempty"` // provider-specific params blob
}
// handleGet returns TTS config for the current tenant.
+54
View File
@@ -7,9 +7,11 @@ import (
"maps"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
@@ -98,6 +100,58 @@ func newValidationTTSConfigMux(sc store.SystemConfigStore, cs store.ConfigSecret
return mux
}
func newValidationTTSConfigMuxWithTenants(sc store.SystemConfigStore, cs store.ConfigSecretsStore, ts store.TenantStore) *http.ServeMux {
h := NewTTSConfigHandler(sc, cs, ts)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
return mux
}
func TestTTSConfigRequiresTenantAdminForReadAndWrite(t *testing.T) {
setupTestToken(t, "gateway-token")
setupTestNoAuthFallback(t, false)
ts := newMockTenantStore()
tenantID := uuid.New()
ts.addTenant(tenantID, "acme")
ts.setUserRole(tenantID, "viewer-user", store.TenantRoleViewer)
ts.setUserRole(tenantID, "admin-user", store.TenantRoleAdmin)
setupTestTenantStore(t, ts)
sc := &validationSystemConfigStore{data: map[string]string{}}
cs := &validationSecretsStore{data: map[string]string{}}
mux := newValidationTTSConfigMuxWithTenants(sc, cs, ts)
viewerGet := httptest.NewRequest("GET", "/v1/tts/config", nil)
viewerGet.Header.Set("Authorization", "Bearer gateway-token")
viewerGet.Header.Set("X-GoClaw-User-Id", "viewer-user")
viewerGet.Header.Set("X-GoClaw-Tenant-Id", "acme")
viewerGetRR := httptest.NewRecorder()
mux.ServeHTTP(viewerGetRR, viewerGet)
if viewerGetRR.Code != http.StatusForbidden {
t.Fatalf("viewer GET status = %d, want 403", viewerGetRR.Code)
}
viewerPost := httptest.NewRequest("POST", "/v1/tts/config", strings.NewReader(`{"provider":"edge"}`))
viewerPost.Header.Set("Authorization", "Bearer gateway-token")
viewerPost.Header.Set("X-GoClaw-User-Id", "viewer-user")
viewerPost.Header.Set("X-GoClaw-Tenant-Id", "acme")
viewerPostRR := httptest.NewRecorder()
mux.ServeHTTP(viewerPostRR, viewerPost)
if viewerPostRR.Code != http.StatusForbidden {
t.Fatalf("viewer POST status = %d, want 403", viewerPostRR.Code)
}
adminPost := httptest.NewRequest("POST", "/v1/tts/config", strings.NewReader(`{"provider":"edge"}`))
adminPost.Header.Set("Authorization", "Bearer gateway-token")
adminPost.Header.Set("X-GoClaw-User-Id", "admin-user")
adminPost.Header.Set("X-GoClaw-Tenant-Id", "acme")
adminPostRR := httptest.NewRecorder()
mux.ServeHTTP(adminPostRR, adminPost)
if adminPostRR.Code != http.StatusOK {
t.Fatalf("tenant admin POST status = %d, want 200: %s", adminPostRR.Code, adminPostRR.Body.String())
}
}
func TestTTSConfigSave_AcceptsLegacyAndUISchemaAliases(t *testing.T) {
setupTestToken(t, "")
+40 -23
View File
@@ -15,6 +15,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/crypto"
"github.com/nextlevelbuilder/goclaw/internal/edition"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
@@ -60,12 +61,28 @@ func (h *WebhooksAdminHandler) SetEncKey(encKey string) {
// Runtime routes (/v1/webhooks/message, /v1/webhooks/llm) are mounted by phases 05/06
// conditionally: message-kind only if edition.Current().AllowsChannels().
func (h *WebhooksAdminHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /v1/webhooks", h.handleCreate)
mux.HandleFunc("GET /v1/webhooks", h.handleList)
mux.HandleFunc("GET /v1/webhooks/{id}", h.handleGet)
mux.HandleFunc("PATCH /v1/webhooks/{id}", h.handleUpdate)
mux.HandleFunc("POST /v1/webhooks/{id}/rotate", h.handleRotate)
mux.HandleFunc("DELETE /v1/webhooks/{id}", h.handleRevoke)
mux.HandleFunc("POST /v1/webhooks", h.requireAdmin(h.handleCreate))
mux.HandleFunc("GET /v1/webhooks", h.requireAdmin(h.handleList))
mux.HandleFunc("GET /v1/webhooks/{id}", h.requireAdmin(h.handleGet))
mux.HandleFunc("PATCH /v1/webhooks/{id}", h.requireAdmin(h.handleUpdate))
mux.HandleFunc("POST /v1/webhooks/{id}/rotate", h.requireAdmin(h.handleRotate))
mux.HandleFunc("DELETE /v1/webhooks/{id}", h.requireAdmin(h.handleRevoke))
}
func (h *WebhooksAdminHandler) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if role := permissions.Role(store.RoleFromContext(r.Context())); role != "" {
if !permissions.HasMinRole(role, permissions.RoleAdmin) {
writeJSON(w, http.StatusForbidden, map[string]string{
"error": i18n.T(store.LocaleFromContext(r.Context()), i18n.MsgPermissionDenied, r.URL.Path+" requires "+string(permissions.RoleAdmin)+" role"),
})
return
}
next(w, r)
return
}
requireAuth(permissions.RoleAdmin, next)(w, r)
}
}
// --- Create ---
@@ -87,21 +104,21 @@ type createWebhookReq struct {
// hmac_signing_key = raw secret itself — callers sign HMAC requests using raw secret bytes.
// The raw secret is encrypted at rest; secret_hash is kept only for bearer-token lookup.
type webhookCreateResp struct {
ID uuid.UUID `json:"id"`
TenantID uuid.UUID `json:"tenant_id"`
AgentID *uuid.UUID `json:"agent_id,omitempty"`
Name string `json:"name"`
Kind string `json:"kind"`
SecretPrefix string `json:"secret_prefix"`
Secret string `json:"secret"` // raw secret — shown ONCE; use this as HMAC key
HMACSigningKey string `json:"hmac_signing_key"` // same as Secret — raw bytes for X-GoClaw-Signature
Scopes []string `json:"scopes"`
ChannelID *uuid.UUID `json:"channel_id,omitempty"`
RateLimitPerMin int `json:"rate_limit_per_min"`
IPAllowlist []string `json:"ip_allowlist"`
RequireHMAC bool `json:"require_hmac"`
LocalhostOnly bool `json:"localhost_only"`
CreatedAt time.Time `json:"created_at"`
ID uuid.UUID `json:"id"`
TenantID uuid.UUID `json:"tenant_id"`
AgentID *uuid.UUID `json:"agent_id,omitempty"`
Name string `json:"name"`
Kind string `json:"kind"`
SecretPrefix string `json:"secret_prefix"`
Secret string `json:"secret"` // raw secret — shown ONCE; use this as HMAC key
HMACSigningKey string `json:"hmac_signing_key"` // same as Secret — raw bytes for X-GoClaw-Signature
Scopes []string `json:"scopes"`
ChannelID *uuid.UUID `json:"channel_id,omitempty"`
RateLimitPerMin int `json:"rate_limit_per_min"`
IPAllowlist []string `json:"ip_allowlist"`
RequireHMAC bool `json:"require_hmac"`
LocalhostOnly bool `json:"localhost_only"`
CreatedAt time.Time `json:"created_at"`
}
func (h *WebhooksAdminHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
@@ -465,8 +482,8 @@ func (h *WebhooksAdminHandler) handleRotate(w http.ResponseWriter, r *http.Reque
writeJSON(w, http.StatusOK, map[string]any{
"id": id,
"secret": raw, // new raw secret — shown ONCE; use as HMAC key
"hmac_signing_key": raw, // same as secret; raw bytes are HMAC key (encrypted at rest)
"secret": raw, // new raw secret — shown ONCE; use as HMAC key
"hmac_signing_key": raw, // same as secret; raw bytes are HMAC key (encrypted at rest)
"secret_prefix": newPrefix,
})
}
+54
View File
@@ -206,6 +206,15 @@ func webhookTenantAdminCtx(tenantID uuid.UUID, userID string) context.Context {
ctx := context.Background()
ctx = store.WithTenantID(ctx, tenantID)
ctx = store.WithUserID(ctx, userID)
ctx = store.WithRole(ctx, "admin")
return ctx
}
func webhookTenantCtxWithRole(tenantID uuid.UUID, userID, role string) context.Context {
ctx := context.Background()
ctx = store.WithTenantID(ctx, tenantID)
ctx = store.WithUserID(ctx, userID)
ctx = store.WithRole(ctx, role)
return ctx
}
@@ -239,6 +248,28 @@ func doRequest(t *testing.T, h *WebhooksAdminHandler, method, path string, body
// ---- tests ----
func TestWebhookAdmin_RouteRequiresHTTPAuth(t *testing.T) {
oldToken := pkgGatewayToken
oldFallback := pkgNoAuthFallbackAllowed
InitGatewayToken("required-token")
InitGatewayNoAuthFallbackAllowed(false)
defer func() {
InitGatewayToken(oldToken)
InitGatewayNoAuthFallbackAllowed(oldFallback)
}()
h := newAdminHandler(newAdminWebhookStore(), &adminTenantStore{})
r := httptest.NewRequest(http.MethodGet, "/v1/webhooks", nil)
w := httptest.NewRecorder()
mux := http.NewServeMux()
h.RegisterRoutes(mux)
mux.ServeHTTP(w, r)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 for unauthenticated admin route, got %d", w.Code)
}
}
// TestWebhookAdmin_Create_HappyPath verifies POST /v1/webhooks returns secret once.
func TestWebhookAdmin_Create_HappyPath(t *testing.T) {
tenantID := uuid.New()
@@ -310,6 +341,29 @@ func TestWebhookAdmin_Create_NonAdmin_403(t *testing.T) {
}
}
func TestWebhookAdmin_Create_ContextOperatorRoleDeniedBeforeTenantAdmin(t *testing.T) {
tenantID := uuid.New()
userID := "operator-context"
ts := &adminTenantStore{
roles: map[string]string{
tenantID.String() + ":" + userID: store.TenantRoleAdmin,
},
}
ws := newAdminWebhookStore()
h := newAdminHandler(ws, ts)
ctx := webhookTenantCtxWithRole(tenantID, userID, "operator")
w := doRequest(t, h, http.MethodPost, "/v1/webhooks", map[string]any{
"name": "x",
"kind": "llm",
}, ctx)
if w.Code != http.StatusForbidden {
t.Fatalf("want 403, got %d: %s", w.Code, w.Body.String())
}
}
// TestWebhookAdmin_Create_InvalidKind_400 verifies unknown kind is rejected.
func TestWebhookAdmin_Create_InvalidKind_400(t *testing.T) {
tenantID := uuid.New()
+13 -10
View File
@@ -41,7 +41,7 @@ const (
// WebhookAuthMiddleware is the composed middleware chain for all /v1/webhooks/*
// runtime endpoints. Order: body cap → bearer/HMAC auth → localhost gate →
// IP allowlist → rate limit → idempotency guard → inject context → next.
// IP allowlist → rate limit → inject context → idempotency guard → next.
//
// Parameters:
// - ws: WebhookStore for secret + row lookup.
@@ -185,25 +185,28 @@ func WebhookAuthMiddleware(
return
}
// 7. Idempotency check.
proceed, _ := checkIdempotency(w, r, body, webhook.ID, calls)
if !proceed {
return
}
// 8. Inject webhook + tenant into context; propagate to stores.
// 7. Inject webhook + tenant into context; propagate to stores.
// K1: tenant injected HERE so all store calls below are tenant-scoped.
ctx = WithWebhookData(ctx, webhook)
ctx = WithWebhookRawBody(ctx, body)
ctx = store.WithTenantID(ctx, webhook.TenantID)
if webhook.AgentID != nil {
ctx = store.WithAgentID(ctx, *webhook.AgentID)
}
scopedReq := r.WithContext(ctx)
// 8. Idempotency check. This must run after tenant injection because
// WebhookCallStore lookups are tenant scoped.
proceed, _ := checkIdempotency(w, scopedReq, body, webhook.ID, calls)
if !proceed {
return
}
// Best-effort touch — don't block on failure. Use WithoutCancel so
// the DB write is not cancelled when the HTTP response completes.
go func() { _ = ws.TouchLastUsed(context.WithoutCancel(r.Context()), webhook.ID) }()
go func() { _ = ws.TouchLastUsed(context.WithoutCancel(scopedReq.Context()), webhook.ID) }()
next.ServeHTTP(w, r.WithContext(ctx))
next.ServeHTTP(w, scopedReq)
})
}
}
+28 -5
View File
@@ -84,11 +84,12 @@ func (s *stubWebhookStore) Update(_ context.Context, _ uuid.UUID, _ map[string]a
func (s *stubWebhookStore) RotateSecret(_ context.Context, _ uuid.UUID, _, _, _ string) error {
return nil
}
func (s *stubWebhookStore) Revoke(_ context.Context, _ uuid.UUID) error { return nil }
func (s *stubWebhookStore) Revoke(_ context.Context, _ uuid.UUID) error { return nil }
func (s *stubWebhookStore) TouchLastUsed(_ context.Context, _ uuid.UUID) error { return nil }
type stubWebhookCallStore struct {
calls map[string]*store.WebhookCallData // key = idempotency_key
calls map[string]*store.WebhookCallData // key = idempotency_key
lastTenant uuid.UUID
}
func newStubCallStore(calls ...*store.WebhookCallData) *stubWebhookCallStore {
@@ -101,7 +102,8 @@ func newStubCallStore(calls ...*store.WebhookCallData) *stubWebhookCallStore {
return s
}
func (s *stubWebhookCallStore) GetByIdempotency(_ context.Context, _ uuid.UUID, key string) (*store.WebhookCallData, error) {
func (s *stubWebhookCallStore) GetByIdempotency(ctx context.Context, _ uuid.UUID, key string) (*store.WebhookCallData, error) {
s.lastTenant = store.TenantIDFromContext(ctx)
c, ok := s.calls[key]
if !ok {
return nil, sql.ErrNoRows
@@ -182,8 +184,8 @@ func makeWebhook(kind string, opts ...func(*store.WebhookData)) *store.WebhookDa
return w
}
func withRevoked(w *store.WebhookData) { w.Revoked = true }
func withRequireHMAC(w *store.WebhookData) { w.RequireHMAC = true }
func withRevoked(w *store.WebhookData) { w.Revoked = true }
func withRequireHMAC(w *store.WebhookData) { w.RequireHMAC = true }
func withLocalhostOnly(w *store.WebhookData) { w.LocalhostOnly = true }
func withRPM(rpm int) func(*store.WebhookData) {
return func(w *store.WebhookData) { w.RateLimitPerMin = rpm }
@@ -483,6 +485,27 @@ func TestWebhookAuth_IdempotencyReplay(t *testing.T) {
}
}
func TestWebhookAuth_IdempotencyRunsWithTenantContext(t *testing.T) {
raw, hashHex := makeSecret()
wh := makeWebhook("llm")
wh.SecretHash = hashHex
ws := newStubWebhookStore(wh)
calls := newStubCallStore()
handler := makeMiddleware(ws, calls, "llm", WebhookMaxBodyLLM)
w := httptest.NewRecorder()
r := bearerReq(raw, `{"input":"hi"}`)
r.Header.Set("Idempotency-Key", "tenant-context-key")
handler.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("expected middleware to proceed, got %d", w.Code)
}
if calls.lastTenant != wh.TenantID {
t.Fatalf("idempotency lookup tenant = %s, want %s", calls.lastTenant, wh.TenantID)
}
}
func TestWebhookAuth_NoAuthHeader(t *testing.T) {
wh := makeWebhook("llm")
ws := newStubWebhookStore(wh)
+14
View File
@@ -10,6 +10,7 @@ import (
// Uses a distinct struct type (not contextKey string) to avoid collision with
// store-layer keys while following the same struct-key pattern.
type webhookCtxKey struct{}
type webhookRawBodyCtxKey struct{}
// WithWebhookData returns a new context carrying the resolved WebhookData.
// Call store.WithTenantID separately to propagate tenant to downstream stores.
@@ -23,3 +24,16 @@ func WebhookDataFromContext(ctx context.Context) *store.WebhookData {
v, _ := ctx.Value(webhookCtxKey{}).(*store.WebhookData)
return v
}
func WithWebhookRawBody(ctx context.Context, body []byte) context.Context {
cp := append([]byte(nil), body...)
return context.WithValue(ctx, webhookRawBodyCtxKey{}, cp)
}
func WebhookRawBodyFromContext(ctx context.Context) []byte {
v, _ := ctx.Value(webhookRawBodyCtxKey{}).([]byte)
if v == nil {
return nil
}
return append([]byte(nil), v...)
}
+161
View File
@@ -1,18 +1,26 @@
package http
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"log/slog"
"net/http"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// webhookSyncReservationTTL must exceed the longest legitimate sync webhook path.
// Telegram media sends can run for 3 minutes on slow uploads; keep enough margin
// so a duplicate idempotency request cannot mark an active send as expired.
const webhookSyncReservationTTL = 10 * time.Minute
// checkIdempotency inspects the Idempotency-Key header and resolves prior calls.
//
// Returns:
@@ -66,6 +74,8 @@ func checkIdempotency(
return false, errors.New("idempotency conflict")
}
expireStaleSyncReservation(ctx, calls, existing, time.Now())
// Same key + matching body → replay last stored response.
if len(existing.Response) > 0 {
w.Header().Set("Content-Type", "application/json")
@@ -116,3 +126,154 @@ func extractBodyHash(payload []byte) string {
}
return p.BodyHash
}
func optionalIdempotencyKey(r *http.Request) *string {
if key := r.Header.Get("Idempotency-Key"); key != "" {
return &key
}
return nil
}
func reserveIdempotentCall(
w http.ResponseWriter,
r *http.Request,
calls store.WebhookCallStore,
call *store.WebhookCallData,
) (reserved bool, handled bool) {
if call.IdempotencyKey == nil {
return false, false
}
if err := calls.Create(r.Context(), call); err != nil {
if errors.Is(err, store.ErrIdempotencyConflict) {
if replayStoredIdempotencyFromPayload(w, r, calls, call.WebhookID, *call.IdempotencyKey, call.RequestPayload) {
return false, true
}
}
slog.Error("webhook.idempotency_reserve_failed", "error", err, "call_id", call.ID)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": i18n.T(store.LocaleFromContext(r.Context()), i18n.MsgInternalError, "failed to reserve idempotency key"),
})
return false, true
}
return true, false
}
func persistWebhookCall(
ctx context.Context,
calls store.WebhookCallStore,
call *store.WebhookCallData,
reserved bool,
logName string,
) {
ctx = context.WithoutCancel(ctx)
var err error
if reserved {
updates := map[string]any{
"status": call.Status,
"attempts": call.Attempts,
"response": call.Response,
"last_error": call.LastError,
"completed_at": call.CompletedAt,
}
err = calls.UpdateStatus(ctx, call.ID, updates)
} else {
err = calls.Create(ctx, call)
}
if err != nil {
slog.Warn(logName, "error", err, "call_id", call.ID)
}
}
func replayStoredIdempotencyFromPayload(
w http.ResponseWriter,
r *http.Request,
calls store.WebhookCallStore,
webhookID uuid.UUID,
key string,
requestPayload []byte,
) bool {
existing, err := calls.GetByIdempotency(r.Context(), webhookID, key)
if err != nil {
return false
}
locale := store.LocaleFromContext(r.Context())
if extractBodyHash(existing.RequestPayload) != extractBodyHash(requestPayload) {
writeJSON(w, http.StatusConflict, map[string]string{
"error": i18n.T(locale, i18n.MsgWebhookIdempotencyConflict),
})
return true
}
expireStaleSyncReservation(r.Context(), calls, existing, time.Now())
if len(existing.Response) > 0 {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Idempotency-Replayed", "true")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(existing.Response)
return true
}
writeJSON(w, http.StatusAccepted, map[string]string{
"status": existing.Status,
"call_id": existing.ID.String(),
})
return true
}
func expireStaleSyncReservation(
ctx context.Context,
calls store.WebhookCallStore,
existing *store.WebhookCallData,
now time.Time,
) bool {
if !isStaleSyncReservation(existing, now) {
return false
}
reason := "sync idempotency reservation expired"
resp, err := json.Marshal(map[string]string{
"call_id": existing.ID.String(),
"status": "failed",
"error": reason,
})
if err != nil {
slog.Warn("webhook.idempotency_expire_response_failed", "error", err, "call_id", existing.ID)
return false
}
completedAt := now
attempts := existing.Attempts
if attempts == 0 {
attempts = 1
}
updates := map[string]any{
"status": "failed",
"attempts": attempts,
"response": resp,
"last_error": reason,
"completed_at": completedAt,
}
if err := calls.UpdateStatus(context.WithoutCancel(ctx), existing.ID, updates); err != nil {
slog.Warn("webhook.idempotency_expire_failed", "error", err, "call_id", existing.ID)
return false
}
existing.Status = "failed"
existing.Attempts = attempts
existing.Response = resp
existing.LastError = &reason
existing.CompletedAt = &completedAt
return true
}
func isStaleSyncReservation(existing *store.WebhookCallData, now time.Time) bool {
if existing == nil || existing.Mode != "sync" || existing.Status != "running" {
return false
}
startedAt := existing.CreatedAt
if existing.StartedAt != nil {
startedAt = *existing.StartedAt
}
if startedAt.IsZero() {
return false
}
return now.Sub(startedAt) > webhookSyncReservationTTL
}
@@ -6,6 +6,7 @@ import (
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
@@ -144,6 +145,54 @@ func TestCheckIdempotency_malformedStoredHash(t *testing.T) {
}
}
func TestCheckIdempotency_StaleSyncReservationExpires(t *testing.T) {
webhookID := uuid.New()
body := []byte(`{"input":"hello"}`)
payload, err := buildAuditPayload(body, map[string]string{"input": "hello"})
if err != nil {
t.Fatalf("buildAuditPayload: %v", err)
}
key := "idem-stale-sync"
startedAt := time.Now().Add(-(webhookSyncReservationTTL + time.Second))
existing := &store.WebhookCallData{
ID: uuid.New(),
WebhookID: webhookID,
IdempotencyKey: &key,
Mode: "sync",
Status: "running",
RequestPayload: payload,
StartedAt: &startedAt,
CreatedAt: startedAt,
}
calls := newStubCallStore(existing)
req := httptest.NewRequest(http.MethodPost, "/v1/webhooks/llm", strings.NewReader(string(body)))
req.Header.Set("Idempotency-Key", key)
rec := httptest.NewRecorder()
proceed, err := checkIdempotency(rec, req, body, webhookID, calls)
if proceed {
t.Fatal("expected stale idempotency row to be handled, got proceed=true")
}
if err != nil {
t.Fatalf("expected nil error for expired replay response, got %v", err)
}
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 replay for expired row, got %d: %s", rec.Code, rec.Body.String())
}
if rec.Header().Get("X-Idempotency-Replayed") != "true" {
t.Fatal("expected X-Idempotency-Replayed header")
}
if existing.Status != "failed" {
t.Fatalf("expected stale row status failed, got %q", existing.Status)
}
if len(existing.Response) == 0 || !strings.Contains(string(existing.Response), "sync idempotency reservation expired") {
t.Fatalf("expected stored expiry response, got %s", string(existing.Response))
}
}
// strPtr is a test helper returning a pointer to s.
func strPtr(s string) *string { return &s }
+58 -62
View File
@@ -233,19 +233,23 @@ func (h *WebhookLLMHandler) handle(w http.ResponseWriter, r *http.Request) {
deliveryID := store.GenNewID()
now := time.Now()
// Capture raw body bytes for body_hash computation.
// req was decoded from the HTTP body; re-marshal to get canonical bytes.
// Capture raw body bytes for body_hash computation when middleware supplied them.
// Direct handler tests fall back to canonical JSON bytes from the decoded request.
// The audit payload uses the canonical JSON shape {"body_hash":"...","meta":{...}}
// so PG jsonb insert never triggers error 22P02.
reqBytes, _ := json.Marshal(req)
reqBytes := WebhookRawBodyFromContext(ctx)
if reqBytes == nil {
reqBytes, _ = json.Marshal(req)
}
requestPayload, _ := buildAuditPayload(reqBytes, req)
idempotencyKey := optionalIdempotencyKey(r)
// Dispatch based on mode.
switch mode {
case "async":
h.handleAsync(w, r, ctx, locale, webhook, ag, agentID, req, callID, deliveryID, now, requestPayload, userMessage, extraSystemPrompt)
h.handleAsync(w, r, ctx, locale, webhook, ag, agentID, req, callID, deliveryID, now, requestPayload, idempotencyKey, userMessage, extraSystemPrompt)
default: // "sync"
h.handleSync(w, r, ctx, locale, webhook, ag, agentID, req, callID, deliveryID, now, requestPayload, userMessage, extraSystemPrompt)
h.handleSync(w, r, ctx, locale, webhook, ag, agentID, req, callID, deliveryID, now, requestPayload, idempotencyKey, userMessage, extraSystemPrompt)
}
}
@@ -262,10 +266,29 @@ func (h *WebhookLLMHandler) handleSync(
callID, deliveryID uuid.UUID,
now time.Time,
requestPayload []byte,
idempotencyKey *string,
userMessage, extraSystemPrompt string,
) {
runID := uuid.NewString()
sessionKey := resolveWebhookSessionKey(req.SessionKey, agentID, webhook.ID, runID)
callRecord := &store.WebhookCallData{
ID: callID,
TenantID: webhook.TenantID,
WebhookID: webhook.ID,
AgentID: webhook.AgentID,
DeliveryID: deliveryID,
IdempotencyKey: idempotencyKey,
Mode: "sync",
Status: "running",
Attempts: 0,
RequestPayload: requestPayload,
CreatedAt: now,
StartedAt: &now,
}
callReserved, handled := reserveIdempotentCall(w, r, h.callStore, callRecord)
if handled {
return
}
rr := agent.RunRequest{
SessionKey: sessionKey,
@@ -318,6 +341,13 @@ func (h *WebhookLLMHandler) handleSync(
})
if submitErr != nil {
completedAt := time.Now()
errMsg := submitErr.Error()
callRecord.Status = "failed"
callRecord.Attempts = 1
callRecord.CompletedAt = &completedAt
callRecord.LastError = &errMsg
persistWebhookCall(ctx, h.callStore, callRecord, callReserved, "webhook.llm.audit_write_failed")
// Lane at capacity or ctx cancelled before slot acquired.
slog.Warn("webhook.lane_saturated",
"webhook_id", webhook.ID,
@@ -345,21 +375,11 @@ func (h *WebhookLLMHandler) handleSync(
if errors.Is(out.err, context.DeadlineExceeded) {
// Write audit row as failed/timeout.
errMsg := "context deadline exceeded"
h.writeCallRecord(ctx, &store.WebhookCallData{
ID: callID,
TenantID: webhook.TenantID,
WebhookID: webhook.ID,
AgentID: webhook.AgentID,
DeliveryID: deliveryID,
Mode: "sync",
Status: "failed",
Attempts: 1,
RequestPayload: requestPayload,
LastError: &errMsg,
CreatedAt: now,
CompletedAt: &completedAt,
StartedAt: &now,
})
callRecord.Status = "failed"
callRecord.Attempts = 1
callRecord.LastError = &errMsg
callRecord.CompletedAt = &completedAt
persistWebhookCall(ctx, h.callStore, callRecord, callReserved, "webhook.llm.audit_write_failed")
writeError(w, http.StatusGatewayTimeout, protocol.ErrInternal,
i18n.T(locale, i18n.MsgWebhookLLMTimeout))
return
@@ -367,21 +387,11 @@ func (h *WebhookLLMHandler) handleSync(
// Other error.
errMsg := out.err.Error()
h.writeCallRecord(ctx, &store.WebhookCallData{
ID: callID,
TenantID: webhook.TenantID,
WebhookID: webhook.ID,
AgentID: webhook.AgentID,
DeliveryID: deliveryID,
Mode: "sync",
Status: "failed",
Attempts: 1,
RequestPayload: requestPayload,
LastError: &errMsg,
CreatedAt: now,
CompletedAt: &completedAt,
StartedAt: &now,
})
callRecord.Status = "failed"
callRecord.Attempts = 1
callRecord.LastError = &errMsg
callRecord.CompletedAt = &completedAt
persistWebhookCall(ctx, h.callStore, callRecord, callReserved, "webhook.llm.audit_write_failed")
writeError(w, http.StatusInternalServerError, protocol.ErrInternal,
i18n.T(locale, i18n.MsgInternalError, out.err.Error()))
return
@@ -409,21 +419,11 @@ func (h *WebhookLLMHandler) handleSync(
}
completedAt := time.Now()
h.writeCallRecord(ctx, &store.WebhookCallData{
ID: callID,
TenantID: webhook.TenantID,
WebhookID: webhook.ID,
AgentID: webhook.AgentID,
DeliveryID: deliveryID,
Mode: "sync",
Status: "done",
Attempts: 1,
RequestPayload: requestPayload,
Response: respBytes,
CreatedAt: now,
CompletedAt: &completedAt,
StartedAt: &now,
})
callRecord.Status = "done"
callRecord.Attempts = 1
callRecord.Response = respBytes
callRecord.CompletedAt = &completedAt
persistWebhookCall(ctx, h.callStore, callRecord, callReserved, "webhook.llm.audit_write_failed")
slog.Info("webhook.llm.sync",
"call_id", callID,
@@ -438,7 +438,7 @@ func (h *WebhookLLMHandler) handleSync(
// handleAsync enqueues a webhook_calls row and returns 202 immediately.
func (h *WebhookLLMHandler) handleAsync(
w http.ResponseWriter,
_ *http.Request,
r *http.Request,
ctx context.Context,
locale string,
webhook *store.WebhookData,
@@ -448,6 +448,7 @@ func (h *WebhookLLMHandler) handleAsync(
callID, deliveryID uuid.UUID,
now time.Time,
requestPayload []byte,
idempotencyKey *string,
_, _ string, // userMessage, extraSystemPrompt — stored in requestPayload, not used here
) {
// SSRF validation on callback_url — defense against DNS rebinding.
@@ -471,6 +472,7 @@ func (h *WebhookLLMHandler) handleAsync(
WebhookID: webhook.ID,
AgentID: webhook.AgentID,
DeliveryID: deliveryID,
IdempotencyKey: idempotencyKey,
Mode: "async",
Status: "queued",
CallbackURL: &cbURL,
@@ -481,6 +483,11 @@ func (h *WebhookLLMHandler) handleAsync(
}
if err := h.callStore.Create(ctx, call); err != nil {
if idempotencyKey != nil && errors.Is(err, store.ErrIdempotencyConflict) {
if replayStoredIdempotencyFromPayload(w, r, h.callStore, webhook.ID, *idempotencyKey, requestPayload) {
return
}
}
slog.Error("webhook.llm.async_enqueue_failed",
"error", err,
"call_id", callID,
@@ -504,16 +511,6 @@ func (h *WebhookLLMHandler) handleAsync(
})
}
// writeCallRecord persists an audit call record. Best-effort — failures are logged but not fatal.
func (h *WebhookLLMHandler) writeCallRecord(ctx context.Context, call *store.WebhookCallData) {
if err := h.callStore.Create(ctx, call); err != nil {
slog.Warn("webhook.llm.audit_write_failed",
"error", err,
"call_id", call.ID,
)
}
}
// buildInput parses the raw JSON input into a user message and optional extra system prompt.
//
// Two formats are accepted:
@@ -561,4 +558,3 @@ func resolveWebhookSessionKey(reqSessionKey, agentID string, webhookID uuid.UUID
}
return fmt.Sprintf("webhook:%s:%s:%s", agentID, webhookID.String(), runID[:8])
}
+20 -20
View File
@@ -105,7 +105,7 @@ type webhookMessageReq struct {
// webhookMessageResp is the success response envelope.
type webhookMessageResp struct {
CallID string `json:"call_id"`
Status string `json:"status"` // always "sent"
Status string `json:"status"` // always "sent"
ChannelName string `json:"channel_name"`
ChatID string `json:"chat_id"`
Warning string `json:"warning,omitempty"` // set when media was dropped on fallback
@@ -163,9 +163,13 @@ func (h *WebhookMessageHandler) handle(w http.ResponseWriter, r *http.Request) {
deliveryID := store.GenNewID()
now := time.Now()
callRecord := h.newCallRecord(r, webhook, callID, deliveryID, now, channelName, req)
callReserved, handled := reserveIdempotentCall(w, r, h.callStore, callRecord)
if handled {
return
}
// Dispatch — media or text-only path.
warning, sendErr := h.dispatch(ctx, w, r, webhook, req, channelName, callRecord, locale)
warning, sendErr := h.dispatch(ctx, w, r, webhook, req, channelName, callRecord, callReserved, locale)
if sendErr != nil {
return // error response already written by dispatch
}
@@ -186,13 +190,7 @@ func (h *WebhookMessageHandler) handle(w http.ResponseWriter, r *http.Request) {
respBytes, _ := json.Marshal(respBody)
callRecord.Response = respBytes
if err := h.callStore.Create(ctx, callRecord); err != nil {
// Non-fatal: audit failure must not fail a delivered message.
slog.Warn("webhook.message.audit_write_failed",
"error", err,
"call_id", callID,
)
}
persistWebhookCall(ctx, h.callStore, callRecord, callReserved, "webhook.message.audit_write_failed")
slog.Info("webhook.message.delivered",
"tenant_id", webhook.TenantID,
@@ -215,12 +213,13 @@ func (h *WebhookMessageHandler) dispatch(
req webhookMessageReq,
channelName string,
callRecord *store.WebhookCallData,
callReserved bool,
locale string,
) (warning string, _ error) {
if req.MediaURL == "" {
// Text-only path.
if err := h.channelMgr.SendToChannel(ctx, channelName, req.ChatID, req.Content); err != nil {
h.failCall(ctx, callRecord, err.Error())
h.failCall(ctx, callRecord, callReserved, err.Error())
slog.Error("webhook.message.dispatch_failed",
"error", err,
"channel_name", channelName,
@@ -238,7 +237,7 @@ func (h *WebhookMessageHandler) dispatch(
if probeErr != nil {
var mve *mediaValidateError
if errors.As(probeErr, &mve) {
h.failCall(ctx, callRecord, mve.message)
h.failCall(ctx, callRecord, callReserved, mve.message)
switch mve.code {
case "ssrf":
slog.Warn("security.webhook.ssrf_blocked",
@@ -258,7 +257,7 @@ func (h *WebhookMessageHandler) dispatch(
i18n.T(locale, i18n.MsgWebhookMediaSSRFBlocked))
}
} else {
h.failCall(ctx, callRecord, probeErr.Error())
h.failCall(ctx, callRecord, callReserved, probeErr.Error())
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest,
i18n.T(locale, i18n.MsgWebhookMediaSSRFBlocked))
}
@@ -274,7 +273,7 @@ func (h *WebhookMessageHandler) dispatch(
Caption: req.MediaCaption,
}}
if err := h.channelMgr.SendMediaToChannel(ctx, channelName, req.ChatID, req.Content, media); err != nil {
h.failCall(ctx, callRecord, err.Error())
h.failCall(ctx, callRecord, callReserved, err.Error())
slog.Error("webhook.message.dispatch_failed",
"error", err,
"channel_name", channelName,
@@ -295,7 +294,7 @@ func (h *WebhookMessageHandler) dispatch(
"webhook_id", webhook.ID,
)
if err := h.channelMgr.SendToChannel(ctx, channelName, req.ChatID, req.Content); err != nil {
h.failCall(ctx, callRecord, err.Error())
h.failCall(ctx, callRecord, callReserved, err.Error())
slog.Error("webhook.message.dispatch_failed",
"error", err,
"channel_name", channelName,
@@ -310,7 +309,7 @@ func (h *WebhookMessageHandler) dispatch(
// Media unsupported + no fallback → 501.
const reason = "channel does not support media and fallback_to_text is false"
h.failCall(ctx, callRecord, reason)
h.failCall(ctx, callRecord, callReserved, reason)
writeError(w, http.StatusNotImplemented, protocol.ErrInvalidRequest,
i18n.T(locale, i18n.MsgWebhookMediaChannelUnsupported))
return "", errors.New(reason)
@@ -385,7 +384,10 @@ func (h *WebhookMessageHandler) newCallRecord(
) *store.WebhookCallData {
// Encode canonical audit payload: {"body_hash": "<sha256>", "meta": {...}}.
// PG jsonb rejects non-JSON bytes; this shape is valid JSON on both PG and SQLite.
bodyBytes, _ := json.Marshal(req)
bodyBytes := WebhookRawBodyFromContext(r.Context())
if bodyBytes == nil {
bodyBytes, _ = json.Marshal(req)
}
requestPayload, _ := buildAuditPayload(bodyBytes, map[string]any{
"channel_name": channelName,
"chat_id": req.ChatID,
@@ -413,15 +415,13 @@ func (h *WebhookMessageHandler) newCallRecord(
}
// failCall mutates call to status=failed and records it in the store. Best-effort.
func (h *WebhookMessageHandler) failCall(ctx context.Context, call *store.WebhookCallData, reason string) {
func (h *WebhookMessageHandler) failCall(ctx context.Context, call *store.WebhookCallData, reserved bool, reason string) {
now := time.Now()
call.Status = "failed"
call.CompletedAt = &now
call.LastError = &reason
call.Attempts = 1
if err := h.callStore.Create(ctx, call); err != nil {
slog.Warn("webhook.message.audit_write_failed", "error", err, "call_id", call.ID)
}
persistWebhookCall(ctx, h.callStore, call, reserved, "webhook.message.audit_write_failed")
}
// redactedHost extracts the hostname from a URL string for safe (no-path) log output.
+74
View File
@@ -126,3 +126,77 @@ func TestResolveScopeKey(t *testing.T) {
}
}
}
func TestFsBridgeResolvePathRejectsWorkspaceEscapes(t *testing.T) {
bridge := NewFsBridge("container-test", "/workspace/agent-a")
tests := []struct {
name string
path string
want string
}{
{name: "inside relative", path: "notes/a.txt", want: "/workspace/agent-a/notes/a.txt"},
{name: "inside absolute", path: "/workspace/agent-a/notes/a.txt", want: "/workspace/agent-a/notes/a.txt"},
{name: "relative parent escape", path: "../agent-b/secret.txt", want: "/workspace/agent-a"},
{name: "absolute sibling escape", path: "/workspace/agent-b/secret.txt", want: "/workspace/agent-a"},
{name: "root escape", path: "/etc/passwd", want: "/workspace/agent-a"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := bridge.resolvePath(tt.path); got != tt.want {
t.Fatalf("resolvePath(%q) = %q, want %q", tt.path, got, tt.want)
}
})
}
}
func TestFsBridgePathWithinUsesPathBoundaries(t *testing.T) {
tests := []struct {
name string
root string
target string
want bool
}{
{name: "root itself", root: "/workspace/agent-a", target: "/workspace/agent-a", want: true},
{name: "child path", root: "/workspace/agent-a", target: "/workspace/agent-a/file.txt", want: true},
{name: "sibling with shared prefix", root: "/workspace/agent-a", target: "/workspace/agent-a-b/file.txt", want: false},
{name: "parent path", root: "/workspace/agent-a", target: "/workspace", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := fsBridgePathWithin(tt.root, tt.target); got != tt.want {
t.Fatalf("fsBridgePathWithin(%q, %q) = %v, want %v", tt.root, tt.target, got, tt.want)
}
})
}
}
func TestFsBridgeWriteFileCommandPreservesOverwriteTruncation(t *testing.T) {
args := fsBridgeWriteDDArgs("/workspace/file.txt", false)
for _, arg := range args {
if arg == "conv=notrunc" || arg == "oflag=append" {
t.Fatalf("overwrite command must truncate, got append-only arg %q in %v", arg, args)
}
}
}
func TestFsBridgeWriteFileCommandUsesNoTruncOnlyForAppend(t *testing.T) {
args := fsBridgeWriteDDArgs("/workspace/file.txt", true)
if !containsString(args, "conv=notrunc") {
t.Fatalf("append command missing conv=notrunc: %v", args)
}
if !containsString(args, "oflag=append") {
t.Fatalf("append command missing oflag=append: %v", args)
}
}
func containsString(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}
+129 -22
View File
@@ -37,8 +37,12 @@ func NewFsBridge(containerID, workdir string) *FsBridge {
// Matching TS FsBridge.readFile().
func (b *FsBridge) ReadFile(ctx context.Context, path string) (string, error) {
resolved := b.resolvePath(path)
realPath, err := b.resolveExistingPath(ctx, resolved)
if err != nil {
return "", err
}
stdout, stderr, exitCode, err := b.dockerExec(ctx, nil, "cat", "--", resolved)
stdout, stderr, exitCode, err := b.dockerExec(ctx, nil, "cat", "--", realPath)
if err != nil {
return "", fmt.Errorf("fsbridge read: %w", err)
}
@@ -50,23 +54,34 @@ func (b *FsBridge) ReadFile(ctx context.Context, path string) (string, error) {
}
// WriteFile writes content to a file inside the container, creating directories as needed.
// When append is true, content is appended (shell >>); otherwise the file is overwritten (shell >).
// When append is true, content is appended; otherwise the file is overwritten.
// Matching TS FsBridge.writeFile().
func (b *FsBridge) WriteFile(ctx context.Context, path, content string, appendMode bool) error {
resolved := b.resolvePath(path)
// Create parent directory
dir := resolved[:strings.LastIndex(resolved, "/")]
if dir != "" && dir != "/" {
_, _, _, _ = b.dockerExec(ctx, nil, "mkdir", "-p", dir)
if err := b.validateExistingTargetIfPresent(ctx, resolved); err != nil {
return err
}
redir := ">"
if appendMode {
redir = ">>"
dir := resolved[:strings.LastIndex(resolved, "/")]
if dir != "" && dir != "/" {
if err := b.validateParentBeforeCreate(ctx, dir); err != nil {
return err
}
_, stderr, exitCode, err := b.dockerExec(ctx, nil, "mkdir", "-p", "--", dir)
if err != nil {
return fmt.Errorf("fsbridge mkdir: %w", err)
}
if exitCode != 0 {
return fmt.Errorf("mkdir failed: %s", strings.TrimSpace(stderr))
}
if err := b.validateParentBeforeCreate(ctx, dir); err != nil {
return err
}
}
// Write content via stdin pipe
_, stderr, exitCode, err := b.dockerExec(ctx, []byte(content), "sh", "-c", fmt.Sprintf("cat %s %q", redir, resolved))
ddArgs := fsBridgeWriteDDArgs(resolved, appendMode)
_, stderr, exitCode, err := b.dockerExec(ctx, []byte(content), ddArgs...)
if err != nil {
return fmt.Errorf("fsbridge write: %w", err)
}
@@ -77,13 +92,25 @@ func (b *FsBridge) WriteFile(ctx context.Context, path, content string, appendMo
return nil
}
func fsBridgeWriteDDArgs(resolved string, appendMode bool) []string {
args := []string{"dd", "bs=1048576", "status=none", "of=" + resolved}
if appendMode {
args = append(args, "conv=notrunc", "oflag=append")
}
return args
}
// ListDir lists files and directories inside the container.
// Matching TS FsBridge.readdir().
func (b *FsBridge) ListDir(ctx context.Context, path string) (string, error) {
resolved := b.resolvePath(path)
realPath, err := b.resolveExistingPath(ctx, resolved)
if err != nil {
return "", err
}
// Use ls -la for detailed listing
stdout, stderr, exitCode, err := b.dockerExec(ctx, nil, "ls", "-la", "--", resolved)
stdout, stderr, exitCode, err := b.dockerExec(ctx, nil, "ls", "-la", "--", realPath)
if err != nil {
return "", fmt.Errorf("fsbridge list: %w", err)
}
@@ -97,8 +124,12 @@ func (b *FsBridge) ListDir(ctx context.Context, path string) (string, error) {
// Stat checks if a path exists and returns basic info.
func (b *FsBridge) Stat(ctx context.Context, path string) (string, error) {
resolved := b.resolvePath(path)
realPath, err := b.resolveExistingPath(ctx, resolved)
if err != nil {
return "", err
}
stdout, stderr, exitCode, err := b.dockerExec(ctx, nil, "stat", "--", resolved)
stdout, stderr, exitCode, err := b.dockerExec(ctx, nil, "stat", "--", realPath)
if err != nil {
return "", fmt.Errorf("fsbridge stat: %w", err)
}
@@ -112,20 +143,96 @@ func (b *FsBridge) Stat(ctx context.Context, path string) (string, error) {
// resolvePath resolves a path relative to the container workdir.
// Validates that absolute paths stay within the workdir (defense in depth).
func (b *FsBridge) resolvePath(path string) string {
workdir := filepath.Clean(b.workdir)
if path == "" || path == "." {
return b.workdir
return workdir
}
var cleaned string
if strings.HasPrefix(path, "/") {
// Validate absolute paths stay within workdir (defense in depth,
// container is already sandboxed with read-only FS + cap-drop ALL).
cleaned := filepath.Clean(path)
if cleaned == b.workdir || strings.HasPrefix(cleaned, b.workdir+"/") {
return cleaned
cleaned = filepath.Clean(path)
} else {
cleaned = filepath.Clean(filepath.Join(workdir, path))
}
if cleaned == workdir || strings.HasPrefix(cleaned, workdir+"/") {
return cleaned
}
return workdir
}
func fsBridgePathWithin(root, target string) bool {
root = filepath.Clean(root)
target = filepath.Clean(target)
if target == root {
return true
}
return strings.HasPrefix(target, root+"/")
}
func (b *FsBridge) containerRealPath(ctx context.Context, path string) (string, error) {
stdout, stderr, exitCode, err := b.dockerExec(ctx, nil, "realpath", "-e", "--", path)
if err != nil {
return "", fmt.Errorf("fsbridge realpath: %w", err)
}
if exitCode != 0 {
return "", fmt.Errorf("realpath failed: %s", strings.TrimSpace(stderr))
}
return strings.TrimSpace(stdout), nil
}
func (b *FsBridge) containerRealWorkdir(ctx context.Context) (string, error) {
return b.containerRealPath(ctx, filepath.Clean(b.workdir))
}
func (b *FsBridge) resolveExistingPath(ctx context.Context, resolved string) (string, error) {
realWorkdir, err := b.containerRealWorkdir(ctx)
if err != nil {
return "", err
}
realPath, err := b.containerRealPath(ctx, resolved)
if err != nil {
return "", err
}
if !fsBridgePathWithin(realWorkdir, realPath) {
return "", fmt.Errorf("path escapes sandbox workdir")
}
return realPath, nil
}
func (b *FsBridge) validateExistingTargetIfPresent(ctx context.Context, resolved string) error {
realWorkdir, err := b.containerRealWorkdir(ctx)
if err != nil {
return err
}
realPath, err := b.containerRealPath(ctx, resolved)
if err != nil {
return nil
}
if !fsBridgePathWithin(realWorkdir, realPath) {
return fmt.Errorf("path escapes sandbox workdir")
}
return nil
}
func (b *FsBridge) validateParentBeforeCreate(ctx context.Context, dir string) error {
realWorkdir, err := b.containerRealWorkdir(ctx)
if err != nil {
return err
}
current := filepath.Clean(dir)
for {
realParent, err := b.containerRealPath(ctx, current)
if err == nil {
if !fsBridgePathWithin(realWorkdir, realParent) {
return fmt.Errorf("path parent escapes sandbox workdir")
}
return nil
}
return b.workdir // fallback to workdir for escapes
next := filepath.Dir(current)
if next == current {
return fmt.Errorf("path parent does not exist inside sandbox workdir")
}
current = next
}
// Relative paths: use filepath.Join for proper normalization
return filepath.Clean(filepath.Join(b.workdir, path))
}
// dockerExec runs a command inside the container and returns stdout, stderr, exit code.
+7 -4
View File
@@ -56,11 +56,13 @@ func (s *PGWebhookCallStore) Create(ctx context.Context, call *store.WebhookCall
`INSERT INTO webhook_calls
(id, tenant_id, webhook_id, agent_id, delivery_id,
idempotency_key, mode, status, callback_url, attempts,
next_attempt_at, request_payload, created_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`,
next_attempt_at, started_at, request_payload, response, last_error,
created_at, completed_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)`,
call.ID, call.TenantID, call.WebhookID, nilUUID(call.AgentID), call.DeliveryID,
call.IdempotencyKey, call.Mode, call.Status, call.CallbackURL, call.Attempts,
call.NextAttemptAt, call.RequestPayload, call.CreatedAt,
call.NextAttemptAt, call.StartedAt, call.RequestPayload, call.Response, call.LastError,
call.CreatedAt, call.CompletedAt,
)
if err != nil {
// Map partial unique index violation (webhook_id, idempotency_key) → typed sentinel.
@@ -141,6 +143,7 @@ func (s *PGWebhookCallStore) ClaimNext(ctx context.Context, tenantID uuid.UUID,
err = tx.QueryRowContext(ctx,
`SELECT id FROM webhook_calls
WHERE tenant_id = $1
AND mode = 'async'
AND status = 'queued'
AND (next_attempt_at IS NULL OR next_attempt_at <= $2)
ORDER BY next_attempt_at ASC NULLS FIRST
@@ -251,7 +254,7 @@ func (s *PGWebhookCallStore) ReclaimStale(ctx context.Context, staleThreshold ti
res, err := s.db.ExecContext(ctx,
`UPDATE webhook_calls
SET status = 'queued', started_at = NULL, lease_token = NULL
WHERE status = 'running' AND started_at < $1`,
WHERE mode = 'async' AND status = 'running' AND started_at < $1`,
staleThreshold,
)
if err != nil {
+8 -1
View File
@@ -16,7 +16,7 @@ var schemaSQL string
// SchemaVersion is the current SQLite schema version.
// Bump this when adding new migration steps below.
const SchemaVersion = 36
const SchemaVersion = 37
// migrations maps version → SQL to apply when upgrading FROM that version.
// schema.sql always represents the LATEST full schema (for fresh DBs).
@@ -559,6 +559,8 @@ CREATE TABLE IF NOT EXISTS agent_workstation_links (
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
PRIMARY KEY (agent_id, workstation_id)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_workstation_default
ON agent_workstation_links(agent_id) WHERE is_default = 1;
CREATE INDEX IF NOT EXISTS idx_agent_workstation_tenant ON agent_workstation_links(tenant_id);`,
// Version 31 → 32: workstation_permissions allowlist table. Mirrors PG migration 000063.
@@ -610,6 +612,11 @@ WHERE id IN (
OR (s.is_system = 0 AND sag.tenant_id <> s.tenant_id)
);`,
// Version 36 → 37: enforce one default workstation link per agent.
// Mirrors PG migration 000062 partial unique index.
36: `CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_workstation_default
ON agent_workstation_links(agent_id) WHERE is_default = 1;`,
// Version 23 → 24: vault_documents scope/ownership consistency triggers.
// Mirrors PG migration 000055 CHECK constraint; SQLite cannot add CHECK via
// ALTER TABLE so we use BEFORE INSERT + BEFORE UPDATE triggers instead.
+2
View File
@@ -1774,6 +1774,8 @@ CREATE TABLE IF NOT EXISTS agent_workstation_links (
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
PRIMARY KEY (agent_id, workstation_id)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_workstation_default
ON agent_workstation_links(agent_id) WHERE is_default = 1;
CREATE INDEX IF NOT EXISTS idx_agent_workstation_tenant ON agent_workstation_links(tenant_id);
-- ============================================================
@@ -48,6 +48,42 @@ func TestEnsureSchema_FreshDB(t *testing.T) {
t.Errorf("vault_documents missing column %q", want)
}
}
for _, table := range []string{"hooks", "hook_agents"} {
var count int
if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?`, table).Scan(&count); err != nil {
t.Fatalf("lookup %s table: %v", table, err)
}
if count != 1 {
t.Errorf("fresh schema missing %q table", table)
}
}
}
func TestEnsureSchema_PreHooksUpgradeCreatesHookTables(t *testing.T) {
db := openTestDBAtVersion(t, 19)
for _, table := range []string{"tenant_hook_budget", "hook_executions", "hook_agents", "hooks"} {
if _, err := db.Exec(`DROP TABLE IF EXISTS ` + table); err != nil {
t.Fatalf("drop %s: %v", table, err)
}
}
if _, err := db.Exec(`UPDATE schema_version SET version = 19`); err != nil {
t.Fatalf("set pre-hooks schema version: %v", err)
}
if err := EnsureSchema(db); err != nil {
t.Fatalf("EnsureSchema (pre-hooks to current) failed: %v", err)
}
for _, table := range []string{"hooks", "hook_agents"} {
var count int
if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?`, table).Scan(&count); err != nil {
t.Fatalf("lookup %s table: %v", table, err)
}
if count != 1 {
t.Errorf("upgrade schema missing %q table", table)
}
}
}
// TestEnsureSchema_MigrationV11Only verifies migrations from v11 onward
+7 -4
View File
@@ -70,11 +70,13 @@ func (s *SQLiteWebhookCallStore) Create(ctx context.Context, call *store.Webhook
`INSERT INTO webhook_calls
(id, tenant_id, webhook_id, agent_id, delivery_id,
idempotency_key, mode, status, callback_url, attempts,
next_attempt_at, request_payload, created_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`,
next_attempt_at, started_at, request_payload, response, last_error,
created_at, completed_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
call.ID, call.TenantID, call.WebhookID, nilUUID(call.AgentID), call.DeliveryID,
call.IdempotencyKey, call.Mode, call.Status, call.CallbackURL, call.Attempts,
call.NextAttemptAt, call.RequestPayload, call.CreatedAt,
call.NextAttemptAt, call.StartedAt, call.RequestPayload, call.Response, call.LastError,
call.CreatedAt, call.CompletedAt,
)
if err != nil {
// Map partial unique index violation (webhook_id, idempotency_key) → typed sentinel.
@@ -154,6 +156,7 @@ func (s *SQLiteWebhookCallStore) ClaimNext(ctx context.Context, tenantID uuid.UU
err = tx.QueryRowContext(ctx,
`SELECT id FROM webhook_calls
WHERE tenant_id = ?
AND mode = 'async'
AND status = 'queued'
AND (next_attempt_at IS NULL OR next_attempt_at <= ?)
ORDER BY next_attempt_at ASC
@@ -265,7 +268,7 @@ func (s *SQLiteWebhookCallStore) ReclaimStale(ctx context.Context, staleThreshol
res, err := s.db.ExecContext(ctx,
`UPDATE webhook_calls
SET status = 'queued', started_at = NULL, lease_token = NULL
WHERE status = 'running' AND started_at < ?`,
WHERE mode = 'async' AND status = 'running' AND started_at < ?`,
staleThreshold,
)
if err != nil {
@@ -160,6 +160,25 @@ func TestWebhookCallClaimNextSkipsRunningAndDone(t *testing.T) {
t.Errorf("expected ErrNoRows when no queued rows, got: %v", err)
}
// A queued sync audit row is not worker-owned and must not be claimed.
syncQueued := &store.WebhookCallData{
ID: uuid.New(),
TenantID: tenantID,
WebhookID: wh.ID,
DeliveryID: uuid.New(),
Mode: "sync",
Status: "queued",
Attempts: 0,
CreatedAt: now,
}
if err := cs.Create(ctx, syncQueued); err != nil {
t.Fatalf("Create queued sync call: %v", err)
}
_, err = cs.ClaimNext(ctx, tenantID, now)
if err != sql.ErrNoRows {
t.Errorf("expected ErrNoRows for queued sync row, got: %v", err)
}
// Insert a queued call due now.
queued := &store.WebhookCallData{
ID: uuid.New(),
@@ -194,6 +213,65 @@ func TestWebhookCallClaimNextSkipsRunningAndDone(t *testing.T) {
}
}
func TestWebhookCallReclaimStaleOnlyAsync(t *testing.T) {
db := openTestWebhookDB(t)
ws := NewSQLiteWebhookStore(db)
cs := NewSQLiteWebhookCallStore(db)
tenantID := uuid.New()
ctx := testTenantCtx(tenantID)
wh := &store.WebhookData{
ID: uuid.New(), TenantID: tenantID, Name: "wh-reclaim", Kind: "llm",
SecretHash: "h-reclaim", Scopes: []string{}, IPAllowlist: []string{},
RateLimitPerMin: 60, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(),
}
if err := ws.Create(ctx, wh); err != nil {
t.Fatalf("Create webhook: %v", err)
}
stale := time.Now().UTC().Add(-time.Hour)
rows := []struct {
mode string
id uuid.UUID
}{
{mode: "sync", id: uuid.New()},
{mode: "async", id: uuid.New()},
}
for _, row := range rows {
_, err := db.ExecContext(ctx,
`INSERT INTO webhook_calls (id,tenant_id,webhook_id,delivery_id,mode,status,attempts,created_at,started_at)
VALUES (?,?,?,?,?,?,?,?,?)`,
row.id, tenantID, wh.ID, uuid.New(), row.mode, "running", 0, stale, stale,
)
if err != nil {
t.Fatalf("insert %s row: %v", row.mode, err)
}
}
n, err := cs.ReclaimStale(ctx, time.Now().UTC())
if err != nil {
t.Fatalf("ReclaimStale: %v", err)
}
if n != 1 {
t.Fatalf("reclaimed %d rows, want 1", n)
}
var syncStatus string
if err := db.QueryRowContext(ctx, `SELECT status FROM webhook_calls WHERE id = ?`, rows[0].id).Scan(&syncStatus); err != nil {
t.Fatalf("select sync row: %v", err)
}
if syncStatus != "running" {
t.Fatalf("sync row status = %q, want running", syncStatus)
}
var asyncStatus string
if err := db.QueryRowContext(ctx, `SELECT status FROM webhook_calls WHERE id = ?`, rows[1].id).Scan(&asyncStatus); err != nil {
t.Fatalf("select async row: %v", err)
}
if asyncStatus != "queued" {
t.Fatalf("async row status = %q, want queued", asyncStatus)
}
}
// TestWebhookCallIdempotencyConflict verifies duplicate (webhook_id, idempotency_key)
// returns ErrIdempotencyConflict.
func TestWebhookCallIdempotencyConflict(t *testing.T) {
@@ -50,6 +50,6 @@ type WorkstationPermissionStore interface {
// NOTE: shells (bash, sh, zsh) are intentionally excluded — adding a shell binary
// bypasses all protection by allowing arbitrary commands as arguments.
var DefaultAllowedBinaries = []string{
"echo", "pwd", "ls", "cat", "git", "env",
"echo", "pwd", "ls", "cat", "git",
"whoami", "hostname", "date", "uname", "claude",
}
+3 -3
View File
@@ -16,8 +16,8 @@ import (
type EditTool struct {
workspace string
restrict bool
allowedPrefixes []string // extra allowed path prefixes (cross-drive on Windows)
deniedPrefixes []string // path prefixes to deny access to (e.g. .goclaw)
allowedPrefixes []string // extra allowed path prefixes (cross-drive on Windows)
deniedPrefixes []string // path prefixes to deny access to (e.g. .goclaw)
sandboxMgr sandbox.Manager
contextFileIntc *ContextFileInterceptor
memIntc *MemoryInterceptor
@@ -218,7 +218,7 @@ func (t *EditTool) executeInSandbox(ctx context.Context, path, oldStr, newStr st
}
containerPath := ResolveSandboxPath(path, containerCwd)
bridge := sandbox.NewFsBridge(sb.ID(), sandbox.DefaultContainerWorkdir)
bridge := sandbox.NewFsBridge(sb.ID(), containerCwd)
content, err := bridge.ReadFile(ctx, containerPath)
if err != nil {
return ErrorResult(fmt.Sprintf("failed to read file: %v", err) + MaybeFsBridgeHint(err))
+15 -17
View File
@@ -23,15 +23,15 @@ var virtualSystemFiles = map[string]string{
// ReadFileTool reads file contents, optionally through a sandbox container.
type ReadFileTool struct {
workspace string
restrict bool
allowedPrefixes []string // extra allowed path prefixes (e.g. skills dirs)
deniedPrefixes []string // path prefixes to deny access to (e.g. .goclaw)
sandboxMgr sandbox.Manager // nil = direct host access
contextFileIntc *ContextFileInterceptor // nil = no virtual FS routing
memIntc *MemoryInterceptor // nil = no memory routing
permStore store.ConfigPermissionStore // nil = no group read restriction
vaultIntc *VaultInterceptor // nil = no vault lazy sync
workspace string
restrict bool
allowedPrefixes []string // extra allowed path prefixes (e.g. skills dirs)
deniedPrefixes []string // path prefixes to deny access to (e.g. .goclaw)
sandboxMgr sandbox.Manager // nil = direct host access
contextFileIntc *ContextFileInterceptor // nil = no virtual FS routing
memIntc *MemoryInterceptor // nil = no memory routing
permStore store.ConfigPermissionStore // nil = no group read restriction
vaultIntc *VaultInterceptor // nil = no vault lazy sync
}
// SetContextFileInterceptor enables virtual FS routing for context files.
@@ -196,15 +196,14 @@ func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *Result
}
func (t *ReadFileTool) executeInSandbox(ctx context.Context, path, sandboxKey string, args map[string]any) *Result {
bridge, err := t.getFsBridge(ctx, sandboxKey)
if err != nil {
return ErrorResult(fmt.Sprintf("sandbox error: %v", err))
}
containerCwd, cwdErr := SandboxCwd(ctx, t.workspace, sandbox.DefaultContainerWorkdir)
if cwdErr != nil {
return ErrorResult(fmt.Sprintf("sandbox path mapping: %v", cwdErr))
}
bridge, err := t.getFsBridge(ctx, sandboxKey, containerCwd)
if err != nil {
return ErrorResult(fmt.Sprintf("sandbox error: %v", err))
}
containerPath := ResolveSandboxPath(path, containerCwd)
data, err := bridge.ReadFile(ctx, containerPath)
@@ -215,12 +214,12 @@ func (t *ReadFileTool) executeInSandbox(ctx context.Context, path, sandboxKey st
return t.paginateOutput(data, args)
}
func (t *ReadFileTool) getFsBridge(ctx context.Context, sandboxKey string) (*sandbox.FsBridge, error) {
func (t *ReadFileTool) getFsBridge(ctx context.Context, sandboxKey, containerCwd string) (*sandbox.FsBridge, error) {
sb, err := t.sandboxMgr.Get(ctx, sandboxKey, t.workspace, SandboxConfigFromCtx(ctx))
if err != nil {
return nil, err
}
return sandbox.NewFsBridge(sb.ID(), sandbox.DefaultContainerWorkdir), nil
return sandbox.NewFsBridge(sb.ID(), containerCwd), nil
}
// readFileMaxChars is the output cap for read_file. Large files require offset/limit pagination.
@@ -582,4 +581,3 @@ func resolveThroughExistingAncestors(target string) (string, error) {
}
return filepath.Clean(target), nil
}
+6 -7
View File
@@ -140,15 +140,14 @@ func (t *ListFilesTool) Execute(ctx context.Context, args map[string]any) *Resul
}
func (t *ListFilesTool) executeInSandbox(ctx context.Context, path, sandboxKey string) *Result {
bridge, err := t.getFsBridge(ctx, sandboxKey)
if err != nil {
return ErrorResult(fmt.Sprintf("sandbox error: %v", err))
}
containerCwd, cwdErr := SandboxCwd(ctx, t.workspace, sandbox.DefaultContainerWorkdir)
if cwdErr != nil {
return ErrorResult(fmt.Sprintf("sandbox path mapping: %v", cwdErr))
}
bridge, err := t.getFsBridge(ctx, sandboxKey, containerCwd)
if err != nil {
return ErrorResult(fmt.Sprintf("sandbox error: %v", err))
}
containerPath := ResolveSandboxPath(path, containerCwd)
output, err := bridge.ListDir(ctx, containerPath)
@@ -159,10 +158,10 @@ func (t *ListFilesTool) executeInSandbox(ctx context.Context, path, sandboxKey s
return SilentResult(output)
}
func (t *ListFilesTool) getFsBridge(ctx context.Context, sandboxKey string) (*sandbox.FsBridge, error) {
func (t *ListFilesTool) getFsBridge(ctx context.Context, sandboxKey, containerCwd string) (*sandbox.FsBridge, error) {
sb, err := t.sandboxMgr.Get(ctx, sandboxKey, t.workspace, SandboxConfigFromCtx(ctx))
if err != nil {
return nil, err
}
return sandbox.NewFsBridge(sb.ID(), sandbox.DefaultContainerWorkdir), nil
return sandbox.NewFsBridge(sb.ID(), containerCwd), nil
}
+8 -9
View File
@@ -15,8 +15,8 @@ import (
type WriteFileTool struct {
workspace string
restrict bool
allowedPrefixes []string // extra allowed path prefixes (cross-drive on Windows)
deniedPrefixes []string // path prefixes to deny access to (e.g. .goclaw)
allowedPrefixes []string // extra allowed path prefixes (cross-drive on Windows)
deniedPrefixes []string // path prefixes to deny access to (e.g. .goclaw)
sandboxMgr sandbox.Manager
contextFileIntc *ContextFileInterceptor // nil = no virtual FS routing
memIntc *MemoryInterceptor // nil = no memory routing
@@ -240,15 +240,14 @@ func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *Resul
}
func (t *WriteFileTool) executeInSandbox(ctx context.Context, path, content, sandboxKey string, deliver, appendMode bool) *Result {
bridge, err := t.getFsBridge(ctx, sandboxKey)
if err != nil {
return ErrorResult(fmt.Sprintf("sandbox error: %v", err))
}
containerCwd, cwdErr := SandboxCwd(ctx, t.workspace, sandbox.DefaultContainerWorkdir)
if cwdErr != nil {
return ErrorResult(fmt.Sprintf("sandbox path mapping: %v", cwdErr))
}
bridge, err := t.getFsBridge(ctx, sandboxKey, containerCwd)
if err != nil {
return ErrorResult(fmt.Sprintf("sandbox error: %v", err))
}
containerPath := ResolveSandboxPath(path, containerCwd)
if err := bridge.WriteFile(ctx, containerPath, content, appendMode); err != nil {
@@ -284,10 +283,10 @@ func (t *WriteFileTool) executeInSandbox(ctx context.Context, path, content, san
return result
}
func (t *WriteFileTool) getFsBridge(ctx context.Context, sandboxKey string) (*sandbox.FsBridge, error) {
func (t *WriteFileTool) getFsBridge(ctx context.Context, sandboxKey, containerCwd string) (*sandbox.FsBridge, error) {
sb, err := t.sandboxMgr.Get(ctx, sandboxKey, t.workspace, SandboxConfigFromCtx(ctx))
if err != nil {
return nil, err
}
return sandbox.NewFsBridge(sb.ID(), sandbox.DefaultContainerWorkdir), nil
return sandbox.NewFsBridge(sb.ID(), containerCwd), nil
}
+15 -6
View File
@@ -35,12 +35,21 @@ func SandboxCwd(ctx context.Context, globalWorkspace, containerBase string) (str
}
// ResolveSandboxPath resolves a tool-provided path (relative or absolute)
// against the sandbox container CWD. If the path is relative, it is joined
// with containerCwd. Absolute paths are returned as-is (the sandbox
// filesystem already restricts access to the mounted volume).
// against the sandbox container CWD. Escapes are rejected to containerCwd so a
// tool scoped to /workspace/agent-a cannot address /workspace/agent-b.
func ResolveSandboxPath(filePath, containerCwd string) string {
if strings.HasPrefix(filePath, "/") {
return filePath
cwd := path.Clean(containerCwd)
if cwd == "." || cwd == "/" {
cwd = "/workspace"
}
return path.Join(containerCwd, filePath)
var resolved string
if strings.HasPrefix(filePath, "/") {
resolved = path.Clean(filePath)
} else {
resolved = path.Clean(path.Join(cwd, filePath))
}
if resolved == cwd || strings.HasPrefix(resolved, cwd+"/") {
return resolved
}
return cwd
}
+13 -1
View File
@@ -109,11 +109,23 @@ func TestResolveSandboxPath(t *testing.T) {
want: "/workspace/agent-a/subdir/file.txt",
},
{
name: "absolute path passed through",
name: "absolute sibling workspace path is rejected to cwd",
path: "/workspace/agent-a/file.txt",
containerCwd: "/workspace/agent-b",
want: "/workspace/agent-b",
},
{
name: "absolute path inside cwd stays absolute",
path: "/workspace/agent-a/file.txt",
containerCwd: "/workspace/agent-a",
want: "/workspace/agent-a/file.txt",
},
{
name: "relative parent escape is rejected to cwd",
path: "../agent-b/file.txt",
containerCwd: "/workspace/agent-a",
want: "/workspace/agent-a",
},
{
name: "dot path",
path: ".",
+16
View File
@@ -372,10 +372,26 @@ func (t *WorkstationExecTool) streamAndCollect(
wg.Add(2)
go readStream(stream.Stdout(), "stdout", &stdoutTail)
go readStream(stream.Stderr(), "stderr", &stderrTail)
readersDone := make(chan struct{})
var killOnce sync.Once
go func() {
select {
case <-ctx.Done():
killOnce.Do(func() { _ = stream.Kill() })
case <-readersDone:
}
}()
wg.Wait()
close(readersDone)
exitCode, waitErr := stream.Wait()
durationMs := time.Since(startTime).Milliseconds()
if ctx.Err() != nil {
killOnce.Do(func() { _ = stream.Kill() })
if waitErr == nil {
waitErr = ctx.Err()
}
}
// Emit done event.
if t.eventBus != nil {
+85
View File
@@ -0,0 +1,85 @@
package tools
import (
"context"
"errors"
"io"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
type blockingWorkstationStream struct {
stdoutR *io.PipeReader
stdoutW *io.PipeWriter
stderrR *io.PipeReader
stderrW *io.PipeWriter
killN atomic.Int64
once sync.Once
done chan struct{}
}
func newBlockingWorkstationStream() *blockingWorkstationStream {
stdoutR, stdoutW := io.Pipe()
stderrR, stderrW := io.Pipe()
return &blockingWorkstationStream{
stdoutR: stdoutR,
stdoutW: stdoutW,
stderrR: stderrR,
stderrW: stderrW,
done: make(chan struct{}),
}
}
func (s *blockingWorkstationStream) Stdout() io.Reader { return s.stdoutR }
func (s *blockingWorkstationStream) Stderr() io.Reader { return s.stderrR }
func (s *blockingWorkstationStream) Wait() (int, error) {
<-s.done
return 137, errors.New("killed")
}
func (s *blockingWorkstationStream) Kill() error {
s.killN.Add(1)
s.once.Do(func() {
_ = s.stdoutW.CloseWithError(context.Canceled)
_ = s.stderrW.CloseWithError(context.Canceled)
close(s.done)
})
return nil
}
func TestStreamAndCollectTimeoutKillsBlockedReaders(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer cancel()
stream := newBlockingWorkstationStream()
tool := &WorkstationExecTool{}
ws := &store.Workstation{
ID: uuid.New(),
TenantID: uuid.New(),
}
done := make(chan *Result, 1)
go func() {
done <- tool.streamAndCollect(ctx, stream, ws, uuid.NewString(), "session-timeout", "sleep 60")
}()
select {
case result := <-done:
if !result.IsError {
t.Fatalf("expected timeout result to be an error, got %#v", result)
}
if stream.killN.Load() == 0 {
t.Fatal("expected timed-out stream to be killed")
}
case <-time.After(time.Second):
t.Fatal("streamAndCollect did not return after context timeout")
}
}
+18 -2
View File
@@ -73,6 +73,22 @@ type asyncPayload struct {
Metadata json.RawMessage `json:"metadata,omitempty"`
}
func decodeAsyncPayload(payload []byte) (asyncPayload, error) {
var envelope struct {
BodyHash string `json:"body_hash"`
Meta json.RawMessage `json:"meta"`
}
if err := json.Unmarshal(payload, &envelope); err == nil && envelope.BodyHash != "" && len(envelope.Meta) > 0 {
payload = envelope.Meta
}
var req asyncPayload
if err := json.Unmarshal(payload, &req); err != nil {
return asyncPayload{}, err
}
return req, nil
}
// callbackPayload is the JSON body POSTed to the receiver's callback_url.
type callbackPayload struct {
CallID string `json:"call_id"`
@@ -300,8 +316,8 @@ func (w *WebhookWorker) execute(ctx context.Context, call *store.WebhookCallData
}()
// Decode stored request payload.
var req asyncPayload
if err := json.Unmarshal(call.RequestPayload, &req); err != nil {
req, err := decodeAsyncPayload(call.RequestPayload)
if err != nil {
slog.Error("webhook.worker.payload_decode_failed",
"call_id", call.ID,
"error", err,
+29
View File
@@ -169,6 +169,35 @@ func newTestCall(callbackURL string, agentID *uuid.UUID) *store.WebhookCallData
return call
}
func TestDecodeAsyncPayload_UnwrapsAuditEnvelope(t *testing.T) {
meta := asyncPayload{
Input: json.RawMessage(`"hello"`),
CallbackURL: "https://example.com/callback",
}
metaBytes, err := json.Marshal(meta)
if err != nil {
t.Fatalf("marshal meta: %v", err)
}
envelope, err := json.Marshal(map[string]any{
"body_hash": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"meta": json.RawMessage(metaBytes),
})
if err != nil {
t.Fatalf("marshal envelope: %v", err)
}
got, err := decodeAsyncPayload(envelope)
if err != nil {
t.Fatalf("decodeAsyncPayload: %v", err)
}
if string(got.Input) != `"hello"` {
t.Fatalf("input = %s, want %s", got.Input, `"hello"`)
}
if got.CallbackURL != meta.CallbackURL {
t.Fatalf("callback_url = %q, want %q", got.CallbackURL, meta.CallbackURL)
}
}
// newTestWebhook creates a webhook with an encrypted raw secret.
// Returns the webhook and the raw secret bytes for signature verification.
// encKey is the AES-256-GCM key (same as testEncKey).
@@ -112,6 +112,10 @@ func (c *AllowlistChecker) Check(
c.auditDeny(ws, cmd, "empty_binary_name")
return errors.New(i18n.T(locale, i18n.MsgWorkstationCmdDenied, "empty binary name"))
}
if reason := validateLauncherArgs(binaryName, args); reason != "" {
c.auditDeny(ws, cmd, reason)
return errors.New(i18n.T(locale, i18n.MsgWorkstationCmdDenied, reason))
}
patterns, err := c.loadAllowlist(ctx, ws.ID)
if err != nil {
@@ -186,6 +190,16 @@ func isBlockedEnvKey(k string) bool {
return strings.HasPrefix(k, "GOCLAW_")
}
func validateLauncherArgs(binaryName string, args []string) string {
switch binaryName {
case "env", "nohup", "setsid", "timeout", "nice", "stdbuf", "xargs":
if len(args) > 0 {
return "launcher command with arguments denied: " + binaryName
}
}
return ""
}
// loadAllowlist returns the enabled binary name patterns for workstationID.
// Results are cached for cacheTTL; evicted by Invalidate().
func (c *AllowlistChecker) loadAllowlist(ctx context.Context, workstationID uuid.UUID) ([]string, error) {
@@ -0,0 +1,15 @@
package security
import "testing"
func TestValidateLauncherArgsDeniesEnvCommandLaunch(t *testing.T) {
if reason := validateLauncherArgs("env", []string{"bash", "-lc", "id"}); reason == "" {
t.Fatal("expected env with command args to be denied")
}
}
func TestValidateLauncherArgsAllowsPlainNonLauncherCommand(t *testing.T) {
if reason := validateLauncherArgs("git", []string{"status"}); reason != "" {
t.Fatalf("expected git args to be allowed, got %q", reason)
}
}
@@ -154,8 +154,8 @@ export function WorkstationCreateDialog({
{backend === "ssh" && (
<>
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2 space-y-1.5">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="ws-host">{t("createDialog.hostLabel")}</Label>
<Input
id="ws-host"