fix(security): close remaining GHSA gaps from security triage

Block scheme-prefixed loopback storage checks, validate Notion attachment
URLs, sanitize wiki markdown HTML, and require MCP_SERVER_AUTH_TOKEN for
network MCP transports.
This commit is contained in:
wizardchen
2026-07-02 14:58:19 +08:00
committed by lyingbug
parent f23c674bad
commit cd65665894
9 changed files with 158 additions and 13 deletions
@@ -649,7 +649,8 @@ const wikiDrawerContent = computed(() => {
return `<a href="#" class="wiki-content-link citation-wiki" data-slug="${escapeHtml(slug)}">${escapeHtml(display)}</a>`;
});
return wrapChatMarkdownTables(marked.parse(preprocessed, { breaks: true, async: false }) as string);
const html = marked.parse(preprocessed, { breaks: true, async: false }) as string;
return sanitizeMarkdownHTML(wrapChatMarkdownTables(html));
});
watch(wikiDrawerContent, async () => {
@@ -699,7 +699,7 @@ import { useI18n } from 'vue-i18n'
import { marked } from 'marked'
import { MessagePlugin } from 'tdesign-vue-next'
import { RecycleScroller } from 'vue-virtual-scroller'
import { hydrateProtectedFileImages } from '@/utils/security'
import { hydrateProtectedFileImages, sanitizeMarkdownHTML } from '@/utils/security'
import picturePreview from '@/components/picture-preview.vue'
import WikiFolderActions from './WikiFolderActions.vue'
import { createSessions } from '@/api/chat'
@@ -1398,8 +1398,8 @@ function renderMarkdown(content: string): string {
return `<a href="#" class="wiki-content-link" data-slug="${slug}">${display}</a>`
})
// Use marked to render the markdown to HTML
return marked.parse(preprocessed, { breaks: true, async: false }) as string
const html = marked.parse(preprocessed, { breaks: true, async: false }) as string
return sanitizeMarkdownHTML(html)
}
async function openGraphDrawer(slug: string) {
@@ -15,6 +15,7 @@ import (
"github.com/Tencent/WeKnora/internal/datasource"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/utils"
)
// notionClient wraps the Notion API with rate limiting and retry logic.
@@ -378,6 +379,9 @@ const maxDownloadSize = 100 * 1024 * 1024 // 100MB — prevent OOM from oversize
// DownloadFile downloads a file from the given URL (typically an S3 signed URL).
// Does not go through the rate limiter since it's not a Notion API call.
func (c *notionClient) DownloadFile(ctx context.Context, fileURL string) ([]byte, error) {
if err := utils.ValidateURLForSSRF(fileURL); err != nil {
return nil, fmt.Errorf("attachment URL rejected: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fileURL, nil)
if err != nil {
return nil, fmt.Errorf("create download request: %w", err)
@@ -347,3 +347,17 @@ func TestClientQueryDatabaseAll(t *testing.T) {
// Ensure time import is used (referenced by fakeNotion indirectly via time.Time in types).
var _ = time.Now
func TestDownloadFile_RejectsLoopbackURL(t *testing.T) {
secutils.ResetSSRFWhitelistForTest()
t.Cleanup(secutils.ResetSSRFWhitelistForTest)
client, err := newClient("test-token", "https://api.notion.com")
if err != nil {
t.Fatalf("newClient: %v", err)
}
_, err = client.DownloadFile(context.Background(), "http://127.0.0.1/secret")
if err == nil {
t.Fatal("expected loopback attachment URL to be rejected")
}
}
+25 -3
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"net"
"net/http"
"net/url"
"os"
"regexp"
"strconv"
@@ -634,14 +635,35 @@ func sanitizeStorageCheckError(err error) string {
}
}
// storageEndpointHost extracts the hostname from a storage endpoint string.
// Endpoints may be bare host:port, hostnames, or full URLs with a scheme
// (e.g. "http://127.0.0.1:9000" for S3-compatible stores).
func storageEndpointHost(endpoint string) string {
endpoint = strings.TrimSpace(endpoint)
if endpoint == "" {
return ""
}
if strings.Contains(endpoint, "://") {
if u, err := url.Parse(endpoint); err == nil {
if h := u.Hostname(); h != "" {
return h
}
}
}
if host, _, err := net.SplitHostPort(endpoint); err == nil {
return host
}
return endpoint
}
// isBlockedStorageEndpoint checks whether a storage endpoint resolves to a dangerous
// address (cloud metadata, loopback, link-local). Unlike the stricter isSSRFSafeURL,
// this allows private IPs since MinIO is commonly deployed on internal networks.
// It also respects the SSRF_WHITELIST environment variable for whitelisted hosts.
func isBlockedStorageEndpoint(endpoint string) (bool, string) {
host, _, err := net.SplitHostPort(endpoint)
if err != nil {
host = endpoint
host := storageEndpointHost(endpoint)
if host == "" {
return true, "无效的地址"
}
// Check SSRF whitelist first – whitelisted hosts bypass the block check.
@@ -0,0 +1,39 @@
package handler
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestStorageEndpointHost(t *testing.T) {
tests := []struct {
endpoint string
want string
}{
{"127.0.0.1:9000", "127.0.0.1"},
{"http://127.0.0.1:9000", "127.0.0.1"},
{"https://127.0.0.1:9000", "127.0.0.1"},
{"minio.internal:9000", "minio.internal"},
{"https://s3.amazonaws.com", "s3.amazonaws.com"},
}
for _, tt := range tests {
t.Run(tt.endpoint, func(t *testing.T) {
assert.Equal(t, tt.want, storageEndpointHost(tt.endpoint))
})
}
}
func TestIsBlockedStorageEndpoint_SchemePrefixedLoopback(t *testing.T) {
for _, endpoint := range []string{
"http://127.0.0.1:9000",
"https://127.0.0.1:9000",
"127.0.0.1:9000",
} {
t.Run(endpoint, func(t *testing.T) {
blocked, reason := isBlockedStorageEndpoint(endpoint)
assert.True(t, blocked, "expected loopback endpoint to be blocked")
assert.NotEmpty(t, reason)
})
}
}
+1
View File
@@ -10,6 +10,7 @@ RUN pip install --no-cache-dir -e .
ENV MCP_HOST=0.0.0.0
ENV MCP_PORT=8000
ENV WEKNORA_BASE_URL=http://app:8080/api/v1
# Set MCP_SERVER_AUTH_TOKEN at runtime; HTTP transport refuses to start without it.
EXPOSE 8000
+5 -4
View File
@@ -68,8 +68,9 @@ def parse_arguments():
python main.py --verbose # 启用详细日志
环境变量:
WEKNORA_BASE_URL WeKnora API 基础 URL (默认: http://localhost:8080/api/v1)
WEKNORA_API_KEY WeKnora API 密钥
WEKNORA_BASE_URL WeKnora API 基础 URL (默认: http://localhost:8080/api/v1)
WEKNORA_API_KEY WeKnora API 密钥
MCP_SERVER_AUTH_TOKEN SSE/HTTP 传输必填,客户端通过 Authorization: Bearer 传递
""",
)
@@ -91,8 +92,8 @@ def parse_arguments():
)
parser.add_argument(
"--host",
default=os.getenv("MCP_HOST", "0.0.0.0"),
help="Bind host for network transports (default: 0.0.0.0)",
default=os.getenv("MCP_HOST", "127.0.0.1"),
help="Bind host for network transports (default: 127.0.0.1)",
)
parser.add_argument(
"--port",
+65 -2
View File
@@ -12,6 +12,8 @@ import json
import logging
import os
import re
import secrets
import sys
from typing import Any, Dict
import urllib3
@@ -37,6 +39,63 @@ except ValueError:
WEKNORA_CHAT_TIMEOUT = 300
def network_transport_auth_token() -> str:
"""Shared secret clients must present for SSE/HTTP transports."""
return os.getenv("MCP_SERVER_AUTH_TOKEN", "").strip()
def require_network_transport_auth(transport: str) -> str:
"""SSE/HTTP must not start without a configured auth token."""
token = network_transport_auth_token()
if transport in ("sse", "http") and not token:
logger.error(
"MCP_SERVER_AUTH_TOKEN is required for %s transport. "
"Set a strong shared secret; clients must send "
"Authorization: Bearer <token> or X-MCP-Auth-Token.",
transport,
)
sys.exit(1)
return token
class MCPAuthMiddleware:
"""ASGI middleware that gates network MCP transports behind a shared secret."""
def __init__(self, app, token: str):
self.app = app
self.token = token
async def __call__(self, scope, receive, send):
if scope.get("type") != "http":
await self.app(scope, receive, send)
return
headers = {
k.decode("latin-1").lower(): v.decode("latin-1")
for k, v in scope.get("headers", [])
}
provided = ""
auth = headers.get("authorization", "")
if auth.lower().startswith("bearer "):
provided = auth[7:].strip()
elif "x-mcp-auth-token" in headers:
provided = headers["x-mcp-auth-token"]
if not provided or not secrets.compare_digest(provided, self.token):
body = b'{"error":"unauthorized"}'
await send(
{
"type": "http.response.start",
"status": 401,
"headers": [[b"content-type", b"application/json"]],
}
)
await send({"type": "http.response.body", "body": body})
return
await self.app(scope, receive, send)
class WeKnoraClient:
"""Client for interacting with WeKnora API"""
@@ -1283,6 +1342,7 @@ async def run_stdio():
async def run_sse(host: str, port: int):
"""Run the MCP server using SSE transport (legacy MCP clients)"""
auth_token = require_network_transport_auth("sse")
try:
from mcp.server.sse import SseServerTransport
from starlette.applications import Starlette
@@ -1307,6 +1367,7 @@ async def run_sse(host: str, port: int):
Mount("/messages/", app=sse.handle_post_message),
]
)
starlette_app = MCPAuthMiddleware(starlette_app, auth_token)
logger.info("Starting SSE MCP server on %s:%d", host, port)
logger.info("SSE endpoint: http://%s:%d/sse", host, port)
@@ -1317,6 +1378,7 @@ async def run_sse(host: str, port: int):
async def run_http(host: str, port: int):
"""Run the MCP server using Streamable HTTP transport (MCP 2025-03-26 spec)"""
auth_token = require_network_transport_auth("http")
try:
from contextlib import asynccontextmanager
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
@@ -1344,6 +1406,7 @@ async def run_http(host: str, port: int):
routes=[Mount("/", app=session_manager.handle_request)],
lifespan=lifespan,
)
starlette_app = MCPAuthMiddleware(starlette_app, auth_token)
logger.info("Starting Streamable HTTP MCP server on %s:%d", host, port)
logger.info("MCP endpoint: http://%s:%d/mcp", host, port)
@@ -1373,8 +1436,8 @@ def main():
)
parser.add_argument(
"--host",
default=os.getenv("MCP_HOST", "0.0.0.0"),
help="Bind host for network transports (default: 0.0.0.0)",
default=os.getenv("MCP_HOST", "127.0.0.1"),
help="Bind host for network transports (default: 127.0.0.1)",
)
parser.add_argument(
"--port",