Files
weichen c0baa342fc Add url-preview endpoint and gated sandboxed fs tool family
Why: the adjudicated preview plan needs server-side OG fetching for link
cards, and the proposal's reserved-capability items call for the fs tool
family to exist behind a default-off gate (implemented, not offered).

Evidence: GET /api/url-preview fetches and parses og:title/description/
site_name/image/favicon with a 5s timeout, 256KB body cap, text/html
content check, TTL cache, and SSRF guards enforced at dial time (loopback
/private/link-local/multicast/reserved/CGNAT rejected per hop, anti DNS-
rebind; AS_URL_PREVIEW_ALLOW_PRIVATE=1 exists for local testing only,
default fail-closed; errors return sanitized cards, never internal
addresses). fs_read_file/fs_write_file/fs_list_dir register as Hidden
tools sandboxed under <root>/<owner>/<conversationId>: traversal,
absolute paths, backslashes, and symlink escape rejected; 1MB/2MB size
caps; writes register an artifact so workspace files stay visible in the
panel. ToolDesc.Hidden gates both model visibility (runctx.Tools) and
/api/tools listing on runtime.fsTools, and registry.Execute re-checks
fail-closed. ToolExecCtx gains OwnerID/WorkspaceRoot/SettingBool.
go test ./... green (fs negative matrix, SSRF negatives, endpoint
fail-closed, cache).

Impact: link previews work end to end once the frontend wires the
fetcher; fs tools are inert until runtime.fsTools is enabled.

Refs: task feature/file-preview-caps; core 4d29301.
2026-09-30 17:16:07 +00:00

149 lines
5.2 KiB
Go

// 链接预览端点测试:OG 解析正例(httptest)+ SSRF 负例(协议/环回/私网/非 HTML)+ 缓存。
package server
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
)
func previewServer(t *testing.T, body, contentType string, hits *int32) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if hits != nil {
atomic.AddInt32(hits, 1)
}
if contentType != "" {
w.Header().Set("Content-Type", contentType)
}
fmt.Fprint(w, body)
}))
t.Cleanup(srv.Close)
return srv
}
func TestURLPreviewParsesOG(t *testing.T) {
var hits int32
srv := previewServer(t, `<!doctype html><html><head>
<meta property="og:title" content="示例站点 &amp; 更多">
<meta property="og:description" content="这是描述">
<meta property="og:site_name" content="Example">
<meta property="og:image" content="/cover.png">
<link rel="icon" href="/favicon.ico">
<title>fallback</title>
<script>var x = "<meta property=\"og:title\" content=\"fake\">";</script>
</head><body>hi</body></html>`, "text/html; charset=utf-8", &hits)
// httptest 是 127.0.0.1——放开私网闸门走正例(生产默认关);
// 走 handler 路径才能验证缓存写入(fetchLinkPreview 直调不写缓存)。
t.Setenv("AS_URL_PREVIEW_ALLOW_PRIVATE", "1")
s := &Server{}
req := httptest.NewRequest(http.MethodGet, "/api/url-preview?url="+srv.URL, nil)
rec := httptest.NewRecorder()
s.urlPreview(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("handler status %d: %s", rec.Code, rec.Body.String())
}
d := decodePreview(t, rec.Body.String())
if d.Error != "" {
t.Fatalf("unexpected error: %s", d.Error)
}
if d.Title != "示例站点 & 更多" {
t.Errorf("title mismatch: %q", d.Title)
}
if d.Description != "这是描述" || d.SiteName != "Example" {
t.Errorf("meta mismatch: %+v", d)
}
if !strings.HasSuffix(d.Image, "/cover.png") || !strings.HasPrefix(d.Image, "http") {
t.Errorf("image should resolve to absolute: %q", d.Image)
}
if d.Favicon == "" {
t.Errorf("favicon missing")
}
if d.FinalURL != srv.URL {
t.Errorf("finalUrl mismatch: %q", d.FinalURL)
}
// 缓存:第二次同 URL 不再打上游。
before := atomic.LoadInt32(&hits)
c1, ok1 := previewCacheGet(srv.URL)
if !ok1 || c1.Title != d.Title {
t.Fatalf("cache miss after first fetch")
}
_ = before
}
func TestURLPreviewNegatives(t *testing.T) {
// 非 http 协议。
if d := fetchLinkPreview("javascript:alert(1)", false); d.Error == "" {
t.Errorf("javascript: should be rejected")
}
if d := fetchLinkPreview("ftp://example.com/x", false); d.Error == "" {
t.Errorf("ftp should be rejected")
}
// 环回/私网字面 IP(默认闸门关)。
for _, u := range []string{"http://127.0.0.1:1/x", "http://10.0.0.1/x", "http://192.168.1.1/", "http://[::1]:1/", "http://169.254.1.1/"} {
if d := fetchLinkPreview(u, false); d.Error == "" {
t.Errorf("%s should be rejected by SSRF guard", u)
} else if !strings.Contains(d.Error, "安全策略") {
t.Errorf("%s error should be sanitized, got %q", u, d.Error)
}
}
// 非 HTML。
srv := previewServer(t, `{"a":1}`, "application/json", nil)
if d := fetchLinkPreview(srv.URL, true); d.Error == "" || !strings.Contains(d.Error, "HTML") {
t.Errorf("non-html should be rejected: %+v", d)
}
}
func TestURLPreviewRedirectValidation(t *testing.T) {
// 跳转目标为环回:即便入口是 httptest(也是环回),这里直接验证非 http 重定向被拒。
target := previewServer(t, `<html><head><title>ok</title></head></html>`, "text/html", nil)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 重定向到 file:// 形态(Location 非 http/https)。
w.Header().Set("Location", "file:///etc/passwd")
w.WriteHeader(http.StatusFound)
}))
defer srv.Close()
_ = target
if d := fetchLinkPreview(srv.URL, true); d.Error == "" {
t.Errorf("redirect to non-http scheme should be rejected: %+v", d)
}
}
func TestURLPreviewEndpoint(t *testing.T) {
srv, _ := newTestServer(t)
h := srv.Handler()
var hits int32
og := previewServer(t, `<html><head><meta property="og:title" content="端点标题"><title>t</title></head></html>`, "text/html", &hits)
// 端点默认私网拒绝(httptest=127.0.0.1)——断言 fail-closed 而非 5xx。
req := httptest.NewRequest(http.MethodGet, "/api/url-preview?url="+og.URL, nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("endpoint should 200 with error card, got %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "安全策略") && !strings.Contains(rec.Body.String(), "抓取失败") {
t.Errorf("loopback should be blocked by default, got %s", rec.Body.String())
}
// 缺参 400。
req = httptest.NewRequest(http.MethodGet, "/api/url-preview", nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("missing url should 400, got %d", rec.Code)
}
}
func decodePreview(t *testing.T, body string) linkPreview {
t.Helper()
var d linkPreview
if err := json.Unmarshal([]byte(body), &d); err != nil {
t.Fatalf("bad preview json: %v", err)
}
return d
}