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
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:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)"
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 "连接失败,请检查配置参数是否正确"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user