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:
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user