WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s

This commit is contained in:
yawan
2026-09-22 16:19:06 +08:00
commit f7fc992f57
3929 changed files with 1006607 additions and 0 deletions
+159
View File
@@ -0,0 +1,159 @@
package utils
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"errors"
"io"
"os"
"strings"
)
// EncPrefix marks a string as AES-256-GCM encrypted
const EncPrefix = "enc:v1:"
// GetAESKey reads the 32-byte AES key from SYSTEM_AES_KEY env.
// Returns nil if not set or not exactly 32 bytes.
func GetAESKey() []byte {
key := []byte(os.Getenv("SYSTEM_AES_KEY"))
if len(key) == 32 {
return key
}
return nil
}
// EncryptAESGCM encrypts plaintext with AES-256-GCM.
// Returns the original string if empty, already encrypted, or key is nil.
func EncryptAESGCM(plaintext string, key []byte) (string, error) {
if plaintext == "" || key == nil {
return plaintext, nil
}
if strings.HasPrefix(plaintext, EncPrefix) {
return plaintext, nil
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
aesgcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, aesgcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
ciphertext := aesgcm.Seal(nil, nonce, []byte(plaintext), nil)
combined := append(nonce, ciphertext...)
return EncPrefix + base64.RawURLEncoding.EncodeToString(combined), nil
}
// ErrEncryptedDataMissingKey is returned by DecryptStoredSecret when the value
// carries the enc:v1: prefix but no AES key is available to decrypt it. This
// signals an operator misconfiguration (typically a rotated or unset
// SYSTEM_AES_KEY) and must propagate so the system fails loudly instead of
// silently using ciphertext as a credential.
var ErrEncryptedDataMissingKey = errors.New("encrypted data found but SYSTEM_AES_KEY is not set or has wrong length")
// DecryptAESGCM decrypts an AES-256-GCM encrypted string.
// If the string lacks the enc:v1: prefix, it's treated as legacy plaintext and returned as-is.
func DecryptAESGCM(encrypted string, key []byte) (string, error) {
if encrypted == "" || key == nil {
return encrypted, nil
}
if !strings.HasPrefix(encrypted, EncPrefix) {
return encrypted, nil
}
data, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(encrypted, EncPrefix))
if err != nil {
return "", err
}
if len(data) < 12 {
return "", errors.New("invalid encrypted data: too short")
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
aesgcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce, ciphertext := data[:aesgcm.NonceSize()], data[aesgcm.NonceSize():]
plaintext, err := aesgcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", err
}
return string(plaintext), nil
}
// DecryptStoredSecret decrypts a value loaded from the database with strict
// error propagation. Use this in hot-path code that is about to actually
// USE the credential (e.g. building an upstream HTTP request) — a rotated
// or missing key must surface as a loud error rather than silently
// degrading to "no credential", because the user expects the configured
// upstream call to either work or fail with a clear "auth misconfigured"
// message.
//
// Behaviour:
// - empty input -> empty output, no error.
// - no enc:v1: prefix -> returned as-is (legacy plaintext column), no error.
// - has enc:v1: prefix and SYSTEM_AES_KEY is missing or wrong length ->
// returns ErrEncryptedDataMissingKey.
// - has enc:v1: prefix and key is set -> decrypts, returns any decryption
// error verbatim (e.g. base64 decode failure, GCM auth tag mismatch from a
// rotated key).
//
// For GORM Scan paths that load a whole row (where erroring out hides the
// rest of the resource from the user) use DecryptStoredSecretLenient
// instead — it lets the row load with the secret blanked out so the UI
// can render "credential not configured" and the user can re-enter it,
// without crashing list endpoints when the operator rotates or removes
// SYSTEM_AES_KEY.
func DecryptStoredSecret(encrypted string) (string, error) {
if encrypted == "" {
return "", nil
}
if !strings.HasPrefix(encrypted, EncPrefix) {
return encrypted, nil
}
key := GetAESKey()
if key == nil {
return "", ErrEncryptedDataMissingKey
}
return DecryptAESGCM(encrypted, key)
}
// DecryptStoredSecretLenient is the load-path counterpart for use inside
// GORM Scan / AfterFind hooks. It prefers graceful degradation over
// failing the whole row load.
//
// Returns:
// - (plaintext, true) for empty input, legacy plaintext, or successful decrypt
// - ("", false) when the value has the enc:v1: prefix but cannot be
// decrypted (SYSTEM_AES_KEY missing, rotated, or ciphertext corrupted).
// The bool=false case is the operator's signal: log a warning and treat
// the field as unconfigured. The row still loads so the rest of the
// resource displays normally; the UI shows "credential not configured",
// and any upstream call attempted with this empty value fails with a
// clear "missing api key" rather than sending ciphertext as the key.
//
// Rationale: a single line of broken ciphertext used to crash entire list
// endpoints (e.g. ListModels returning "" because one row failed Scan),
// hiding all other models from the user. Lenient load + loud per-row log
// is strictly more recoverable than fail-fast Scan.
func DecryptStoredSecretLenient(encrypted string) (plaintext string, ok bool) {
plain, err := DecryptStoredSecret(encrypted)
if err != nil {
return "", false
}
return plain, true
}
+159
View File
@@ -0,0 +1,159 @@
package utils
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const testAESKey = "01234567890123456789012345678901" // 32 bytes
func TestEncryptAESGCM(t *testing.T) {
key := []byte(testAESKey)
t.Run("encrypts plaintext with enc:v1: prefix", func(t *testing.T) {
encrypted, err := EncryptAESGCM("sk-secret-key", key)
require.NoError(t, err)
assert.True(t, strings.HasPrefix(encrypted, EncPrefix))
assert.NotEqual(t, "sk-secret-key", encrypted)
})
t.Run("returns empty string as-is", func(t *testing.T) {
encrypted, err := EncryptAESGCM("", key)
require.NoError(t, err)
assert.Equal(t, "", encrypted)
})
t.Run("returns already encrypted string as-is (idempotent)", func(t *testing.T) {
first, err := EncryptAESGCM("sk-secret-key", key)
require.NoError(t, err)
second, err := EncryptAESGCM(first, key)
require.NoError(t, err)
assert.Equal(t, first, second, "re-encrypting should be a no-op")
})
t.Run("returns plaintext when key is nil", func(t *testing.T) {
encrypted, err := EncryptAESGCM("sk-secret-key", nil)
require.NoError(t, err)
assert.Equal(t, "sk-secret-key", encrypted)
})
}
func TestDecryptAESGCM(t *testing.T) {
key := []byte(testAESKey)
t.Run("round-trip encrypt then decrypt", func(t *testing.T) {
original := "sk-my-secret-api-key"
encrypted, err := EncryptAESGCM(original, key)
require.NoError(t, err)
decrypted, err := DecryptAESGCM(encrypted, key)
require.NoError(t, err)
assert.Equal(t, original, decrypted)
})
t.Run("returns legacy plaintext as-is (no enc:v1: prefix)", func(t *testing.T) {
decrypted, err := DecryptAESGCM("sk-legacy-plaintext", key)
require.NoError(t, err)
assert.Equal(t, "sk-legacy-plaintext", decrypted)
})
t.Run("returns empty string as-is", func(t *testing.T) {
decrypted, err := DecryptAESGCM("", key)
require.NoError(t, err)
assert.Equal(t, "", decrypted)
})
t.Run("returns as-is when key is nil", func(t *testing.T) {
decrypted, err := DecryptAESGCM("enc:v1:something", nil)
require.NoError(t, err)
assert.Equal(t, "enc:v1:something", decrypted)
})
}
func TestDecryptStoredSecret(t *testing.T) {
t.Run("returns empty string as-is", func(t *testing.T) {
t.Setenv("SYSTEM_AES_KEY", testAESKey)
out, err := DecryptStoredSecret("")
require.NoError(t, err)
assert.Equal(t, "", out)
})
t.Run("legacy plaintext (no enc:v1: prefix) returned as-is", func(t *testing.T) {
t.Setenv("SYSTEM_AES_KEY", testAESKey)
out, err := DecryptStoredSecret("sk-legacy-plaintext")
require.NoError(t, err)
assert.Equal(t, "sk-legacy-plaintext", out)
})
t.Run("legacy plaintext is returned even when key is unset", func(t *testing.T) {
t.Setenv("SYSTEM_AES_KEY", "")
out, err := DecryptStoredSecret("sk-legacy-plaintext")
require.NoError(t, err)
assert.Equal(t, "sk-legacy-plaintext", out)
})
t.Run("round-trip with valid key", func(t *testing.T) {
t.Setenv("SYSTEM_AES_KEY", testAESKey)
encrypted, err := EncryptAESGCM("sk-secret", []byte(testAESKey))
require.NoError(t, err)
out, err := DecryptStoredSecret(encrypted)
require.NoError(t, err)
assert.Equal(t, "sk-secret", out)
})
t.Run("encrypted value with missing key returns ErrEncryptedDataMissingKey", func(t *testing.T) {
encrypted, err := EncryptAESGCM("sk-secret", []byte(testAESKey))
require.NoError(t, err)
t.Setenv("SYSTEM_AES_KEY", "")
out, err := DecryptStoredSecret(encrypted)
require.ErrorIs(t, err, ErrEncryptedDataMissingKey)
assert.Equal(t, "", out, "ciphertext must NOT leak when decryption is impossible")
})
t.Run("encrypted value with wrong-length key returns ErrEncryptedDataMissingKey", func(t *testing.T) {
encrypted, err := EncryptAESGCM("sk-secret", []byte(testAESKey))
require.NoError(t, err)
t.Setenv("SYSTEM_AES_KEY", "too-short")
out, err := DecryptStoredSecret(encrypted)
require.ErrorIs(t, err, ErrEncryptedDataMissingKey)
assert.Equal(t, "", out)
})
t.Run("encrypted value with rotated key returns auth-tag error", func(t *testing.T) {
encrypted, err := EncryptAESGCM("sk-secret", []byte(testAESKey))
require.NoError(t, err)
t.Setenv("SYSTEM_AES_KEY", "abcdefghijklmnopqrstuvwxyz123456")
out, err := DecryptStoredSecret(encrypted)
require.Error(t, err)
assert.NotErrorIs(t, err, ErrEncryptedDataMissingKey, "should be a real auth failure, not the missing-key sentinel")
assert.Equal(t, "", out, "ciphertext must NOT leak when decryption fails")
})
}
func TestGetAESKey(t *testing.T) {
t.Run("returns key when SYSTEM_AES_KEY is 32 bytes", func(t *testing.T) {
t.Setenv("SYSTEM_AES_KEY", testAESKey)
key := GetAESKey()
assert.Equal(t, []byte(testAESKey), key)
})
t.Run("returns nil when SYSTEM_AES_KEY is not set", func(t *testing.T) {
t.Setenv("SYSTEM_AES_KEY", "")
key := GetAESKey()
assert.Nil(t, key)
})
t.Run("returns nil when SYSTEM_AES_KEY is wrong length", func(t *testing.T) {
t.Setenv("SYSTEM_AES_KEY", "too-short")
key := GetAESKey()
assert.Nil(t, key)
})
}
+90
View File
@@ -0,0 +1,90 @@
package utils
import (
"context"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
// CleanupStaleRunningTasks 清理可能残留的running task keys
// 这是一个调试和维护工具,可以用来清理因异常情况导致的残留running keys
func CleanupStaleRunningTasks(ctx context.Context, redisClient *redis.Client, keyPrefix string, maxAge time.Duration) (int, error) {
// 获取所有匹配的keys
keys, err := redisClient.Keys(ctx, keyPrefix+"*").Result()
if err != nil {
return 0, fmt.Errorf("failed to get keys: %w", err)
}
if len(keys) == 0 {
return 0, nil
}
// 检查每个key的TTL
var staleTasks []string
for _, key := range keys {
ttl, err := redisClient.TTL(ctx, key).Result()
if err != nil {
continue // 跳过错误的key
}
// 如果TTL小于0(永不过期)或者剩余时间太长(可能是残留的),标记为stale
if ttl < 0 || ttl > maxAge {
staleTasks = append(staleTasks, key)
}
}
if len(staleTasks) == 0 {
return 0, nil
}
// 删除stale keys
deleted, err := redisClient.Del(ctx, staleTasks...).Result()
if err != nil {
return 0, fmt.Errorf("failed to delete stale keys: %w", err)
}
return int(deleted), nil
}
// CheckRunningTaskStatus 检查指定running task的状态
func CheckRunningTaskStatus(ctx context.Context, redisClient *redis.Client, runningKey, progressKey string) (map[string]interface{}, error) {
result := make(map[string]interface{})
// 检查running key
runningTaskID, err := redisClient.Get(ctx, runningKey).Result()
if err != nil {
if err == redis.Nil {
result["running_task_exists"] = false
} else {
return nil, fmt.Errorf("failed to get running task: %w", err)
}
} else {
result["running_task_exists"] = true
result["running_task_id"] = runningTaskID
// 获取running key的TTL
ttl, _ := redisClient.TTL(ctx, runningKey).Result()
result["running_task_ttl"] = ttl.String()
}
// 检查progress key
progressData, err := redisClient.Get(ctx, progressKey).Result()
if err != nil {
if err == redis.Nil {
result["progress_exists"] = false
} else {
return nil, fmt.Errorf("failed to get progress: %w", err)
}
} else {
result["progress_exists"] = true
result["progress_data"] = progressData
// 获取progress key的TTL
ttl, _ := redisClient.TTL(ctx, progressKey).Result()
result["progress_ttl"] = ttl.String()
}
return result, nil
}
+87
View File
@@ -0,0 +1,87 @@
package utils
import (
"net/http"
"strings"
)
// reservedHeaderKeys 列出不允许被用户自定义头覆盖的关键请求头。
// 这些头由各 provider 的签名、鉴权或 SSE 流程控制,覆盖后可能直接导致调用失败。
var reservedHeaderKeys = map[string]struct{}{
"authorization": {},
"api-key": {},
"x-api-key": {},
"x-goog-api-key": {},
"content-type": {},
"content-length": {},
"accept-encoding": {},
"host": {},
"connection": {},
"transfer-encoding": {},
}
// IsReservedHeader 判断某个 header key 是否为保留 header,保留 header 不允许被自定义头覆盖。
func IsReservedHeader(key string) bool {
_, ok := reservedHeaderKeys[strings.ToLower(strings.TrimSpace(key))]
return ok
}
// ApplyCustomHeaders 将用户自定义的 header 写入 http.Request。
// 保留 header(Authorization、api-key、Content-Type 等)会被跳过以避免破坏鉴权/签名。
// 其它 header 会直接覆盖同名条目,允许用户替换默认值(例如 Accept)。
func ApplyCustomHeaders(req *http.Request, headers map[string]string) {
if req == nil || len(headers) == 0 {
return
}
for k, v := range headers {
name := strings.TrimSpace(k)
if name == "" {
continue
}
if IsReservedHeader(name) {
continue
}
req.Header.Set(name, v)
}
}
// CustomHeadersRoundTripper 是一个 http.RoundTripper 包装器,
// 会在每个 HTTP 请求发出前注入用户自定义的 header。
// 用于无法直接拿到底层 *http.Request 的场景(如 go-openai SDK)。
type CustomHeadersRoundTripper struct {
Headers map[string]string
Base http.RoundTripper
}
// RoundTrip 实现 http.RoundTripper 接口。
func (t *CustomHeadersRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
base := t.Base
if base == nil {
base = http.DefaultTransport
}
if len(t.Headers) > 0 {
// 复制请求以避免修改调用方传入的对象。
cloned := req.Clone(req.Context())
ApplyCustomHeaders(cloned, t.Headers)
return base.RoundTrip(cloned)
}
return base.RoundTrip(req)
}
// WrapHTTPClientWithHeaders 返回一个新的 *http.Client,在原有 client 基础上注入自定义 header。
// 如果 headers 为空则直接返回原 client,避免不必要的开销。
func WrapHTTPClientWithHeaders(client *http.Client, headers map[string]string) *http.Client {
if len(headers) == 0 {
return client
}
if client == nil {
client = NewSSRFSafeHTTPClient(DefaultSSRFSafeHTTPClientConfig())
}
base := client.Transport
if base == nil {
base = http.DefaultTransport
}
wrapped := *client
wrapped.Transport = &CustomHeadersRoundTripper{Headers: headers, Base: base}
return &wrapped
}
+94
View File
@@ -0,0 +1,94 @@
package utils
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestApplyCustomHeaders_SkipReserved(t *testing.T) {
req, _ := http.NewRequest("GET", "https://example.com", nil)
req.Header.Set("Authorization", "Bearer original")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-goog-api-key", "google-original")
ApplyCustomHeaders(req, map[string]string{
"Authorization": "Bearer injected",
"Content-Type": "text/plain",
"x-goog-api-key": "google-injected",
"X-Trace-Id": "trace-123",
"X-Route": "edge",
"": "empty-key-should-be-skipped",
})
if got := req.Header.Get("Authorization"); got != "Bearer original" {
t.Fatalf("authorization overwritten: %q", got)
}
if got := req.Header.Get("Content-Type"); got != "application/json" {
t.Fatalf("content-type overwritten: %q", got)
}
if got := req.Header.Get("x-goog-api-key"); got != "google-original" {
t.Fatalf("x-goog-api-key overwritten: %q", got)
}
if got := req.Header.Get("X-Trace-Id"); got != "trace-123" {
t.Fatalf("X-Trace-Id not injected: %q", got)
}
if got := req.Header.Get("X-Route"); got != "edge" {
t.Fatalf("X-Route not injected: %q", got)
}
}
func TestApplyCustomHeaders_NilSafe(t *testing.T) {
ApplyCustomHeaders(nil, map[string]string{"x": "y"})
req, _ := http.NewRequest("GET", "https://example.com", nil)
ApplyCustomHeaders(req, nil)
if len(req.Header) != 0 {
t.Fatalf("unexpected headers added: %+v", req.Header)
}
}
func TestWrapHTTPClientWithHeaders(t *testing.T) {
t.Setenv("SSRF_WHITELIST", "127.0.0.1,::1,localhost")
ResetSSRFWhitelistForTest()
t.Cleanup(ResetSSRFWhitelistForTest)
gotTrace := ""
gotAuth := ""
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotTrace = r.Header.Get("X-Trace-Id")
gotAuth = r.Header.Get("Authorization")
io.Copy(io.Discard, r.Body)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
client := WrapHTTPClientWithHeaders(nil, map[string]string{
"X-Trace-Id": "rt-1",
"Authorization": "Bearer should-not-override",
})
req, _ := http.NewRequest("POST", srv.URL, strings.NewReader("{}"))
req.Header.Set("Authorization", "Bearer kept")
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
resp.Body.Close()
if gotTrace != "rt-1" {
t.Fatalf("expected custom header injected, got %q", gotTrace)
}
if gotAuth != "Bearer kept" {
t.Fatalf("reserved header must not be overridden, got %q", gotAuth)
}
}
func TestWrapHTTPClientWithHeaders_EmptyReturnsOriginal(t *testing.T) {
orig := &http.Client{}
wrapped := WrapHTTPClientWithHeaders(orig, nil)
if wrapped != orig {
t.Fatalf("expected original client returned when headers empty")
}
}
+81
View File
@@ -0,0 +1,81 @@
package utils
import (
"os"
"strconv"
)
const (
defaultMaxFileSizeMB = 50
// maxFileSizeMBCeiling is the supported single-file transfer ceiling. A
// deployment still needs the same value configured in nginx, docreader and
// the browser runtime; the application refuses larger values consistently.
maxFileSizeMBCeiling = 20 * 1024
defaultMaxSkillBundleSizeMB = 256
// maxSkillBundleSizeMBCeiling matches the install-time uncompressed
// archive cap: a download larger than that cannot become a valid skill.
maxSkillBundleSizeMBCeiling = 512
)
// GetMaxFileSize returns the maximum file upload size in bytes.
// Default is 50MB, can be configured via MAX_FILE_SIZE_MB environment variable.
//
// MAX_FILE_SIZE_MB is intentionally a deploy-time-only knob (NOT a
// runtime system_setting). The effective upload limit is gated by
// three other layers that all read this env at startup and cache the
// value:
// - frontend nginx client_max_body_size (envsubst into nginx.conf)
// - docreader gRPC max_send/recv_message_length
// - frontend client-side check via window.__RUNTIME_CONFIG__
//
// Surfacing a SystemAdmin UI knob whose effect is silently capped by
// any of the above would mislead operators ("I raised it to 200MB but
// nginx still returns 413"). Until all four layers can be reconfigured
// in lockstep without container restarts, every call site must read
// the env directly via this helper.
func GetMaxFileSize() int64 {
return GetMaxFileSizeMB() * 1024 * 1024
}
// GetMaxFileSizeMB returns the maximum file upload size in MB. Same
// caveat as GetMaxFileSize — handlers should prefer SystemSettingService.GetInt.
func GetMaxFileSizeMB() int64 {
value := envSizeMB("MAX_FILE_SIZE_MB", defaultMaxFileSizeMB)
if value > maxFileSizeMBCeiling {
return maxFileSizeMBCeiling
}
return value
}
// GetMaxSkillBundleSize is the compressed zip / source-download cap for
// skills. Knowledge uploads stay on GetMaxFileSize: packages such as
// ppt-master exceed 50MB, but raising the document limit would also
// inflate docreader gRPC messages. A GitHub zipball is the whole
// repository archive, not the SKILL.md subtree, so a huge monorepo can
// still be refused here even when the skill itself is small.
func GetMaxSkillBundleSize() int64 {
return GetMaxSkillBundleSizeMB() * 1024 * 1024
}
// GetMaxSkillBundleSizeMB returns the skill zip cap in MB.
// MAX_SKILL_BUNDLE_SIZE_MB defaults to 256, is never below MAX_FILE_SIZE_MB,
// and is capped at 512.
func GetMaxSkillBundleSizeMB() int64 {
skillMB := envSizeMB("MAX_SKILL_BUNDLE_SIZE_MB", defaultMaxSkillBundleSizeMB)
if fileMB := GetMaxFileSizeMB(); skillMB < fileMB {
skillMB = fileMB
}
if skillMB > maxSkillBundleSizeMBCeiling {
return maxSkillBundleSizeMBCeiling
}
return skillMB
}
func envSizeMB(key string, fallback int64) int64 {
if sizeStr := os.Getenv(key); sizeStr != "" {
if size, err := strconv.ParseInt(sizeStr, 10, 64); err == nil && size > 0 {
return size
}
}
return fallback
}
+52
View File
@@ -0,0 +1,52 @@
package utils
import "testing"
func TestGetMaxSkillBundleSizeMB(t *testing.T) {
t.Run("supports the documented 20 GB knowledge upload ceiling", func(t *testing.T) {
t.Setenv("MAX_FILE_SIZE_MB", "20480")
if got := GetMaxFileSizeMB(); got != 20480 {
t.Fatalf("GetMaxFileSizeMB() = %d, want 20480", got)
}
})
t.Run("caps an unsafe upload setting", func(t *testing.T) {
t.Setenv("MAX_FILE_SIZE_MB", "999999")
if got := GetMaxFileSizeMB(); got != maxFileSizeMBCeiling {
t.Fatalf("GetMaxFileSizeMB() = %d, want %d", got, maxFileSizeMBCeiling)
}
})
t.Run("defaults above the knowledge upload cap", func(t *testing.T) {
t.Setenv("MAX_FILE_SIZE_MB", "")
t.Setenv("MAX_SKILL_BUNDLE_SIZE_MB", "")
if got := GetMaxSkillBundleSizeMB(); got != defaultMaxSkillBundleSizeMB {
t.Fatalf("GetMaxSkillBundleSizeMB() = %d, want %d", got, defaultMaxSkillBundleSizeMB)
}
if GetMaxFileSizeMB() != defaultMaxFileSizeMB {
t.Fatalf("knowledge cap must stay %d MB", defaultMaxFileSizeMB)
}
})
t.Run("honours an explicit skill cap above the knowledge cap", func(t *testing.T) {
t.Setenv("MAX_FILE_SIZE_MB", "50")
t.Setenv("MAX_SKILL_BUNDLE_SIZE_MB", "300")
if got := GetMaxSkillBundleSizeMB(); got != 300 {
t.Fatalf("GetMaxSkillBundleSizeMB() = %d, want 300", got)
}
})
t.Run("never below the knowledge upload cap", func(t *testing.T) {
t.Setenv("MAX_FILE_SIZE_MB", "200")
t.Setenv("MAX_SKILL_BUNDLE_SIZE_MB", "64")
if got := GetMaxSkillBundleSizeMB(); got != 200 {
t.Fatalf("GetMaxSkillBundleSizeMB() = %d, want 200", got)
}
})
t.Run("caps at the uncompressed archive ceiling", func(t *testing.T) {
t.Setenv("MAX_FILE_SIZE_MB", "50")
t.Setenv("MAX_SKILL_BUNDLE_SIZE_MB", "4096")
if got := GetMaxSkillBundleSizeMB(); got != maxSkillBundleSizeMBCeiling {
t.Fatalf("GetMaxSkillBundleSizeMB() = %d, want %d", got, maxSkillBundleSizeMBCeiling)
}
})
}
+80
View File
@@ -0,0 +1,80 @@
package utils
import "strings"
// IsActiveBrowserContentExt reports whether a file extension can execute script
// or otherwise participate as active same-origin browser content when served
// inline. User-uploaded files with these extensions must be downloaded instead
// of previewed inline.
func IsActiveBrowserContentExt(ext string) bool {
switch strings.ToLower(ext) {
case ".svg", ".svgz", ".html", ".htm", ".xhtml", ".xml", ".js", ".mjs", ".css":
return true
default:
return false
}
}
// GetContentTypeByExt returns the content type based on file extension
func GetContentTypeByExt(ext string) string {
if IsActiveBrowserContentExt(ext) {
return "application/octet-stream"
}
switch strings.ToLower(ext) {
case ".csv":
return "text/csv; charset=utf-8"
case ".json":
return "application/json"
case ".pdf":
return "application/pdf"
case ".doc":
return "application/msword"
case ".docx":
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
case ".xls":
return "application/vnd.ms-excel"
case ".xlsx":
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
case ".png":
return "image/png"
case ".jpg", ".jpeg":
return "image/jpeg"
case ".gif":
return "image/gif"
case ".webp":
return "image/webp"
case ".txt":
return "text/plain; charset=utf-8"
case ".mp4":
return "video/mp4"
case ".mp3":
return "audio/mpeg"
case ".wav":
return "audio/wav"
case ".zip":
return "application/zip"
case ".tar":
return "application/x-tar"
case ".gz":
return "application/gzip"
case ".md":
return "text/markdown; charset=utf-8"
default:
return "application/octet-stream"
}
}
// SafeContentTypeByFilename returns a browser-safe content type for serving
// user-controlled files and whether that file may be displayed inline.
func SafeContentTypeByFilename(filename string) (string, bool) {
ext := filename
if idx := strings.LastIndex(ext, "."); idx >= 0 {
ext = ext[idx:]
} else {
ext = ""
}
if IsActiveBrowserContentExt(ext) {
return "application/octet-stream", false
}
return GetContentTypeByExt(ext), true
}
+38
View File
@@ -0,0 +1,38 @@
package utils
import (
"fmt"
"io"
"net/http"
"strings"
"time"
)
var defaultHTTPClient = NewSSRFSafeHTTPClient(SSRFSafeHTTPClientConfig{
Timeout: 60 * time.Second,
MaxRedirects: 10,
})
// DownloadBytes fetches the content at the given HTTP(S) URL and returns the
// raw bytes. It reuses a package-level http.Client with a 60-second timeout.
func DownloadBytes(url string) ([]byte, error) {
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
return nil, fmt.Errorf("unsupported URL scheme: %s", url)
}
if err := ValidateURLForSSRF(url); err != nil {
return nil, fmt.Errorf("URL rejected by SSRF policy: %w", err)
}
resp, err := defaultHTTPClient.Get(url)
if err != nil {
return nil, fmt.Errorf("HTTP GET: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d for %s", resp.StatusCode, url)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read body: %w", err)
}
return data, nil
}
File diff suppressed because it is too large Load Diff
+625
View File
@@ -0,0 +1,625 @@
package utils
import (
"encoding/json"
"fmt"
"strings"
"testing"
)
func TestParseSQL(t *testing.T) {
tests := []struct {
name string
sql string
wantIsSelect bool
wantTables []string
wantSelect []string
wantWhere []string
wantWhereText string
}{
{
name: "Simple SELECT",
sql: "SELECT id, name, age FROM users WHERE age > 18",
wantIsSelect: true,
wantTables: []string{"users"},
wantSelect: []string{"id", "name", "age"},
wantWhere: []string{"age"},
wantWhereText: "age > 18",
},
{
name: "SELECT with multiple WHERE conditions",
sql: "SELECT u.id, u.name FROM users u WHERE u.age > 18 AND u.status = 'active'",
wantIsSelect: true,
wantTables: []string{"users"},
wantSelect: []string{"id", "name"},
wantWhere: []string{"age", "status"},
wantWhereText: "u.age > 18 AND u.status = 'active'",
},
{
name: "SELECT with JOIN",
sql: "SELECT u.name, o.total FROM users u JOIN orders o ON u.id = o.user_id WHERE o.total > 100",
wantIsSelect: true,
wantTables: []string{"users", "orders"},
wantSelect: []string{"name", "total"},
wantWhere: []string{"total"},
wantWhereText: "o.total > 100",
},
{
name: "SELECT with aggregate functions",
sql: "SELECT COUNT(id), AVG(score) FROM students WHERE grade = 'A'",
wantIsSelect: true,
wantTables: []string{"students"},
wantSelect: []string{"id", "score"},
wantWhere: []string{"grade"},
wantWhereText: "grade = 'A'",
},
{
name: "SELECT with complex WHERE",
sql: "SELECT * FROM products WHERE price BETWEEN 10 AND 100 AND category IN ('electronics', 'books')",
wantIsSelect: true,
wantTables: []string{"products"},
wantSelect: []string{},
wantWhere: []string{"price", "category"},
wantWhereText: "price BETWEEN 10 AND 100 AND category IN ('electronics', 'books')",
},
{
name: "INSERT statement",
sql: "INSERT INTO users (name, age) VALUES ('John', 25)",
wantIsSelect: false,
},
{
name: "UPDATE statement",
sql: "UPDATE users SET age = 26 WHERE id = 1",
wantIsSelect: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ParseSQL(tt.sql)
// Print result for debugging
resultJSON, _ := json.MarshalIndent(result, "", " ")
fmt.Printf("\nTest: %s\nResult:\n%s\n", tt.name, string(resultJSON))
if result.IsSelect != tt.wantIsSelect {
t.Errorf("IsSelect = %v, want %v", result.IsSelect, tt.wantIsSelect)
}
if !tt.wantIsSelect {
// For non-SELECT statements, just check IsSelect
return
}
if result.ParseError != "" {
t.Errorf("ParseError = %v, want empty", result.ParseError)
}
// Check tables
if len(result.TableNames) != len(tt.wantTables) {
t.Errorf("TableNames count = %d, want %d. Got: %v, Want: %v",
len(result.TableNames), len(tt.wantTables), result.TableNames, tt.wantTables)
} else {
for i, table := range tt.wantTables {
if i < len(result.TableNames) && result.TableNames[i] != table {
t.Errorf("TableNames[%d] = %v, want %v", i, result.TableNames[i], table)
}
}
}
// Check SELECT fields
if len(result.SelectFields) != len(tt.wantSelect) {
t.Errorf("SelectFields count = %d, want %d. Got: %v, Want: %v",
len(result.SelectFields), len(tt.wantSelect), result.SelectFields, tt.wantSelect)
}
// Check WHERE fields
if len(result.WhereFields) != len(tt.wantWhere) {
t.Errorf("WhereFields count = %d, want %d. Got: %v, Want: %v",
len(result.WhereFields), len(tt.wantWhere), result.WhereFields, tt.wantWhere)
}
// Check WHERE clause text
if result.WhereClause != tt.wantWhereText {
t.Errorf("WhereClause = %q, want %q", result.WhereClause, tt.wantWhereText)
}
})
}
}
func ExampleParseSQL() {
sql := "SELECT id, name, email FROM users WHERE age > 18 AND status = 'active'"
result := ParseSQL(sql)
fmt.Printf("Is SELECT: %v\n", result.IsSelect)
fmt.Printf("Tables: %v\n", result.TableNames)
fmt.Printf("SELECT fields: %v\n", result.SelectFields)
fmt.Printf("WHERE fields: %v\n", result.WhereFields)
fmt.Printf("WHERE clause: %s\n", result.WhereClause)
// Output:
// Is SELECT: true
// Tables: [users]
// SELECT fields: [id name email]
// WHERE fields: [age status]
// WHERE clause: age > 18 AND status = 'active'
}
func TestValidateSQL_TableNames(t *testing.T) {
tests := []struct {
name string
sql string
allowedTables []string
wantValid bool
wantErrorType string
}{
{
name: "Valid table name",
sql: "SELECT * FROM users WHERE id = 1",
allowedTables: []string{"users", "orders"},
wantValid: true,
},
{
name: "Invalid table name",
sql: "SELECT * FROM products WHERE id = 1",
allowedTables: []string{"users", "orders"},
wantValid: false,
wantErrorType: "table_not_allowed",
},
{
name: "Multiple tables - all valid",
sql: "SELECT * FROM users u JOIN orders o ON u.id = o.user_id",
allowedTables: []string{"users", "orders"},
wantValid: true,
},
{
name: "Multiple tables - one invalid",
sql: "SELECT * FROM users u JOIN products p ON u.id = p.user_id",
allowedTables: []string{"users", "orders"},
wantValid: false,
wantErrorType: "table_not_allowed",
},
{
name: "Case insensitive table names",
sql: "SELECT * FROM USERS WHERE id = 1",
allowedTables: []string{"users", "orders"},
wantValid: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, validation := ValidateSQL(tt.sql, WithAllowedTables(tt.allowedTables...))
if validation.Valid != tt.wantValid {
t.Errorf("Valid = %v, want %v", validation.Valid, tt.wantValid)
}
if !tt.wantValid && len(validation.Errors) > 0 {
if validation.Errors[0].Type != tt.wantErrorType {
t.Errorf("Error type = %v, want %v", validation.Errors[0].Type, tt.wantErrorType)
}
}
// Print validation result for debugging
if !validation.Valid {
validationJSON, _ := json.MarshalIndent(validation, "", " ")
fmt.Printf("\nTest: %s\nValidation Result:\n%s\n", tt.name, string(validationJSON))
}
})
}
}
func TestValidateSQL_InjectionRisk(t *testing.T) {
tests := []struct {
name string
sql string
wantValid bool
wantErrorType string
description string
}{
{
name: "Normal WHERE clause",
sql: "SELECT * FROM users WHERE age > 18 AND status = 'active'",
wantValid: true,
description: "Should pass normal conditions",
},
{
name: "SQL injection with 1=1",
sql: "SELECT * FROM users WHERE id = 1 OR 1=1",
wantValid: false,
wantErrorType: "sql_injection_risk",
description: "Should detect 1=1 pattern",
},
{
name: "SQL injection with '1'='1'",
sql: "SELECT * FROM users WHERE username = 'admin' OR '1'='1'",
wantValid: false,
wantErrorType: "sql_injection_risk",
description: "Should detect '1'='1' pattern",
},
{
name: "SQL injection with 0=0",
sql: "SELECT * FROM users WHERE 0=0",
wantValid: false,
wantErrorType: "sql_injection_risk",
description: "Should detect 0=0 pattern",
},
{
name: "SQL injection with true",
sql: "SELECT * FROM users WHERE true",
wantValid: false,
wantErrorType: "sql_injection_risk",
description: "Should detect 'true' pattern",
},
{
name: "SQL injection with empty string comparison",
sql: "SELECT * FROM users WHERE ''=''",
wantValid: false,
wantErrorType: "sql_injection_risk",
description: "Should detect empty string comparison",
},
{
name: "SQL injection with 1=0",
sql: "SELECT * FROM users WHERE 1=0",
wantValid: false,
wantErrorType: "sql_injection_risk",
description: "Should detect 1=0 pattern",
},
{
name: "SQL injection with false",
sql: "SELECT * FROM users WHERE false",
wantValid: false,
wantErrorType: "sql_injection_risk",
description: "Should detect 'false' pattern",
},
{
name: "Complex injection with AND",
sql: "SELECT * FROM users WHERE username = 'admin' AND 1=1",
wantValid: false,
wantErrorType: "sql_injection_risk",
description: "Should detect 1=1 even with AND",
},
{
name: "Normal comparison with numbers",
sql: "SELECT * FROM users WHERE status_code = 1",
wantValid: true,
description: "Should allow normal number comparisons",
},
{
name: "Normal string comparison",
sql: "SELECT * FROM users WHERE name = 'John'",
wantValid: true,
description: "Should allow normal string comparisons",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, validation := ValidateSQL(tt.sql, WithInjectionRiskCheck())
if validation.Valid != tt.wantValid {
t.Errorf("%s: Valid = %v, want %v", tt.description, validation.Valid, tt.wantValid)
}
if !tt.wantValid && len(validation.Errors) > 0 {
found := false
for _, err := range validation.Errors {
if err.Type == tt.wantErrorType {
found = true
break
}
}
if !found {
t.Errorf("%s: Expected error type %v not found in errors", tt.description, tt.wantErrorType)
}
}
// Print validation result for debugging
if !validation.Valid {
validationJSON, _ := json.MarshalIndent(validation, "", " ")
fmt.Printf("\nTest: %s\nValidation Result:\n%s\n", tt.name, string(validationJSON))
}
})
}
}
func TestValidateSQL_CombinedOptions(t *testing.T) {
tests := []struct {
name string
sql string
allowedTables []string
wantValid bool
wantErrorCnt int
}{
{
name: "Valid SQL with both checks",
sql: "SELECT * FROM users WHERE age > 18",
allowedTables: []string{"users", "orders"},
wantValid: true,
wantErrorCnt: 0,
},
{
name: "Invalid table and injection risk",
sql: "SELECT * FROM products WHERE 1=1",
allowedTables: []string{"users", "orders"},
wantValid: false,
wantErrorCnt: 2, // Both table and injection errors
},
{
name: "Valid table but injection risk",
sql: "SELECT * FROM users WHERE id = 1 OR 1=1",
allowedTables: []string{"users", "orders"},
wantValid: false,
wantErrorCnt: 2, // Injection errors
},
{
name: "Invalid table but no injection",
sql: "SELECT * FROM products WHERE age > 18",
allowedTables: []string{"users", "orders"},
wantValid: false,
wantErrorCnt: 1, // Only table error
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, validation := ValidateSQL(tt.sql,
WithAllowedTables(tt.allowedTables...),
WithInjectionRiskCheck(),
)
if validation.Valid != tt.wantValid {
t.Errorf("Valid = %v, want %v", validation.Valid, tt.wantValid)
}
if len(validation.Errors) != tt.wantErrorCnt {
t.Errorf("Error count = %d, want %d", len(validation.Errors), tt.wantErrorCnt)
}
// Print validation result for debugging
validationJSON, _ := json.MarshalIndent(validation, "", " ")
fmt.Printf("\nTest: %s\nValidation Result:\n%s\n", tt.name, string(validationJSON))
})
}
}
func ExampleValidateSQL() {
// Example 1: Validate table names
sql1 := "SELECT * FROM users WHERE age > 18"
_, validation1 := ValidateSQL(sql1, WithAllowedTables("users", "orders"))
fmt.Printf("Example 1 - Valid: %v\n", validation1.Valid)
// Example 2: Detect SQL injection
sql2 := "SELECT * FROM users WHERE id = 1 OR 1=1"
_, validation2 := ValidateSQL(sql2, WithInjectionRiskCheck())
fmt.Printf("Example 2 - Valid: %v\n", validation2.Valid)
if !validation2.Valid {
fmt.Printf("Error: %s\n", validation2.Errors[0].Message)
}
// Example 3: Combined validation
sql3 := "SELECT * FROM products WHERE 1=1"
_, validation3 := ValidateSQL(sql3,
WithAllowedTables("users", "orders"),
WithInjectionRiskCheck(),
)
fmt.Printf("Example 3 - Valid: %v, Error count: %d\n", validation3.Valid, len(validation3.Errors))
// Output:
// Example 1 - Valid: true
// Example 2 - Valid: false
// Error: Potential SQL injection risk detected
// Example 3 - Valid: false, Error count: 2
}
func TestInjectAndConditions(t *testing.T) {
tests := []struct {
name string
sql string
filter string
want string
}{
{
name: "existing WHERE with ORDER BY",
sql: "SELECT id, title FROM knowledges WHERE parse_status = 'completed' ORDER BY created_at DESC LIMIT 10",
filter: "knowledges.tenant_id = 123",
want: "SELECT id, title FROM knowledges WHERE knowledges.tenant_id = 123 AND (parse_status = 'completed') ORDER BY created_at DESC LIMIT 10",
},
{
name: "existing WHERE without tail clauses",
sql: "SELECT id FROM knowledges WHERE enable_status = 'enabled'",
filter: "knowledges.deleted_at IS NULL",
want: "SELECT id FROM knowledges WHERE knowledges.deleted_at IS NULL AND (enable_status = 'enabled')",
},
{
name: "no WHERE with ORDER BY",
sql: "SELECT id FROM knowledges ORDER BY created_at DESC",
filter: "knowledges.tenant_id = 123",
want: "SELECT id FROM knowledges WHERE knowledges.tenant_id = 123 ORDER BY created_at DESC",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := InjectAndConditions(tt.sql, tt.filter)
if got != tt.want {
t.Fatalf("InjectAndConditions() = %q, want %q", got, tt.want)
}
})
}
}
func TestValidateAndSecureSQL_WithStructuredSearchScopes(t *testing.T) {
securedSQL, validation, err := ValidateAndSecureSQL(
"SELECT id FROM chunks",
WithSearchScopes([]SearchScope{
{KnowledgeBaseID: "kb-full"},
{KnowledgeBaseID: "kb-doc", KnowledgeIDs: []string{"doc-1"}},
{KnowledgeBaseID: "kb-tag", TagIDs: []string{"tag-a", "tag-b"}},
}),
)
if err != nil {
t.Fatalf("ValidateAndSecureSQL() error = %v", err)
}
if !validation.Valid {
t.Fatalf("expected validation to pass, got %#v", validation.Errors)
}
for _, want := range []string{
"chunks.knowledge_base_id = 'kb-full'",
"chunks.knowledge_base_id = 'kb-doc' AND chunks.knowledge_id IN ('doc-1')",
"chunks.knowledge_base_id = 'kb-tag' AND EXISTS",
"knowledge_tag_relations",
"ktr.knowledge_id = chunks.knowledge_id",
"ktr.tag_id IN ('tag-a', 'tag-b')",
" OR ",
} {
if !strings.Contains(securedSQL, want) {
t.Fatalf("secured SQL missing %q:\n%s", want, securedSQL)
}
}
}
func TestValidateAndSecureSQL_WithChunkEnabledFilter(t *testing.T) {
securedSQL, validation, err := ValidateAndSecureSQL(
"SELECT c.id, c.content FROM chunks c WHERE c.chunk_type = 'faq'",
WithChunkEnabledFilter(),
)
if err != nil {
t.Fatalf("ValidateAndSecureSQL() error = %v", err)
}
if !validation.Valid {
t.Fatalf("expected validation to pass, got %#v", validation.Errors)
}
if !strings.Contains(securedSQL, "c.is_enabled = true") {
t.Fatalf("secured SQL must exclude disabled chunks:\n%s", securedSQL)
}
}
// A scope carrying both a document whitelist and a tag filter must apply both.
// Emitting only one of them would admit rows the other excludes.
func TestValidateAndSecureSQL_ScopeCombinesDocumentAndTagFilters(t *testing.T) {
securedSQL, validation, err := ValidateAndSecureSQL(
"SELECT id FROM chunks",
WithSearchScopes([]SearchScope{
{KnowledgeBaseID: "kb-1", KnowledgeIDs: []string{"doc-1"}, TagIDs: []string{"tag-a"}},
}),
)
if err != nil {
t.Fatalf("ValidateAndSecureSQL() error = %v", err)
}
if !validation.Valid {
t.Fatalf("expected validation to pass, got %#v", validation.Errors)
}
for _, want := range []string{
"chunks.knowledge_base_id = 'kb-1'",
"chunks.knowledge_id IN ('doc-1')",
"ktr.tag_id IN ('tag-a')",
} {
if !strings.Contains(securedSQL, want) {
t.Fatalf("secured SQL missing %q:\n%s", want, securedSQL)
}
}
if strings.Contains(securedSQL, " OR ") {
t.Fatalf("a single scope must not be split into alternatives:\n%s", securedSQL)
}
}
// TestValidateSQL_JSONNodeBypass verifies that PG17 SQL/JSON expression nodes
// cannot be used to smuggle dangerous functions past the blacklist. These were
// previously accepted because validateNode had no handler for them and fell
// through to a permissive `return nil`.
func TestValidateSQL_JSONNodeBypass(t *testing.T) {
dangerous := []struct {
name string
sql string
}{
{"JSON_SCALAR + pg_read_file", "SELECT JSON_SCALAR(pg_read_file('/etc/passwd')) AS data FROM knowledge_bases LIMIT 1"},
{"JSON_VALUE + pg_read_file", "SELECT JSON_VALUE(pg_read_file('/etc/passwd'), '$') AS data FROM knowledge_bases LIMIT 1"},
{"JSON_QUERY + pg_read_file", "SELECT JSON_QUERY(pg_read_file('/etc/passwd'), '$') AS data FROM knowledge_bases LIMIT 1"},
{"JSON scalar + lo_export", "SELECT JSON_SCALAR(lo_export(1, '/tmp/x')) FROM knowledge_bases LIMIT 1"},
{"JSON() parse + pg_read_file", "SELECT JSON(pg_read_file('/etc/passwd')) FROM knowledge_bases LIMIT 1"},
{"JSON_SERIALIZE + pg_read_file", "SELECT JSON_SERIALIZE(pg_read_file('/etc/passwd')) FROM knowledge_bases LIMIT 1"},
}
for _, tt := range dangerous {
t.Run(tt.name, func(t *testing.T) {
_, validation := ValidateSQL(tt.sql, WithSecurityDefaults(10000))
if validation.Valid {
t.Fatalf("expected SQL to be REJECTED, but it was accepted: %s", tt.sql)
}
})
}
}
// TestValidateSQL_UnknownNodeDefaultDeny ensures a legitimate query still
// passes after the default-deny change, guarding against over-blocking.
func TestValidateSQL_DefaultDenyAllowsLegitimate(t *testing.T) {
legit := []string{
"SELECT id, name FROM knowledge_bases WHERE name LIKE '%test%' ORDER BY created_at DESC LIMIT 10",
"SELECT COUNT(*) AS c FROM knowledges WHERE parse_status = 'completed'",
"SELECT COALESCE(title, 'untitled') FROM knowledges LIMIT 5",
"SELECT CASE WHEN file_size > 100 THEN 'big' ELSE 'small' END FROM knowledges LIMIT 5",
"SELECT kb.name, COUNT(k.id) FROM knowledge_bases kb LEFT JOIN knowledges k ON kb.id = k.knowledge_base_id GROUP BY kb.id, kb.name",
}
for _, sql := range legit {
t.Run(sql, func(t *testing.T) {
_, validation := ValidateSQL(sql, WithSecurityDefaults(10000))
if !validation.Valid {
t.Fatalf("expected legitimate SQL to pass, got errors: %#v\nSQL: %s", validation.Errors, sql)
}
})
}
}
// TestValidateSQL_DuckDBSubqueryFileRead verifies that the data_analysis tool's
// validation options (no WithNoSubqueries) still reject file-reading functions
// hidden inside a FROM subquery, as well as at the top level.
func TestValidateSQL_DuckDBFileRead(t *testing.T) {
// Mirror the exact options used by the data_analysis (DuckDB) tool.
opts := []SQLValidationOption{
WithAllowedTables("k_data"),
WithSingleStatement(),
WithNoDangerousFunctions(),
}
dangerous := []struct {
name string
sql string
}{
{"read_text in FROM subquery", "SELECT * FROM (SELECT * FROM read_text('/etc/passwd')) AS f, k_data AS k LIMIT 1"},
{"read_text in target list", "SELECT read_text('/etc/passwd') FROM k_data LIMIT 1"},
{"read_blob in FROM subquery", "SELECT * FROM (SELECT * FROM read_blob('/etc/passwd')) AS f LIMIT 1"},
{"nested subquery read_csv", "SELECT * FROM (SELECT * FROM (SELECT * FROM read_csv('/etc/passwd')) AS a) AS b LIMIT 1"},
}
for _, tt := range dangerous {
t.Run(tt.name, func(t *testing.T) {
_, validation := ValidateSQL(tt.sql, opts...)
if validation.Valid {
t.Fatalf("expected SQL to be REJECTED, but it was accepted: %s", tt.sql)
}
})
}
t.Run("legitimate table query passes", func(t *testing.T) {
_, validation := ValidateSQL("SELECT product, SUM(amount) FROM k_data GROUP BY product", opts...)
if !validation.Valid {
t.Fatalf("expected legitimate DuckDB query to pass, got: %#v", validation.Errors)
}
})
}
func BenchmarkInjectAndConditions(b *testing.B) {
const sql = "SELECT id, title FROM docs WHERE status = 'active' ORDER BY created_at LIMIT 50"
for i := 0; i < b.N; i++ {
_ = InjectAndConditions(sql, "tenant_id = 1")
}
}
func BenchmarkCheckSQLInjectionRisks(b *testing.B) {
const where = "status = 'active' AND name LIKE '%foo%' AND (deleted_at IS NULL OR archived = false)"
for i := 0; i < b.N; i++ {
_ = checkSQLInjectionRisks(where)
}
}
+35
View File
@@ -0,0 +1,35 @@
package utils
import (
"encoding/json"
"fmt"
jsonschema "github.com/google/jsonschema-go/jsonschema"
)
// ToJSON converts a value to a JSON string
func ToJSON(v interface{}) string {
json, err := json.Marshal(v)
if err != nil {
return ""
}
return string(json)
}
// GenerateSchema generates JSON schema for type T and returns it as a map
// This is optimized to avoid unnecessary serialization/deserialization
func GenerateSchema[T any]() json.RawMessage {
schema, err := jsonschema.For[T](nil)
if err != nil {
panic(fmt.Sprintf("failed to generate schema: %v", err))
}
// Convert schema to map directly through JSON marshaling
// This is necessary because the schema object doesn't expose its internal structure
schemaBytes, err := json.Marshal(schema)
if err != nil {
panic(fmt.Sprintf("failed to marshal schema: %v", err))
}
return schemaBytes
}
+29
View File
@@ -0,0 +1,29 @@
package utils
import (
"regexp"
"strconv"
)
var imageDataURLPatternForLog = regexp.MustCompile(`data:image\/[a-zA-Z0-9.+-]+;base64,[A-Za-z0-9+/=]+`)
const (
defaultMaxLogChars = 12000
defaultMaxDataURLPreview = 96
)
// CompactImageDataURLForLog shortens large image data URLs for log output.
func CompactImageDataURLForLog(raw string) string {
masked := imageDataURLPatternForLog.ReplaceAllStringFunc(raw, func(match string) string {
if len(match) <= defaultMaxDataURLPreview {
return match
}
hidden := len(match) - defaultMaxDataURLPreview
return match[:defaultMaxDataURLPreview] + "...<omitted " + strconv.Itoa(hidden) + " chars>"
})
if len(masked) <= defaultMaxLogChars {
return masked
}
return masked[:defaultMaxLogChars] + "... (truncated, total " + strconv.Itoa(len(masked)) + " chars)"
}
+27
View File
@@ -0,0 +1,27 @@
package utils
import (
"context"
"net"
"sync"
"github.com/go-sql-driver/mysql"
)
// MySQLSSRFNetwork is the mysql driver network name registered by
// RegisterMySQLSSRFDialer. DSNs must set cfg.Net to this value so every
// database/sql open uses SSRFSafeDialContext at the final TCP sink.
const MySQLSSRFNetwork = "tcp-weknora-ssrf"
var mysqlSSRFDialerOnce sync.Once
// RegisterMySQLSSRFDialer installs a go-sql-driver/mysql dialer that routes
// connections through SSRFSafeDialContext. Safe to call from multiple packages;
// registration happens at most once per process.
func RegisterMySQLSSRFDialer() {
mysqlSSRFDialerOnce.Do(func() {
mysql.RegisterDialContext(MySQLSSRFNetwork, func(ctx context.Context, addr string) (net.Conn, error) {
return SSRFSafeDialContext(ctx, "tcp", addr)
})
})
}
+106
View File
@@ -0,0 +1,106 @@
package utils
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"sync"
"time"
)
const oidcStateMaxAge = 10 * time.Minute
// OIDCStatePayload is the signed OIDC authorization state carried in the
// redirect URL and validated on callback.
type OIDCStatePayload struct {
Nonce string `json:"nonce"`
RedirectURI string `json:"redirect_uri,omitempty"`
IssuedAt int64 `json:"iat"`
}
var (
oidcStateSecretOnce sync.Once
oidcStateSecret string
)
func oidcStateSigningKey() string {
oidcStateSecretOnce.Do(func() {
if envSecret := strings.TrimSpace(os.Getenv("JWT_SECRET")); envSecret != "" {
oidcStateSecret = envSecret
return
}
randomBytes := make([]byte, 32)
if _, err := rand.Read(randomBytes); err != nil {
panic(fmt.Sprintf("failed to generate OIDC state signing key: %v", err))
}
oidcStateSecret = base64.StdEncoding.EncodeToString(randomBytes)
})
return oidcStateSecret
}
// SignOIDCState returns a tamper-evident state token: base64url(payload).base64url(hmac).
func SignOIDCState(payload *OIDCStatePayload) (string, error) {
if payload == nil {
return "", errors.New("oidc state payload is required")
}
if strings.TrimSpace(payload.Nonce) == "" {
return "", errors.New("oidc state nonce is required")
}
if strings.TrimSpace(payload.RedirectURI) == "" {
return "", errors.New("oidc state redirect_uri is required")
}
if payload.IssuedAt == 0 {
payload.IssuedAt = time.Now().Unix()
}
raw, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("marshal oidc state: %w", err)
}
mac := hmac.New(sha256.New, []byte(oidcStateSigningKey()))
mac.Write(raw)
sig := mac.Sum(nil)
return base64.RawURLEncoding.EncodeToString(raw) + "." + base64.RawURLEncoding.EncodeToString(sig), nil
}
// VerifyOIDCState validates the HMAC and freshness of a state token.
func VerifyOIDCState(raw string) (*OIDCStatePayload, error) {
raw = strings.TrimSpace(raw)
parts := strings.Split(raw, ".")
if len(parts) != 2 {
return nil, errors.New("invalid oidc state format")
}
payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return nil, fmt.Errorf("decode oidc state payload: %w", err)
}
sigBytes, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, fmt.Errorf("decode oidc state signature: %w", err)
}
mac := hmac.New(sha256.New, []byte(oidcStateSigningKey()))
mac.Write(payloadBytes)
if !hmac.Equal(mac.Sum(nil), sigBytes) {
return nil, errors.New("oidc state signature mismatch")
}
var payload OIDCStatePayload
if err := json.Unmarshal(payloadBytes, &payload); err != nil {
return nil, fmt.Errorf("unmarshal oidc state: %w", err)
}
if strings.TrimSpace(payload.RedirectURI) == "" {
return nil, errors.New("state.redirect_uri is required")
}
if payload.IssuedAt == 0 {
return nil, errors.New("state.iat is required")
}
issuedAt := time.Unix(payload.IssuedAt, 0)
if time.Since(issuedAt) > oidcStateMaxAge || time.Until(issuedAt) > time.Minute {
return nil, errors.New("oidc state expired or invalid timestamp")
}
return &payload, nil
}
+50
View File
@@ -0,0 +1,50 @@
package utils
import (
"strings"
"sync"
"testing"
"time"
)
func TestSignAndVerifyOIDCState(t *testing.T) {
t.Setenv("JWT_SECRET", "test-oidc-state-secret")
oidcStateSecretOnce = sync.Once{}
oidcStateSecret = ""
state, err := SignOIDCState(&OIDCStatePayload{
Nonce: "nonce-abc",
RedirectURI: "http://localhost:5173/login",
IssuedAt: time.Now().Unix(),
})
if err != nil {
t.Fatalf("SignOIDCState: %v", err)
}
got, err := VerifyOIDCState(state)
if err != nil {
t.Fatalf("VerifyOIDCState: %v", err)
}
if got.Nonce != "nonce-abc" || got.RedirectURI != "http://localhost:5173/login" {
t.Fatalf("unexpected payload: %+v", got)
}
}
func TestVerifyOIDCStateRejectsTamperedPayload(t *testing.T) {
t.Setenv("JWT_SECRET", "test-oidc-state-secret")
oidcStateSecretOnce = sync.Once{}
oidcStateSecret = ""
state, err := SignOIDCState(&OIDCStatePayload{
Nonce: "nonce-abc",
RedirectURI: "http://localhost:5173/login",
IssuedAt: time.Now().Unix(),
})
if err != nil {
t.Fatalf("SignOIDCState: %v", err)
}
parts := strings.Split(state, ".")
tampered := parts[0] + ".AAAA"
if _, err := VerifyOIDCState(tampered); err == nil {
t.Fatal("expected tampered state to be rejected")
}
}
+49
View File
@@ -0,0 +1,49 @@
package utils
import (
"errors"
"strings"
"unicode/utf8"
)
const PasswordSpecialCharacters = "!@#$%^&*()_+-=[]{}|;:,.<>?"
var (
ErrPasswordPolicy = errors.New("password must be 3-32 characters and no more than 72 bytes")
ErrComplexPasswordPolicy = errors.New(
"password must be 8-32 characters and no more than 72 bytes, and contain uppercase and lowercase " +
"letters, numbers, and special characters")
)
// ValidatePasswordPolicy is shared by account registration, rotation and
// workspace user management. Keep the byte limit in addition to the Unicode
// character limit because bcrypt rejects inputs longer than 72 bytes.
func ValidatePasswordPolicy(password string, complexPasswordEnabled bool) error {
length := utf8.RuneCountInString(password)
if !utf8.ValidString(password) || length < 3 || length > 32 || len(password) > 72 {
return ErrPasswordPolicy
}
if !complexPasswordEnabled {
return nil
}
if length < 8 {
return ErrComplexPasswordPolicy
}
var hasUpper, hasLower, hasDigit, hasSpecial bool
for _, r := range password {
switch {
case r >= 'A' && r <= 'Z':
hasUpper = true
case r >= 'a' && r <= 'z':
hasLower = true
case r >= '0' && r <= '9':
hasDigit = true
case strings.ContainsRune(PasswordSpecialCharacters, r):
hasSpecial = true
}
}
if !hasUpper || !hasLower || !hasDigit || !hasSpecial {
return ErrComplexPasswordPolicy
}
return nil
}
+222
View File
@@ -0,0 +1,222 @@
package utils
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"net/url"
"os"
"strconv"
"strings"
"time"
)
const (
// presignPath is the URL path for presigned file access.
presignPath = "/api/v1/files/presigned"
// presignDefaultTTL is the default validity period for presigned URLs.
// Kept short because the HMAC key alone authorizes cross-tenant access —
// a leaked URL should expire before it can be widely abused. IM clients
// typically fetch and cache images within seconds of receipt.
presignDefaultTTL = 2 * time.Hour
)
// SystemHMACKey returns the deployment-wide HMAC key derived from
// SYSTEM_AES_KEY, or nil when it is unset or too short to be a real secret.
// Callers must treat nil as "this deployment cannot sign", not as an empty key.
func SystemHMACKey() []byte {
key := os.Getenv("SYSTEM_AES_KEY")
if len(key) < 16 {
return nil
}
return []byte(key)
}
// getPresignKey returns the HMAC key derived from SYSTEM_AES_KEY.
// Returns nil if the key is not configured or invalid.
func getPresignKey() []byte {
return SystemHMACKey()
}
// signPayload computes HMAC-SHA256 over the canonical payload string.
func signPayload(key []byte, filePath string, tenantID uint64, expires int64) string {
payload := fmt.Sprintf("file_path=%s&tenant_id=%d&expires=%d", filePath, tenantID, expires)
mac := hmac.New(sha256.New, key)
mac.Write([]byte(payload))
return hex.EncodeToString(mac.Sum(nil))
}
// SignFileURL generates a presigned HTTP URL for accessing a storage file.
// baseURL is the external URL of the WeKnora instance (e.g. "https://weknora.example.com").
// filePath is the provider:// storage path (e.g. "local://1/abc/img.png").
// tenantID identifies the tenant that owns the file.
// ttl is how long the URL remains valid (0 uses the default presignDefaultTTL).
//
// Returns ("", error) if the signing key is not configured.
func SignFileURL(baseURL, filePath string, tenantID uint64, ttl time.Duration) (string, error) {
key := getPresignKey()
if key == nil {
return "", fmt.Errorf("presign: SYSTEM_AES_KEY not configured")
}
if ttl <= 0 {
ttl = presignDefaultTTL
}
expires := time.Now().Add(ttl).Unix()
sig := signPayload(key, filePath, tenantID, expires)
u, err := url.Parse(strings.TrimRight(baseURL, "/") + presignPath)
if err != nil {
return "", fmt.Errorf("presign: invalid base URL: %w", err)
}
q := u.Query()
q.Set("file_path", filePath)
q.Set("tenant_id", strconv.FormatUint(tenantID, 10))
q.Set("expires", strconv.FormatInt(expires, 10))
q.Set("sig", sig)
u.RawQuery = q.Encode()
return u.String(), nil
}
// VerifyFileURLSig checks the HMAC signature and expiry of a presigned URL.
// Returns true only if the signature is valid and the URL has not expired.
func VerifyFileURLSig(filePath string, tenantID uint64, expiresStr, sig string) bool {
key := getPresignKey()
if key == nil {
return false
}
expires, err := strconv.ParseInt(expiresStr, 10, 64)
if err != nil {
return false
}
// Check expiry.
if time.Now().Unix() > expires {
return false
}
// Verify signature.
expected := signPayload(key, filePath, tenantID, expires)
return hmac.Equal([]byte(expected), []byte(sig))
}
// kbScopedExportsSegment is the only storage prefix served by the KB-scoped
// file proxy. Embedded wiki/chunk images land under exports/; raw knowledge
// uploads use {tenant}/{knowledgeID}/... and are served via
// /knowledge/{id}/download instead.
const kbScopedExportsSegment = "exports"
// ValidateStoragePathTenant ensures the tenant segment embedded in a provider://
// storage path matches the authenticated caller's tenant. Cross-tenant access
// for arbitrary tenant paths uses /api/v1/files/presigned with an HMAC bound to
// the resource owner; KB-scoped shared rendering uses ValidateKBScopedStoragePath.
func ValidateStoragePathTenant(filePath string, tenantID uint64) error {
pathTenant := ParseTenantIDFromStoragePath(filePath)
if pathTenant == 0 {
return fmt.Errorf("storage path has no tenant segment")
}
if pathTenant != tenantID {
return fmt.Errorf("storage path workspace mismatch")
}
return nil
}
// ValidateKBScopedStoragePath is used by GET /knowledge-bases/:id/files. It
// requires the path to belong to the KB owner tenant and to live under the
// exports/ namespace used for embedded images (SaveBytes / multimodal output).
// This prevents borrowers with shared-KB read access from using the proxy to
// fetch arbitrary owner-tenant objects such as raw knowledge uploads.
func ValidateKBScopedStoragePath(filePath string, tenantID uint64) error {
if err := ValidateStoragePathTenant(filePath, tenantID); err != nil {
return err
}
if !storagePathHasExportsScope(filePath, tenantID) {
return fmt.Errorf("storage path is outside KB-scoped exports namespace")
}
return nil
}
// storageBackendScheme wraps a provider:// path with the concrete instance id:
// storage://<backendID>/<provider>://... It is duplicated here (rather than
// reusing types.ParseStorageBackendPath) because internal/types already imports
// internal/utils, so a reverse import would create a cycle.
const storageBackendScheme = "storage://"
// unwrapStorageBackendPath strips a leading storage://<backendID>/ wrapper and
// returns the inner provider:// path. Non-wrapped paths are returned unchanged.
// This keeps tenant/exports parsing anchored on the provider path instead of
// relying on the backend id happening not to look like a tenant segment.
func unwrapStorageBackendPath(filePath string) string {
if !strings.HasPrefix(filePath, storageBackendScheme) {
return filePath
}
rest := strings.TrimPrefix(filePath, storageBackendScheme)
parts := strings.SplitN(rest, "/", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return filePath
}
return parts[1]
}
// storagePathHasExportsScope reports whether tenantID appears next to an
// exports segment in either canonical layout:
// - {tenant}/exports/... (local, minio, s3, most cloud backends)
// - exports/{tenant}/... (OSS temp-bucket layout)
func storagePathHasExportsScope(filePath string, tenantID uint64) bool {
_, rest, ok := strings.Cut(unwrapStorageBackendPath(filePath), "://")
if !ok {
return false
}
tenantSeg := strconv.FormatUint(tenantID, 10)
parts := strings.Split(rest, "/")
for i, part := range parts {
if part != tenantSeg {
continue
}
if i+1 < len(parts) && parts[i+1] == kbScopedExportsSegment {
return true
}
if i > 0 && parts[i-1] == kbScopedExportsSegment {
return true
}
}
return false
}
// ParseTenantIDFromStoragePath extracts the tenant ID from a provider:// storage path.
// Storage paths follow the convention: {scheme}://.../{tenantID}/...
// Returns 0 if the path does not contain a valid tenant ID.
//
// NOTE: For cloud providers whose paths embed numeric bucket or region names
// before the tenant segment, the first numeric segment may not be the tenant.
// Callers that have an authoritative resource-owner tenant ID available
// should pass it directly to SignFileURL instead of relying on this parser.
func ParseTenantIDFromStoragePath(filePath string) uint64 {
// Unwrap storage://<backendID>/ so the tenant scan is anchored on the inner
// provider path, not the (opaque) backend id.
filePath = unwrapStorageBackendPath(filePath)
// Strip scheme: "local://1/abc/img.png" → "1/abc/img.png"
_, rest, ok := strings.Cut(filePath, "://")
if !ok {
return 0
}
// Storage path layouts vary by provider:
// local://TENANT_ID/...
// minio://bucket/TENANT_ID/...
// s3://bucket/prefix/TENANT_ID/...
// cos://bucket/region/prefix/TENANT_ID/...
// tos://bucket/TENANT_ID/...
// oss://bucket/prefix/TENANT_ID/...
// We try each slash-separated segment until we find a numeric tenant ID.
parts := strings.Split(rest, "/")
for _, part := range parts {
if id, err := strconv.ParseUint(part, 10, 64); err == nil {
return id
}
}
return 0
}
+137
View File
@@ -0,0 +1,137 @@
package utils
import (
"strconv"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSignFileURL_RoundTrip(t *testing.T) {
t.Setenv("SYSTEM_AES_KEY", "weknora-test-aes-key-32bytes!!!")
baseURL := "https://weknora.example.com"
filePath := "local://1/abc/img.png"
var tenantID uint64 = 1
signed, err := SignFileURL(baseURL, filePath, tenantID, 1*time.Hour)
require.NoError(t, err)
assert.Contains(t, signed, "https://weknora.example.com/api/v1/files/presigned")
assert.Contains(t, signed, "file_path=")
assert.Contains(t, signed, "tenant_id=1")
assert.Contains(t, signed, "sig=")
}
func TestSignFileURL_NoKey(t *testing.T) {
t.Setenv("SYSTEM_AES_KEY", "")
_, err := SignFileURL("https://example.com", "local://1/img.png", 1, 0)
assert.Error(t, err)
assert.Contains(t, err.Error(), "SYSTEM_AES_KEY")
}
func TestVerifyFileURLSig_Valid(t *testing.T) {
t.Setenv("SYSTEM_AES_KEY", "weknora-test-aes-key-32bytes!!!")
filePath := "local://42/knowledge/img.jpg"
var tenantID uint64 = 42
key := getPresignKey()
require.NotNil(t, key)
expires := time.Now().Add(1 * time.Hour).Unix()
sig := signPayload(key, filePath, tenantID, expires)
assert.True(t, VerifyFileURLSig(filePath, tenantID, strconv.FormatInt(expires, 10), sig))
}
func TestVerifyFileURLSig_Expired(t *testing.T) {
t.Setenv("SYSTEM_AES_KEY", "weknora-test-aes-key-32bytes!!!")
filePath := "local://1/img.png"
var tenantID uint64 = 1
key := getPresignKey()
require.NotNil(t, key)
expires := time.Now().Add(-1 * time.Hour).Unix() // already expired
sig := signPayload(key, filePath, tenantID, expires)
assert.False(t, VerifyFileURLSig(filePath, tenantID, strconv.FormatInt(expires, 10), sig))
}
func TestVerifyFileURLSig_Tampered(t *testing.T) {
t.Setenv("SYSTEM_AES_KEY", "weknora-test-aes-key-32bytes!!!")
filePath := "local://1/img.png"
var tenantID uint64 = 1
key := getPresignKey()
require.NotNil(t, key)
expires := time.Now().Add(1 * time.Hour).Unix()
sig := signPayload(key, filePath, tenantID, expires)
expiresStr := strconv.FormatInt(expires, 10)
// Tamper with file path
assert.False(t, VerifyFileURLSig("local://1/other.png", tenantID, expiresStr, sig))
// Tamper with tenant ID
assert.False(t, VerifyFileURLSig(filePath, 999, expiresStr, sig))
// Tamper with signature
assert.False(t, VerifyFileURLSig(filePath, tenantID, expiresStr, "deadbeef"))
}
func TestVerifyFileURLSig_NoKey(t *testing.T) {
t.Setenv("SYSTEM_AES_KEY", "")
assert.False(t, VerifyFileURLSig("local://1/img.png", 1, "99999999999", "abc"))
}
func TestValidateStoragePathTenant(t *testing.T) {
assert.NoError(t, ValidateStoragePathTenant("local://42/knowledge/file.pdf", 42))
assert.Error(t, ValidateStoragePathTenant("local://7/knowledge/file.pdf", 42))
assert.Error(t, ValidateStoragePathTenant("local://docs/example.txt", 42))
}
func TestValidateKBScopedStoragePath(t *testing.T) {
const tenantID uint64 = 10008
assert.NoError(t, ValidateKBScopedStoragePath("local://10008/exports/img.jpg", tenantID))
assert.NoError(t, ValidateKBScopedStoragePath("minio://bucket/10008/exports/uuid.png", tenantID))
assert.NoError(t, ValidateKBScopedStoragePath("oss://bucket/exports/10008/uuid.png", tenantID))
// storage://<backendID>/ wrapped paths must resolve to the same tenant/exports scope.
assert.NoError(t, ValidateKBScopedStoragePath("storage://backend-a/local://10008/exports/img.jpg", tenantID))
assert.NoError(t, ValidateKBScopedStoragePath("storage://backend-a/cos://bucket/ap-test/10008/exports/a.png", tenantID))
assert.Error(t, ValidateKBScopedStoragePath("local://10008/knowledge-id/123.pdf", tenantID))
assert.Error(t, ValidateKBScopedStoragePath("local://9999/exports/img.jpg", tenantID))
assert.Error(t, ValidateKBScopedStoragePath("local://10008/other/img.jpg", tenantID))
assert.Error(t, ValidateKBScopedStoragePath("storage://backend-a/local://9999/exports/img.jpg", tenantID))
}
func TestParseTenantIDFromStoragePath(t *testing.T) {
tests := []struct {
path string
want uint64
}{
{"local://1/abc/img.png", 1},
{"local://42/knowledge/file.pdf", 42},
{"minio://bucket/1/abc/img.png", 1},
{"s3://bucket/weknora/1/abc/img.png", 1},
{"cos://bucket/region/prefix/1/abc/img.png", 1},
{"tos://bucket/1/abc/img.png", 1},
{"oss://bucket/weknora/1/abc/img.png", 1},
{"https://example.com/img.png", 0},
{"invalid", 0},
{"local://exports/file.csv", 0},
{"storage://backend-a/local://1/abc/img.png", 1},
{"storage://backend-a/cos://bucket/region/prefix/42/abc/img.png", 42},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
got := ParseTenantIDFromStoragePath(tt.path)
assert.Equal(t, tt.want, got, "ParseTenantIDFromStoragePath(%q)", tt.path)
})
}
}
File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
package utils
import (
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestSSRFSafeClientStripsAuthTokenOnCrossHostRedirect(t *testing.T) {
t.Setenv("SSRF_WHITELIST", "127.0.0.1,::1,localhost")
ResetSSRFWhitelistForTest()
t.Cleanup(ResetSSRFWhitelistForTest)
var gotToken string
attacker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotToken = r.Header.Get("X-Auth-Token")
w.WriteHeader(http.StatusOK)
}))
defer attacker.Close()
mux := http.NewServeMux()
mux.HandleFunc("/start", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, attacker.URL, http.StatusFound)
})
origin := httptest.NewServer(mux)
defer origin.Close()
client := NewSSRFSafeHTTPClient(SSRFSafeHTTPClientConfig{
Timeout: 5 * time.Second,
MaxRedirects: 5,
})
req, err := http.NewRequest(http.MethodGet, origin.URL+"/start", nil)
if err != nil {
t.Fatalf("NewRequest: %v", err)
}
req.Header.Set("X-Auth-Token", "super-secret-yuque-token")
resp, err := client.Do(req)
if err != nil {
t.Fatalf("Do: %v", err)
}
resp.Body.Close()
if gotToken != "" {
t.Fatalf("X-Auth-Token leaked on cross-host redirect: %q", gotToken)
}
}
+302
View File
@@ -0,0 +1,302 @@
package utils
import (
"net"
"os"
"strings"
"testing"
)
func TestSSRFSafeURL(t *testing.T) {
t.Parallel()
tests := []struct {
name string
rawURL string
wantOK bool
wantReasonSub string
}{
{
name: "empty URL",
rawURL: "",
wantOK: false,
wantReasonSub: "URL is empty",
},
{
name: "invalid scheme",
rawURL: "ftp://example.com/file.txt",
wantOK: false,
wantReasonSub: "invalid scheme",
},
{
name: "missing hostname",
rawURL: "https:///api/v1/ping",
wantOK: false,
wantReasonSub: "URL has no hostname",
},
{
name: "restricted hostname",
rawURL: "https://localhost/health",
wantOK: false,
wantReasonSub: "is restricted",
},
{
name: "restricted hostname suffix",
rawURL: "https://service.internal/status",
wantOK: false,
wantReasonSub: "hostname suffix .internal is restricted",
},
// --- All direct IPs are blocked by isSSRFSafeURL (strict mode) ---
{
name: "direct IPv4 blocked",
rawURL: "https://8.8.8.8/dns-query",
wantOK: false,
wantReasonSub: "direct IP address access is not allowed",
},
{
name: "direct public IPv6 blocked",
rawURL: "https://[2001:4860:4860::8888]/dns-query",
wantOK: false,
wantReasonSub: "direct IP address access is not allowed",
},
{
name: "loopback IPv6 blocked",
rawURL: "https://[::1]/admin",
wantOK: false,
wantReasonSub: "is restricted",
},
{
name: "link-local IPv6 blocked",
rawURL: "https://[fe80::1]/admin",
wantOK: false,
wantReasonSub: "direct IP address access is not allowed",
},
{
name: "ULA IPv6 blocked",
rawURL: "https://[fd12:3456:789a::1]/admin",
wantOK: false,
wantReasonSub: "direct IP address access is not allowed",
},
{
name: "IPv4-mapped IPv6 blocked",
rawURL: "https://[::ffff:127.0.0.1]/admin",
wantOK: false,
wantReasonSub: "direct IP address access is not allowed",
},
// --- IP obfuscation ---
{
name: "IP-like decimal hostname blocked",
rawURL: "https://2130706433/",
wantOK: false,
wantReasonSub: "IP-like hostname format is not allowed",
},
{
name: "IP-like octal hostname blocked",
rawURL: "https://0177.0.0.1/",
wantOK: false,
wantReasonSub: "IP-like hostname format is not allowed",
},
// --- Port blocking ---
{
name: "blocked internal service port",
rawURL: "https://example.com:3306/db",
wantOK: false,
wantReasonSub: "port 3306 is blocked for security reasons",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ok, reason := isSSRFSafeURL(tt.rawURL)
if ok != tt.wantOK {
t.Fatalf("isSSRFSafeURL(%q) ok = %v, want %v, reason = %q", tt.rawURL, ok, tt.wantOK, reason)
}
if tt.wantReasonSub != "" && !strings.Contains(reason, tt.wantReasonSub) {
t.Fatalf("isSSRFSafeURL(%q) reason = %q, want contains %q", tt.rawURL, reason, tt.wantReasonSub)
}
})
}
}
func TestSSRFSafeURL_AllowPublicDomain(t *testing.T) {
t.Parallel()
ok, reason := isSSRFSafeURL("https://example.com/path")
if !ok {
// This path depends on runtime DNS/network. If DNS is unavailable, skip to keep CI stable.
if strings.Contains(reason, "DNS resolution failed") {
t.Skipf("skip due to DNS unavailable in test environment: %s", reason)
}
t.Fatalf("expected public domain to be allowed, got ok=%v reason=%q", ok, reason)
}
}
// TestValidateURLForSSRF_IPv6Whitelist verifies that whitelisted IPv6 addresses
// bypass the strict IP block in isSSRFSafeURL.
func TestValidateURLForSSRF_IPv6Whitelist(t *testing.T) {
tests := []struct {
name string
whitelist string
rawURL string
wantErr bool
}{
{
name: "exact IPv6 whitelisted",
whitelist: "2001:4860:4860::8888",
rawURL: "https://[2001:4860:4860::8888]/dns-query",
wantErr: false,
},
{
name: "IPv6 CIDR whitelisted",
whitelist: "2001:db8::/32",
rawURL: "https://[2001:db8::1]/page",
wantErr: false,
},
{
name: "IPv6 not in whitelist still blocked",
whitelist: "2001:db8::/32",
rawURL: "https://[2001:4860:4860::8888]/dns-query",
wantErr: true,
},
{
name: "IPv4 whitelisted",
whitelist: "8.8.8.8",
rawURL: "https://8.8.8.8/dns-query",
wantErr: false,
},
{
name: "wildcard domain whitelisted",
whitelist: "*.example.com",
rawURL: "https://api.example.com/v1",
wantErr: false,
},
{
name: "wildcard domain root whitelisted",
whitelist: "*.example.com",
rawURL: "https://example.com/v1",
wantErr: false,
},
{
name: "bare host without scheme normalised",
whitelist: "internal.service",
rawURL: "internal.service:8080/path",
wantErr: false,
},
{
name: "empty whitelist blocks direct IP",
whitelist: "",
rawURL: "https://8.8.8.8/dns-query",
wantErr: true,
},
{
name: "whitelist does not allow non-http scheme",
whitelist: "example.com",
rawURL: "gopher://example.com/_stats",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Reset whitelist singleton so we can set a new value
ResetSSRFWhitelistForTest()
os.Setenv("SSRF_WHITELIST", tt.whitelist)
defer func() {
os.Unsetenv("SSRF_WHITELIST")
ResetSSRFWhitelistForTest()
}()
err := ValidateURLForSSRF(tt.rawURL)
if (err != nil) != tt.wantErr {
t.Fatalf("ValidateURLForSSRF(%q) with whitelist=%q: err = %v, wantErr = %v",
tt.rawURL, tt.whitelist, err, tt.wantErr)
}
})
}
}
// TestSSRFWhitelistExtraMerge verifies that SSRF_WHITELIST_EXTRA is merged
// into the effective whitelist alongside SSRF_WHITELIST, so deployment-managed
// defaults (e.g. docker-compose injected sidecar host names) survive when an
// operator overrides SSRF_WHITELIST in their .env.
func TestSSRFWhitelistExtraMerge(t *testing.T) {
cases := []struct {
name string
main string
extra string
host string
want bool
}{
{name: "extra only", main: "", extra: "searxng", host: "searxng", want: true},
{name: "main only does not match extra host", main: "internal", extra: "", host: "searxng", want: false},
{name: "both merged - main host", main: "internal", extra: "searxng", host: "internal", want: true},
{name: "both merged - extra host", main: "internal", extra: "searxng", host: "searxng", want: true},
{name: "neither matches unrelated host", main: "internal", extra: "searxng", host: "evil.example", want: false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ResetSSRFWhitelistForTest()
os.Setenv("SSRF_WHITELIST", tc.main)
os.Setenv("SSRF_WHITELIST_EXTRA", tc.extra)
defer func() {
os.Unsetenv("SSRF_WHITELIST")
os.Unsetenv("SSRF_WHITELIST_EXTRA")
ResetSSRFWhitelistForTest()
}()
if got := IsSSRFWhitelisted(tc.host); got != tc.want {
t.Fatalf("IsSSRFWhitelisted(%q) main=%q extra=%q = %v, want %v",
tc.host, tc.main, tc.extra, got, tc.want)
}
})
}
}
// TestIsRestrictedIP_IPv6 tests IPv6-specific restricted range detection.
func TestIsRestrictedIP_IPv6(t *testing.T) {
t.Parallel()
tests := []struct {
name string
ip string
wantBlock bool
wantReason string
}{
{"loopback", "::1", true, "loopback"},
{"unspecified", "::", true, "unspecified"},
{"link-local", "fe80::1", true, "link-local"},
{"ULA", "fd12:3456:789a::1", true, ""},
{"site-local", "fec0::1", true, "site-local"},
{"Teredo", "2001:0000:4136:e378:8000:63bf:3fff:fdd2", true, "Teredo"},
{"6to4 private", "2002:c0a8:0101::1", true, "6to4"},
{"6to4 public", "2002:0808:0808::1", false, ""},
{"public IPv6", "2001:4860:4860::8888", false, ""},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ip := parseIPForTest(t, tt.ip)
blocked, reason := isRestrictedIP(ip)
if blocked != tt.wantBlock {
t.Fatalf("isRestrictedIP(%s) = %v, want %v (reason: %s)", tt.ip, blocked, tt.wantBlock, reason)
}
if tt.wantReason != "" && !strings.Contains(strings.ToLower(reason), strings.ToLower(tt.wantReason)) {
t.Fatalf("isRestrictedIP(%s) reason = %q, want contains %q", tt.ip, reason, tt.wantReason)
}
})
}
}
func parseIPForTest(t *testing.T, s string) net.IP {
t.Helper()
ip := net.ParseIP(s)
if ip == nil {
t.Fatalf("invalid test IP: %s", s)
}
return ip
}
+99
View File
@@ -0,0 +1,99 @@
package utils
import (
"context"
"fmt"
"net/http"
"strings"
"testing"
"time"
)
type recordingRoundTripper struct {
called bool
}
func (r *recordingRoundTripper) RoundTrip(*http.Request) (*http.Response, error) {
r.called = true
return nil, fmt.Errorf("unexpected network call")
}
func TestSSRFSafeClientValidatesInitialRequestAtFinalSink(t *testing.T) {
base := &recordingRoundTripper{}
client := NewSSRFSafeHTTPClientWithTransport(DefaultSSRFSafeHTTPClientConfig(), base)
req, err := http.NewRequest(http.MethodGet, "http://169.254.169.254/latest/meta-data", nil)
if err != nil {
t.Fatal(err)
}
_, err = client.Do(req)
if err == nil || !strings.Contains(err.Error(), "SSRF") {
t.Fatalf("expected final-sink SSRF rejection, got %v", err)
}
if base.called {
t.Fatal("unsafe request reached the base transport")
}
}
func TestSSRFSafeDialContextRejectsRestrictedPortAtFinalSink(t *testing.T) {
_, err := SSRFSafeDialContext(context.Background(), "tcp", "example.com:6379")
if err == nil || !strings.Contains(err.Error(), "port 6379") {
t.Fatalf("expected restricted-port error, got %v", err)
}
}
// TestNewSSRFSafeTransport_SharedAcrossClients verifies that a single transport
// can back multiple clients (global connection pooling) while each client keeps
// its own timeout and a redirect policy.
func TestNewSSRFSafeTransport_SharedAcrossClients(t *testing.T) {
shared := NewSSRFSafeTransport(DefaultSSRFSafeHTTPClientConfig())
cfg := DefaultSSRFSafeHTTPClientConfig()
cfg.Timeout = 15 * time.Second
first := NewSSRFSafeHTTPClientWithTransport(cfg, shared)
cfg.Timeout = 45 * time.Second
second := NewSSRFSafeHTTPClientWithTransport(cfg, shared)
if first == second {
t.Fatal("expected distinct HTTP clients")
}
firstGuard, ok := first.Transport.(*SSRFValidatingRoundTripper)
if !ok {
t.Fatalf("expected SSRF-validating wrapper, got %T", first.Transport)
}
secondGuard, ok := second.Transport.(*SSRFValidatingRoundTripper)
if !ok {
t.Fatalf("expected SSRF-validating wrapper, got %T", second.Transport)
}
if firstGuard.Base != secondGuard.Base || firstGuard.Base != http.RoundTripper(shared) {
t.Fatal("expected clients to share the supplied base transport")
}
if first.Timeout != 15*time.Second {
t.Fatalf("unexpected first timeout: got %v, want %v", first.Timeout, 15*time.Second)
}
if second.Timeout != 45*time.Second {
t.Fatalf("unexpected second timeout: got %v, want %v", second.Timeout, 45*time.Second)
}
if first.CheckRedirect == nil || second.CheckRedirect == nil {
t.Fatal("expected SSRF redirect policy to be set on both clients")
}
}
// TestNewSSRFSafeHTTPClient_HasDedicatedTransport verifies the convenience
// constructor still builds a working transport + redirect policy.
func TestNewSSRFSafeHTTPClient_HasDedicatedTransport(t *testing.T) {
client := NewSSRFSafeHTTPClient(DefaultSSRFSafeHTTPClientConfig())
if client.Transport == nil {
t.Fatal("expected a transport to be set")
}
guard, ok := client.Transport.(*SSRFValidatingRoundTripper)
if !ok {
t.Fatalf("expected SSRF-validating wrapper, got %T", client.Transport)
}
if _, ok := guard.Base.(*http.Transport); !ok {
t.Fatalf("expected *http.Transport base, got %T", guard.Base)
}
if client.CheckRedirect == nil {
t.Fatal("expected SSRF redirect policy to be set")
}
}
+135
View File
@@ -0,0 +1,135 @@
package utils
import (
"fmt"
"net/url"
"strings"
"sync"
"sync/atomic"
"time"
"golang.org/x/sync/singleflight"
)
// outboundSSRFValidationTTL bounds how long a successful RoundTrip/redirect
// validation for the same origin may be reused. DNS rebinding at the TCP sink
// is still blocked by SSRFSafeDialContext on every new connection.
const outboundSSRFValidationTTL = 60 * time.Second
var (
ssrfOutboundCacheGen atomic.Uint64
ssrfOutboundCache sync.Map // string -> *ssrfOutboundCacheEntry
ssrfOutboundValidateGroup singleflight.Group
ssrfOutboundValidateMisses atomic.Uint64 // test-only counter
)
type ssrfOutboundCacheEntry struct {
err error
expiresAt time.Time
}
// validateURLForSSRFForOutbound validates URLs on the hot outbound path
// (RoundTripper, redirect checks). Results are cached per origin for a short
// TTL so high-frequency clients do not repeat DNS lookups on every request.
// Handler/input boundaries should keep calling ValidateURLForSSRF directly.
func validateURLForSSRFForOutbound(rawURL string) error {
if rawURL == "" {
return nil
}
cacheKey, ok := outboundSSRFCacheKey(rawURL)
if !ok {
return ValidateURLForSSRF(rawURL)
}
now := time.Now()
if cached, ok := ssrfOutboundCache.Load(cacheKey); ok {
entry := cached.(*ssrfOutboundCacheEntry)
if now.Before(entry.expiresAt) {
return entry.err
}
ssrfOutboundCache.Delete(cacheKey)
}
result, err, _ := ssrfOutboundValidateGroup.Do(cacheKey, func() (any, error) {
if cached, ok := ssrfOutboundCache.Load(cacheKey); ok {
entry := cached.(*ssrfOutboundCacheEntry)
if time.Now().Before(entry.expiresAt) {
return entry.err, entry.err
}
}
ssrfOutboundValidateMisses.Add(1)
validationErr := ValidateURLForSSRF(rawURL)
ssrfOutboundCache.Store(cacheKey, &ssrfOutboundCacheEntry{
err: validationErr,
expiresAt: time.Now().Add(outboundSSRFValidationTTL),
})
return validationErr, validationErr
})
if err != nil {
return err
}
if validationErr, ok := result.(error); ok {
return validationErr
}
return nil
}
func outboundSSRFCacheKey(rawURL string) (string, bool) {
normalized := rawURL
if !strings.Contains(normalized, "://") {
normalized = "https://" + normalized
}
parsed, err := url.Parse(normalized)
if err != nil {
return "", false
}
scheme := strings.ToLower(parsed.Scheme)
if scheme != "http" && scheme != "https" {
return "", false
}
host := strings.ToLower(parsed.Hostname())
if host == "" {
return "", false
}
port := parsed.Port()
if port == "" {
if scheme == "https" {
port = "443"
} else {
port = "80"
}
}
var origin string
if strings.Contains(host, ":") {
origin = fmt.Sprintf("%s://[%s]:%s", scheme, host, port)
} else {
origin = fmt.Sprintf("%s://%s:%s", scheme, host, port)
}
return fmt.Sprintf("%d|%s", ssrfOutboundCacheGen.Load(), origin), true
}
func invalidateSSRFOutboundValidationCache() {
ssrfOutboundCacheGen.Add(1)
ssrfOutboundCache = sync.Map{}
ssrfOutboundValidateGroup = singleflight.Group{}
}
// ResetSSRFOutboundValidationCacheForTest clears the outbound validation cache.
// NOT for production use.
func ResetSSRFOutboundValidationCacheForTest() {
invalidateSSRFOutboundValidationCache()
ssrfOutboundValidateMisses.Store(0)
}
// SSRFOutboundValidationMissesForTest returns how many uncached outbound
// validations have run since the last ResetSSRFOutboundValidationCacheForTest.
func SSRFOutboundValidationMissesForTest() uint64 {
return ssrfOutboundValidateMisses.Load()
}
+101
View File
@@ -0,0 +1,101 @@
package utils
import (
"net/http"
"testing"
"time"
)
func TestOutboundSSRFValidationCacheReusesSameOrigin(t *testing.T) {
ResetSSRFWhitelistForTest()
ResetSSRFOutboundValidationCacheForTest()
SetSSRFWhitelistFromRaw("cache-test.example.com")
if err := validateURLForSSRFForOutbound("https://cache-test.example.com/path/a"); err != nil {
t.Fatalf("first validation failed: %v", err)
}
if err := validateURLForSSRFForOutbound("https://cache-test.example.com/path/b"); err != nil {
t.Fatalf("second validation failed: %v", err)
}
if got := SSRFOutboundValidationMissesForTest(); got != 1 {
t.Fatalf("expected 1 cache miss for same origin, got %d", got)
}
}
func TestOutboundSSRFValidationCacheInvalidatesOnWhitelistChange(t *testing.T) {
ResetSSRFWhitelistForTest()
ResetSSRFOutboundValidationCacheForTest()
SetSSRFWhitelistFromRaw("cache-test.example.com")
if err := validateURLForSSRFForOutbound("https://cache-test.example.com/a"); err != nil {
t.Fatalf("initial validation failed: %v", err)
}
if SSRFOutboundValidationMissesForTest() != 1 {
t.Fatalf("expected one miss before whitelist change, got %d", SSRFOutboundValidationMissesForTest())
}
SetSSRFWhitelistFromRaw("cache-test.example.com,other.example.com")
if err := validateURLForSSRFForOutbound("https://cache-test.example.com/b"); err != nil {
t.Fatalf("validation after whitelist change failed: %v", err)
}
if got := SSRFOutboundValidationMissesForTest(); got != 2 {
t.Fatalf("expected cache miss after whitelist bump, got %d misses", got)
}
}
func TestOutboundSSRFValidationCacheExpires(t *testing.T) {
ResetSSRFWhitelistForTest()
ResetSSRFOutboundValidationCacheForTest()
SetSSRFWhitelistFromRaw("cache-test.example.com")
if err := validateURLForSSRFForOutbound("https://cache-test.example.com/a"); err != nil {
t.Fatalf("initial validation failed: %v", err)
}
cacheKey, ok := outboundSSRFCacheKey("https://cache-test.example.com/a")
if !ok {
t.Fatal("expected cache key")
}
raw, ok := ssrfOutboundCache.Load(cacheKey)
if !ok {
t.Fatal("expected cached entry")
}
entry := raw.(*ssrfOutboundCacheEntry)
entry.expiresAt = time.Now().Add(-time.Second)
ssrfOutboundCache.Store(cacheKey, entry)
if err := validateURLForSSRFForOutbound("https://cache-test.example.com/b"); err != nil {
t.Fatalf("validation after expiry failed: %v", err)
}
if got := SSRFOutboundValidationMissesForTest(); got != 2 {
t.Fatalf("expected revalidation after TTL expiry, got %d misses", got)
}
}
func TestSSRFValidatingRoundTripperUsesOutboundCache(t *testing.T) {
ResetSSRFWhitelistForTest()
ResetSSRFOutboundValidationCacheForTest()
SetSSRFWhitelistFromRaw("cache-test.example.com")
base := &recordingRoundTripper{}
rt := &SSRFValidatingRoundTripper{Base: base}
req1, err := http.NewRequest(http.MethodGet, "https://cache-test.example.com/a", nil)
if err != nil {
t.Fatal(err)
}
req2, err := http.NewRequest(http.MethodGet, "https://cache-test.example.com/b", nil)
if err != nil {
t.Fatal(err)
}
if _, err := rt.RoundTrip(req1); err == nil {
t.Fatal("expected base transport error, validation should pass")
}
if _, err := rt.RoundTrip(req2); err == nil {
t.Fatal("expected base transport error, validation should pass")
}
if got := SSRFOutboundValidationMissesForTest(); got != 1 {
t.Fatalf("round tripper expected one outbound validation miss, got %d", got)
}
}
+35
View File
@@ -0,0 +1,35 @@
package utils
import "strings"
// SanitizeStorageConnectivityError converts a raw storage connectivity error
// into a safe, user-facing message. It deliberately avoids echoing the raw
// driver/network error so responses never leak internal hostnames, IPs, ports
// or TLS/certificate details. Callers that need the full error must log it
// server-side instead of returning it to the client.
func SanitizeStorageConnectivityError(err error) string {
if err == nil {
return ""
}
msg := err.Error()
switch {
case strings.Contains(msg, "Endpoint url cannot have fully qualified paths"):
return "Endpoint 地址格式错误:请去除 http:// 或 https:// 前缀,只填写域名或 IP 地址和端口(例如:minio.example.com:9000)"
case strings.Contains(msg, "no such host"):
return "DNS 解析失败,请检查地址是否正确"
case strings.Contains(msg, "connection refused"):
return "连接被拒绝,请确认服务已启动且端口正确"
case strings.Contains(msg, "no route to host"):
return "无法路由到目标地址,请检查网络配置"
case strings.Contains(msg, "i/o timeout") || strings.Contains(msg, "deadline exceeded") || strings.Contains(msg, "context deadline"):
return "连接超时,请检查网络或服务状态"
case strings.Contains(msg, "403") || strings.Contains(msg, "AccessDenied") || strings.Contains(msg, "access denied"):
return "认证失败,请检查访问凭证是否正确"
case strings.Contains(msg, "certificate") || strings.Contains(msg, "tls") || strings.Contains(msg, "x509"):
return "TLS/SSL 证书错误,请检查 SSL 配置"
case strings.Contains(msg, "404") || strings.Contains(msg, "NoSuchBucket"):
return "Bucket 不存在,请检查名称和 Region"
default:
return "连接失败,请检查配置参数是否正确"
}
}
+130
View File
@@ -0,0 +1,130 @@
package utils
import (
"fmt"
"strconv"
"strings"
"time"
"github.com/google/uuid"
)
// GenerateTaskID generates a unique task ID with multiple collision-resistant elements.
// The format is: <taskType>_<tenantID>_<timestamp>_<uuid>_<businessID>
//
// Parameters:
// - taskType: Type of task (e.g., "faq_import", "kb_clone")
// - tenantID: Tenant ID for multi-tenancy isolation
// - businessID: Optional business-specific ID (e.g., knowledge base ID)
//
// Returns a task ID like: "faq_import_12345_1704628851692_a1b2c3d4_kb789"
func GenerateTaskID(taskType string, tenantID uint64, businessID ...string) string {
// Use current timestamp in milliseconds for temporal uniqueness
timestamp := time.Now().UnixMilli()
// Generate a short UUID (first 8 characters for brevity)
shortUUID := strings.ReplaceAll(uuid.New().String()[:8], "-", "")
// Build the task ID components
components := []string{
sanitizeTaskType(taskType),
strconv.FormatUint(tenantID, 10),
strconv.FormatInt(timestamp, 10),
shortUUID,
}
// Add business ID if provided
if len(businessID) > 0 && businessID[0] != "" {
components = append(components, sanitizeBusinessID(businessID[0]))
}
return strings.Join(components, "_")
}
// GenerateTaskIDWithPrefix generates a task ID with a custom prefix.
// This is useful when you want more control over the task ID format.
func GenerateTaskIDWithPrefix(prefix string, tenantID uint64, businessID ...string) string {
timestamp := time.Now().UnixMilli()
shortUUID := strings.ReplaceAll(uuid.New().String()[:8], "-", "")
components := []string{
sanitizeTaskType(prefix),
strconv.FormatUint(tenantID, 10),
strconv.FormatInt(timestamp, 10),
shortUUID,
}
if len(businessID) > 0 && businessID[0] != "" {
components = append(components, sanitizeBusinessID(businessID[0]))
}
return strings.Join(components, "_")
}
// ParseTaskID parses a task ID generated by GenerateTaskID and returns its components.
// Returns taskType, tenantID, timestamp, uuid, businessID, and error.
//
// Task types may contain underscores (e.g. "faq_import", "kb_clone"), so the
// parser locates the tenant/timestamp pair rather than assuming parts[0] is
// the full task type.
func ParseTaskID(taskID string) (taskType string, tenantID uint64, timestamp int64, uuidPart string, businessID string, err error) {
parts := strings.Split(taskID, "_")
if len(parts) < 4 {
err = fmt.Errorf("invalid task ID format: %s", taskID)
return
}
tenantIdx := -1
for i := 1; i < len(parts)-2; i++ {
candidateTenant, parseErr := strconv.ParseUint(parts[i], 10, 64)
if parseErr != nil || candidateTenant == 0 {
continue
}
candidateTS, parseErr := strconv.ParseInt(parts[i+1], 10, 64)
if parseErr != nil || candidateTS < 1_000_000_000_000 {
continue
}
tenantID = candidateTenant
timestamp = candidateTS
tenantIdx = i
break
}
if tenantIdx < 0 {
err = fmt.Errorf("invalid task ID format: %s", taskID)
return
}
taskType = strings.Join(parts[:tenantIdx], "_")
uuidPart = parts[tenantIdx+2]
if len(parts) > tenantIdx+3 {
businessID = strings.Join(parts[tenantIdx+3:], "_")
}
return
}
// TaskTenantID extracts the tenant ID embedded in a GenerateTaskID task ID.
func TaskTenantID(taskID string) (uint64, error) {
_, tenantID, _, _, _, err := ParseTaskID(taskID)
return tenantID, err
}
// sanitizeTaskType ensures task type is safe for use in task ID
func sanitizeTaskType(taskType string) string {
// Replace colons and other special characters with underscores
taskType = strings.ReplaceAll(taskType, ":", "_")
taskType = strings.ReplaceAll(taskType, "-", "_")
taskType = strings.ReplaceAll(taskType, " ", "_")
return strings.ToLower(taskType)
}
// sanitizeBusinessID ensures business ID is safe for use in task ID
func sanitizeBusinessID(businessID string) string {
// Take first 12 characters and replace special characters
if len(businessID) > 12 {
businessID = businessID[:12]
}
businessID = strings.ReplaceAll(businessID, "-", "")
businessID = strings.ReplaceAll(businessID, "_", "")
businessID = strings.ReplaceAll(businessID, ":", "")
return businessID
}
+34
View File
@@ -0,0 +1,34 @@
package utils
import "testing"
func TestParseTaskID_CompoundTaskType(t *testing.T) {
taskID := GenerateTaskID("faq_import", 42, "kb-abc-123")
taskType, tenantID, _, uuidPart, businessID, err := ParseTaskID(taskID)
if err != nil {
t.Fatalf("ParseTaskID failed: %v", err)
}
if taskType != "faq_import" {
t.Fatalf("taskType = %q, want faq_import", taskType)
}
if tenantID != 42 {
t.Fatalf("tenantID = %d, want 42", tenantID)
}
if uuidPart == "" {
t.Fatal("expected uuid part")
}
if businessID == "" {
t.Fatal("expected business ID")
}
}
func TestTaskTenantID(t *testing.T) {
taskID := GenerateTaskID("kb_clone", 7, "source-kb")
got, err := TaskTenantID(taskID)
if err != nil {
t.Fatalf("TaskTenantID failed: %v", err)
}
if got != 7 {
t.Fatalf("TaskTenantID = %d, want 7", got)
}
}