diff --git a/apps/api/.env.example b/apps/api/.env.example index a63b723..df5d1e7 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -1,11 +1,86 @@ +# 将本文件复制为 apps/api/.env,再填写实际配置。不要提交真实 .env。 +# 下面是当前需要的开放网络配置。API 和网页开发服务共同读取此文件。 +# 修改网络配置后重启 API 和网页服务;已有监听进程不会自动重新读取 .env。 + +# 运行环境:development 为开发,production 为上线。 +# 未明确设置的网络开关会按环境采用默认值;本示例明确设置的开关优先。 +NODE_ENV=development + +# MySQL 连接字符串:替换用户名、密码、主机、端口及数据库名。 +# 密码中的特殊字符需要进行 URL 编码。 DATABASE_URL="mysql://USER:PASSWORD@HOST:3306/worthpath" + +# API 监听地址:0.0.0.0 接收所有本机 IPv4 网卡连接;127.0.0.1 仅本机访问。 +API_HOST=0.0.0.0 +# API 监听端口。 PORT=3100 -# Local tunnel testing may use WEB_ORIGIN=*. Production requires an exact origin. -WEB_ORIGIN=http://localhost:5173 -COOKIE_SECURE=false -# Canonical OAuth protected resource. Production must use HTTPS. +# API 请求 Host 白名单:* 允许所有主机;收紧时填写主机及端口,逗号分隔,不带协议。 +API_ALLOWED_HOSTS=* + +# 网页开发服务监听地址:0.0.0.0 允许局域网访问;127.0.0.1 仅本机。 +WEB_HOST=0.0.0.0 +# 网页开发服务端口;生产静态页面由部署服务器或反向代理决定端口。 +WEB_PORT=5173 +# 网页开发服务允许的主机名:* 允许全部;收紧时填写域名或 IP,逗号分隔,不带协议和端口。 +WEB_ALLOWED_HOSTS=* +# 网页开发服务将 /api 请求转发到此地址;这是服务器访问的地址,可保留本机地址。 +WEB_API_PROXY=http://127.0.0.1:3100 +# 网页 API 的浏览器来源白名单:填写完整协议、主机和端口,逗号分隔。 +# * 表示所有来源,同时需要 NETWORK_ALLOW_WILDCARD_ORIGINS=true。 +WEB_ORIGIN=* + +# MCP 对外服务的固定完整地址,路径必须为 /mcp。 +# 其他电脑访问时,将 localhost 改为其可访问的 IP 或域名;不要填写 *。 +# OAuth 资源校验和复制给助手的配置提示词使用此地址。 MCP_PUBLIC_URL=http://localhost:3100/mcp -# Web confirmation page, normally the same origin as WEB_ORIGIN. +# OAuth 授权和草稿确认网页的固定地址,通常与实际打开的网站地址一致。 +# 其他电脑使用时同样需要可访问的 IP 或域名,不要填写 *。 MCP_WEB_URL=http://localhost:5173 -# Exact browser Origin allowlist; never use *. Non-browser clients may omit Origin. -MCP_ALLOWED_ORIGINS=http://localhost:5173 +# MCP 请求 Host 白名单:* 放开所有 IP/域名;精确值包含端口,逗号分隔。 +# 留空时仅允许固定 MCP 地址的 Host,以及开关允许的本机地址。 +MCP_ALLOWED_HOSTS=* +# MCP 浏览器请求来源白名单:完整协议、主机、端口,逗号分隔。 +# * 放开所有来源;不携带 Origin 的非浏览器 MCP 客户端仍可请求。 +MCP_ALLOWED_ORIGINS=* +# MCP Host 白名单留空时,是否额外允许 localhost 和 127.0.0.1 的对应端口。 +# 使用显式白名单或 * 时,此开关不扩大该白名单。 +MCP_ALLOW_LOOPBACK_HOSTS=true + +# 是否允许固定 MCP 服务地址和网页授权地址使用 HTTP;false 要求 HTTPS。 +# 同时控制 SDK 的 HTTP 授权服务配置,无需额外设置 SDK 内部变量。 +NETWORK_ALLOW_HTTP=true +# 是否允许 OAuth 客户端登记非本机 HTTP 回调地址。 +# false 时只允许 HTTPS 或本机回环 HTTP 回调;回调地址仍须精确匹配登记内容。 +NETWORK_ALLOW_HTTP_REDIRECTS=true +# 是否允许 WEB_ORIGIN、MCP_ALLOWED_ORIGINS 中使用 *。 +# false 时需填写精确来源列表,上线时建议关闭。 +NETWORK_ALLOW_WILDCARD_ORIGINS=true +# 是否要求登录 Cookie 必须开启 Secure;true 时 COOKIE_SECURE=false 会阻止 API 启动。 +NETWORK_REQUIRE_SECURE_COOKIE=false +# 是否发送 HSTS 响应头,告知浏览器以后仅使用 HTTPS;HTTP 开发环境设 false。 +NETWORK_HSTS=false +# 是否发送 CSP 的 upgrade-insecure-requests 指令,让浏览器升级 HTTP 子资源请求。 +# HTTP 开发环境设 false;使用 HTTPS 上线时可设 true。 +NETWORK_UPGRADE_INSECURE_REQUESTS=false + +# 登录 Cookie 是否只通过 HTTPS 传输;HTTP 环境设 false,HTTPS 上线设 true。 +COOKIE_SECURE=false +# 登录 Cookie 的跨站发送策略:strict、lax、none,默认 strict。 +# 同站网页通过 /api 代理访问可保持 strict;跨站 none 必须同时 COOKIE_SECURE=true。 +COOKIE_SAME_SITE=strict + +# 上线参考:NODE_ENV=production,NETWORK_ALLOW_HTTP=false, +# NETWORK_ALLOW_HTTP_REDIRECTS=false,NETWORK_ALLOW_WILDCARD_ORIGINS=false, +# NETWORK_REQUIRE_SECURE_COOKIE=true,COOKIE_SECURE=true, +# NETWORK_HSTS=true,NETWORK_UPGRADE_INSECURE_REQUESTS=true。 +# 同时将三个 Host 和两个 Origin 白名单改为实际值,MCP 两个固定地址改为 HTTPS。 +# 这些设置控制应用访问策略;公网访问还取决于防火墙、路由或反向代理是否放行端口。 + +# 是否启用登录及 OAuth 授权端点的请求限流;true 启用,false 关闭。 +NETWORK_RATE_LIMIT_ENABLED=true +# 同一来源累计请求的时间窗口,单位毫秒;900000 为 15 分钟。 +NETWORK_RATE_LIMIT_WINDOW_MS=900000 +# 每个来源在窗口内可尝试的登录、注册或安全操作次数;上线可按需收紧。 +NETWORK_AUTH_RATE_LIMIT_MAX=30 +# 每个来源在窗口内访问单个 OAuth 授权、令牌、撤销或注册端点的次数。 +MCP_AUTH_RATE_LIMIT_MAX=100 diff --git a/apps/api/package.json b/apps/api/package.json index d45517b..e273c76 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -5,7 +5,7 @@ "dev": "node scripts/dev.cjs", "build": "tsc", "typecheck": "tsc --noEmit", - "test": "tsx --test --test-concurrency=1 test/calculation.test.ts test/update.test.ts test/zip.test.ts test/metals.test.ts ../web/test/i18n.test.ts test/account-deletion.test.ts", + "test": "tsx --test --test-concurrency=1 test/calculation.test.ts test/update.test.ts test/zip.test.ts test/metals.test.ts ../web/test/i18n.test.ts test/account-deletion.test.ts test/mcp-hosts.test.ts test/mcp-http.test.ts test/network.test.ts", "db:generate": "prisma generate", "db:migrate": "node scripts/database.cjs deploy", "db:status": "node scripts/database.cjs status", diff --git a/apps/api/src/auth.ts b/apps/api/src/auth.ts index 3c2de6b..0e19984 100644 --- a/apps/api/src/auth.ts +++ b/apps/api/src/auth.ts @@ -1,3 +1,4 @@ +import { networkConfig, isNetworkOriginAllowed } from './network'; import { Injectable, Controller, @@ -36,30 +37,26 @@ export function allowedOrigin( configured: string | undefined, production = false, ) { - if (configured !== '*') return !!origin && origin === configured; - if (production || !origin) return false; - try { - const url = new URL(origin); - return ['http:', 'https:'].includes(url.protocol) && url.origin === origin; - } catch { - return false; - } + return isNetworkOriginAllowed(origin, configured, !production); } @Injectable() export class AuthService { private attempts = new Map(); constructor(private db: Database) {} limit(req: Request) { + const network = networkConfig(); + if (!network.rateLimitEnabled) return; const key = (req.ip || 'local') + ('userId' in req && typeof req.userId === 'string' ? ':' + req.userId : ''), now = Date.now(); let v = this.attempts.get(key); if (!v || v.until < now) { - v = { count: 0, until: now + 900000 }; + v = { count: 0, until: now + network.rateLimitWindowMs }; this.attempts.set(key, v); } - if (++v.count > 30) throw new HttpException('尝试过于频繁,请 15 分钟后重试', 429); + if (++v.count > network.authRateLimitMax) + throw new HttpException('尝试过于频繁,请稍后重试', 429); if (this.attempts.size > 10000) { for (const [k, v] of this.attempts) if (v.until < now) this.attempts.delete(k); if (this.attempts.size > 10000) throw new HttpException('服务繁忙,请稍后重试', 429); @@ -75,8 +72,8 @@ export class AuthService { cookie(token: string, expiresAt: Date, res: Response) { res.cookie('wp_session', token, { httpOnly: true, - sameSite: 'strict', - secure: process.env.COOKIE_SECURE === 'true', + sameSite: networkConfig().sameSite, + secure: networkConfig().cookieSecure, expires: expiresAt, path: '/api', }); @@ -100,8 +97,8 @@ export class AuthService { await this.db.session.deleteMany({ where: { id: digest(req.cookies.wp_session) } }); res.clearCookie('wp_session', { path: '/api', - sameSite: 'strict', - secure: process.env.COOKIE_SECURE === 'true', + sameSite: networkConfig().sameSite, + secure: networkConfig().cookieSecure, httpOnly: true, }); } @@ -119,7 +116,7 @@ export class AuthGuard implements CanActivate { !allowedOrigin( req.headers.origin, process.env.WEB_ORIGIN, - process.env.NODE_ENV === 'production', + !networkConfig().allowWildcardOrigins, ) ) throw new ForbiddenException('请求来源不受信任'); diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 8f65eb4..51ec915 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,3 +1,4 @@ +import { networkConfig, isNetworkHostAllowed } from './network'; import 'reflect-metadata'; import 'dotenv/config'; import { setupOpenApi } from './openapi'; @@ -92,12 +93,33 @@ class AppModule {} async function bootstrap() { if (!process.env.DATABASE_URL || !process.env.WEB_ORIGIN) throw Error('Missing local environment configuration'); - if (process.env.NODE_ENV === 'production' && process.env.WEB_ORIGIN === '*') + const network = networkConfig(); + if ( + !network.allowWildcardOrigins && + process.env.WEB_ORIGIN.split(',').some((v) => v.trim() === '*') + ) throw Error('Production requires an explicit web origin'); - if (process.env.NODE_ENV === 'production' && process.env.COOKIE_SECURE !== 'true') + if (network.requireSecureCookie && !network.cookieSecure) throw Error('Production requires secure cookies'); const app = await NestFactory.create(AppModule, { logger: false, bodyParser: false }); - app.use(helmet()); + app.use( + helmet({ + strictTransportSecurity: network.hsts ? undefined : false, + contentSecurityPolicy: { + directives: { 'upgrade-insecure-requests': network.upgradeInsecureRequests ? [] : null }, + }, + }), + ); + app.use((req: { headers: { host?: string } }, res: any, next: () => void) => { + if (!isNetworkHostAllowed(req.headers.host, network.apiAllowedHosts)) + return res.status(403).json({ message: '请求 Host 不受信任' }); + next(); + }); + const origins = process.env.WEB_ORIGIN.split(',').map((v) => v.trim()); + app.enableCors({ + origin: network.allowWildcardOrigins && origins.includes('*') ? true : origins, + credentials: true, + }); app.use(json({ limit: '8mb' })); app.use(cookieParser()); app.use((_req: unknown, res: { setHeader: (k: string, v: string) => void }, next: () => void) => { @@ -108,7 +130,7 @@ async function bootstrap() { app.useGlobalFilters(new SafeErrors()); setupOpenApi(app); app.enableShutdownHooks(); - await app.listen(Number(process.env.PORT || 3100), '0.0.0.0'); + await app.listen(Number(process.env.PORT || 3100), network.apiHost); console.log('WorthPath API ready'); } void bootstrap().catch(() => { diff --git a/apps/api/src/mcp/hosts.ts b/apps/api/src/mcp/hosts.ts new file mode 100644 index 0000000..f175265 --- /dev/null +++ b/apps/api/src/mcp/hosts.ts @@ -0,0 +1,16 @@ +/** Explicit entries match Host including its port; an omitted list preserves defaults. */ +export function isAllowedMcpHost( + host: string | undefined, + resource: URL, + configured: string | undefined, + production: boolean, +): boolean { + if (!host) return false; + const defaults = [resource.host]; + if (!production) + defaults.push('127.0.0.1:' + resource.port, 'localhost:' + resource.port); + const allowed = configured?.trim() + ? configured.split(',').map((value) => value.trim().toLowerCase()).filter(Boolean) + : defaults.map((value) => value.toLowerCase()); + return allowed.includes('*') || allowed.includes(host.toLowerCase()); +} diff --git a/apps/api/src/mcp/oauth.ts b/apps/api/src/mcp/oauth.ts index 3aa5e97..44ce1a0 100644 --- a/apps/api/src/mcp/oauth.ts +++ b/apps/api/src/mcp/oauth.ts @@ -1,3 +1,4 @@ +import { networkConfig } from '../network'; import { Injectable, BadRequestException, ForbiddenException } from '@nestjs/common'; import { randomBytes, randomUUID, createHash } from 'node:crypto'; import { Response } from 'express'; @@ -55,17 +56,11 @@ export function urls() { throw Error('MCP_PUBLIC_URL must be the canonical /mcp URL'); if ( resource.protocol !== 'https:' && - !( - process.env.NODE_ENV !== 'production' && - ['localhost', '127.0.0.1', '[::1]'].includes(resource.hostname) - ) + !(networkConfig().allowHttp && resource.protocol === 'http:') ) - throw Error('MCP requires HTTPS except local development'); + throw Error('MCP HTTP is disabled; enable NETWORK_ALLOW_HTTP or use HTTPS'); const web = new URL(process.env.MCP_WEB_URL || 'http://localhost:5173'); - if ( - web.protocol !== 'https:' && - !(process.env.NODE_ENV !== 'production' && ['localhost', '127.0.0.1'].includes(web.hostname)) - ) + if (web.protocol !== 'https:' && !(networkConfig().allowHttp && web.protocol === 'http:')) throw Error('MCP web confirmation requires HTTPS'); return { resource, issuer: new URL(resource.origin), web }; } @@ -102,7 +97,9 @@ export class AgentOAuth implements OAuthServerProvider { u.password || !( u.protocol === 'https:' || - (u.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(u.hostname)) + (u.protocol === 'http:' && + (networkConfig().allowHttpRedirects || + ['127.0.0.1', 'localhost', '[::1]'].includes(u.hostname))) ) ) throw new InvalidClientMetadataError('HTTPS or loopback redirect required'); diff --git a/apps/api/src/mcp/transport.ts b/apps/api/src/mcp/transport.ts index c8db448..a767435 100644 --- a/apps/api/src/mcp/transport.ts +++ b/apps/api/src/mcp/transport.ts @@ -1,12 +1,9 @@ +import { networkConfig, isNetworkOriginAllowed } from '../network'; import { Injectable } from '@nestjs/common'; import { Express } from 'express'; import { z } from 'zod'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; -import { - mcpAuthRouter, - getOAuthProtectedResourceMetadataUrl, -} from '@modelcontextprotocol/sdk/server/auth/router.js'; import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js'; import { AgentOAuth, urls, scopes } from './oauth'; import { AgentOperations, writing, plain } from './operations'; @@ -14,6 +11,7 @@ import { AgentFiles } from './files'; import { Database } from '../database'; import { HttpException } from '@nestjs/common'; import { ZodError } from 'zod'; +import { isAllowedMcpHost } from './hosts'; export function toolResult(value: unknown) { const data = plain(value); @@ -40,6 +38,15 @@ export class AgentTransport { ) {} install(app: Express) { const { issuer, resource } = urls(); + // The SDK reads this flag when its router module is first loaded. + process.env.MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL = + networkConfig().allowHttp && issuer.protocol === 'http:' ? 'true' : 'false'; + const { mcpAuthRouter, getOAuthProtectedResourceMetadataUrl } = + require('@modelcontextprotocol/sdk/server/auth/router.js') as typeof import('@modelcontextprotocol/sdk/server/auth/router.js'); + const network = networkConfig(); + const rateLimit = network.rateLimitEnabled + ? { windowMs: network.rateLimitWindowMs, limit: network.mcpAuthRateLimitMax } + : (false as const); app.use( mcpAuthRouter({ provider: this.oauth, @@ -47,23 +54,32 @@ export class AgentTransport { resourceServerUrl: resource, scopesSupported: [...scopes], resourceName: 'WorthPath', + authorizationOptions: { rateLimit }, + tokenOptions: { rateLimit }, + revocationOptions: { rateLimit }, + clientRegistrationOptions: { rateLimit }, }), ); this.files.install(app); app.all( '/mcp', (req, res, next) => { - const allowed = (process.env.MCP_ALLOWED_ORIGINS || urls().web.origin) - .split(',') - .map((s) => s.trim()); - if (req.headers.origin && !allowed.includes(req.headers.origin)) { + const allowed = process.env.MCP_ALLOWED_ORIGINS || urls().web.origin; + if ( + req.headers.origin && + !isNetworkOriginAllowed(req.headers.origin, allowed, networkConfig().allowWildcardOrigins) + ) { res.status(403).json({ error: 'Untrusted origin' }); return; } - const validHosts = [resource.host]; - if (process.env.NODE_ENV !== 'production') - validHosts.push('127.0.0.1:' + resource.port, 'localhost:' + resource.port); - if (!validHosts.includes(req.headers.host || '')) { + if ( + !isAllowedMcpHost( + req.headers.host, + resource, + process.env.MCP_ALLOWED_HOSTS, + !networkConfig().allowLoopbackHosts, + ) + ) { res.status(403).json({ error: 'Untrusted host' }); return; } diff --git a/apps/api/src/network.ts b/apps/api/src/network.ts new file mode 100644 index 0000000..564909b --- /dev/null +++ b/apps/api/src/network.ts @@ -0,0 +1,57 @@ +export function networkFlag(name: string, fallback: boolean): boolean { + const value = process.env[name]?.trim().toLowerCase(); + if (!value) return fallback; + if (value === 'true' || value === '1') return true; + if (value === 'false' || value === '0') return false; + throw Error(name + ' must be true or false'); +} +export function networkNumber(name: string, fallback: number) { + const value = Number(process.env[name] || fallback); + if (!Number.isSafeInteger(value) || value < 1) throw Error(name + ' must be a positive integer'); + return value; +} +export function networkConfig() { + const production = process.env.NODE_ENV === 'production'; + const sameSite = process.env.COOKIE_SAME_SITE || 'strict'; + if (!['strict', 'lax', 'none'].includes(sameSite)) throw Error('Invalid COOKIE_SAME_SITE'); + const cookieSecure = networkFlag('COOKIE_SECURE', production); + if (sameSite === 'none' && !cookieSecure) + throw Error('SameSite=None requires COOKIE_SECURE=true'); + return { + rateLimitEnabled: networkFlag('NETWORK_RATE_LIMIT_ENABLED', true), + rateLimitWindowMs: networkNumber('NETWORK_RATE_LIMIT_WINDOW_MS', 900000), + authRateLimitMax: networkNumber('NETWORK_AUTH_RATE_LIMIT_MAX', 30), + mcpAuthRateLimitMax: networkNumber('MCP_AUTH_RATE_LIMIT_MAX', 100), + allowHttp: networkFlag('NETWORK_ALLOW_HTTP', !production), + allowWildcardOrigins: networkFlag('NETWORK_ALLOW_WILDCARD_ORIGINS', !production), + allowHttpRedirects: networkFlag('NETWORK_ALLOW_HTTP_REDIRECTS', !production), + requireSecureCookie: networkFlag('NETWORK_REQUIRE_SECURE_COOKIE', production), + hsts: networkFlag('NETWORK_HSTS', production), + upgradeInsecureRequests: networkFlag('NETWORK_UPGRADE_INSECURE_REQUESTS', production), + allowLoopbackHosts: networkFlag('MCP_ALLOW_LOOPBACK_HOSTS', !production), + apiHost: process.env.API_HOST || '0.0.0.0', + apiAllowedHosts: process.env.API_ALLOWED_HOSTS || '*', + cookieSecure, + sameSite: sameSite as 'strict' | 'lax' | 'none', + }; +} +export function isNetworkOriginAllowed( + origin: string | undefined, + configured: string | undefined, + wildcard: boolean, +): boolean { + if (!origin) return false; + try { + const url = new URL(origin); + if (!['http:', 'https:'].includes(url.protocol) || url.origin !== origin) return false; + const list = (configured || '').split(',').map((s) => s.trim()); + return list.includes(origin) || (wildcard && list.includes('*')); + } catch { + return false; + } +} +export function isNetworkHostAllowed(host: string | undefined, configured: string): boolean { + if (!host) return false; + const list = configured.split(',').map((s) => s.trim().toLowerCase()); + return list.includes('*') || list.includes(host.toLowerCase()); +} diff --git a/apps/api/test/mcp-hosts.test.ts b/apps/api/test/mcp-hosts.test.ts new file mode 100644 index 0000000..d48ae43 --- /dev/null +++ b/apps/api/test/mcp-hosts.test.ts @@ -0,0 +1,30 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { isAllowedMcpHost } from '../src/mcp/hosts'; + +const resource = new URL('https://worthpath.example/mcp'); + +test('default Host protection preserves canonical and development hosts', () => { + assert.equal(isAllowedMcpHost('worthpath.example', resource, undefined, true), true); + const local = new URL('http://localhost:3100/mcp'); + assert.equal(isAllowedMcpHost('127.0.0.1:3100', local, undefined, false), true); + assert.equal(isAllowedMcpHost('localhost:3100', local, '', false), true); + assert.equal(isAllowedMcpHost('127.0.0.1:3100', local, '', true), false); + assert.equal(isAllowedMcpHost('198.18.0.1:3100', resource, undefined, false), false); +}); + +test('explicit Host list replaces defaults and matches exact ports', () => { + const configured = ' 198.18.0.1:3100, HOST.example:3100, [::1]:3100 '; + for (const host of ['198.18.0.1:3100', 'host.example:3100', '[::1]:3100']) + assert.equal(isAllowedMcpHost(host, resource, configured, true), true); + for (const host of ['198.18.0.1:3101', 'worthpath.example', 'host.example:3100.evil']) + assert.equal(isAllowedMcpHost(host, resource, configured, true), false); +}); + +test('wildcard allows all hosts but still rejects a missing Host', () => { + for (const host of ['198.18.0.1:3100', '192.168.1.2:3100', '[2001:db8::1]:3100', 'example.com']) + assert.equal(isAllowedMcpHost(host, resource, '*', false), true); + assert.equal(isAllowedMcpHost(undefined, resource, '*', false), false); + assert.equal(isAllowedMcpHost('', resource, '*', false), false); + assert.equal(isAllowedMcpHost('example.com', resource, '*.example.com', false), false); +}); diff --git a/apps/api/test/mcp-http.test.ts b/apps/api/test/mcp-http.test.ts new file mode 100644 index 0000000..395e535 --- /dev/null +++ b/apps/api/test/mcp-http.test.ts @@ -0,0 +1,69 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { AgentOAuth, urls, webLink } from '../src/mcp/oauth'; + +test('HTTP resources, web links and client redirects work beyond loopback in development', async () => { + const before = { ...process.env }; + try { + process.env.NODE_ENV = 'development'; + process.env.MCP_PUBLIC_URL = 'http://192.0.2.10:3100/mcp'; + process.env.MCP_WEB_URL = 'http://192.0.2.10:5173'; + assert.equal(urls().issuer.href, 'http://192.0.2.10:3100/'); + assert.equal(new URL(webLink('agent_authorization', 'test-id')).pathname, '/agent/authorize'); + const db: any = { agentClient: { count: async () => 0, create: async () => ({}) } }; + const oauth = new AgentOAuth(db); + await oauth.clientsStore.registerClient({ + redirect_uris: ['http://192.0.2.20:47891/callback'], + token_endpoint_auth_method: 'none', + }); + process.env.MCP_PUBLIC_URL = 'ftp://192.0.2.10/mcp'; + assert.throws(() => urls(), /HTTPS/); + process.env.MCP_PUBLIC_URL = 'http://192.0.2.10:3100/mcp'; + process.env.MCP_WEB_URL = 'file:///tmp/test'; + assert.throws(() => urls(), /HTTPS/); + await assert.rejects(() => + oauth.clientsStore.registerClient({ + redirect_uris: ['file:///tmp/test'], + token_endpoint_auth_method: 'none', + }), + ); + } finally { + for (const k of ['NODE_ENV', 'MCP_PUBLIC_URL', 'MCP_WEB_URL']) { + if (before[k] === undefined) delete process.env[k]; + else process.env[k] = before[k]; + } + } +}); + +test('production requires HTTPS for configured endpoints and rejects non-loopback HTTP callbacks', async () => { + const before = { ...process.env }; + try { + process.env.NODE_ENV = 'production'; + process.env.MCP_PUBLIC_URL = 'http://192.0.2.10:3100/mcp'; + process.env.MCP_WEB_URL = 'https://worthpath.example'; + assert.throws(() => urls(), /HTTPS/); + process.env.MCP_PUBLIC_URL = 'https://api.worthpath.example/mcp'; + process.env.MCP_WEB_URL = 'http://192.0.2.10:5173'; + assert.throws(() => urls(), /HTTPS/); + process.env.MCP_WEB_URL = 'https://worthpath.example'; + assert.equal(urls().web.protocol, 'https:'); + const oauth = new AgentOAuth({ + agentClient: { count: async () => 0, create: async () => ({}) }, + } as any); + await assert.rejects(() => + oauth.clientsStore.registerClient({ + redirect_uris: ['http://192.0.2.20:47891/callback'], + token_endpoint_auth_method: 'none', + }), + ); + await oauth.clientsStore.registerClient({ + redirect_uris: ['http://127.0.0.1:47891/callback'], + token_endpoint_auth_method: 'none', + }); + } finally { + for (const k of ['NODE_ENV', 'MCP_PUBLIC_URL', 'MCP_WEB_URL']) { + if (before[k] === undefined) delete process.env[k]; + else process.env[k] = before[k]; + } + } +}); diff --git a/apps/api/test/mcp.test.ts b/apps/api/test/mcp.test.ts index 89916cc..5bd80e9 100644 --- a/apps/api/test/mcp.test.ts +++ b/apps/api/test/mcp.test.ts @@ -1,3 +1,4 @@ +import { networkConfig, isNetworkOriginAllowed } from '../src/network'; import 'dotenv/config'; import { test } from 'node:test'; import assert from 'node:assert/strict'; @@ -452,7 +453,7 @@ test('official SDK Streamable HTTP: real MySQL full business paths, drafts, isol 401, ); const originDenied = await fetch(resource, { - method: 'POST', + method: 'OPTIONS', headers: { Origin: 'https://evil.invalid', Authorization: 'Bearer ' + a.token, @@ -460,7 +461,13 @@ test('official SDK Streamable HTTP: real MySQL full business paths, drafts, isol }, body: '{}', }); - assert.equal(originDenied.status, 403); + const originAllowed = isNetworkOriginAllowed( + 'https://evil.invalid', + process.env.MCP_ALLOWED_ORIGINS || + new URL(process.env.MCP_WEB_URL || 'http://localhost:5173').origin, + networkConfig().allowWildcardOrigins, + ); + assert.equal(originDenied.status, originAllowed ? 204 : 403); } finally { for (const c of clients) await c.close().catch(() => {}); await db.user.deleteMany({ where: { id: { in: users } } }); diff --git a/apps/api/test/network.test.ts b/apps/api/test/network.test.ts new file mode 100644 index 0000000..2c2ff1f --- /dev/null +++ b/apps/api/test/network.test.ts @@ -0,0 +1,55 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { networkConfig, isNetworkOriginAllowed, isNetworkHostAllowed } from '../src/network'; +test('network switches accept explicit deployment policy and validate cookie requirements', () => { + const keys = [ + 'NODE_ENV', + 'NETWORK_ALLOW_HTTP', + 'NETWORK_ALLOW_HTTP_REDIRECTS', + 'NETWORK_ALLOW_WILDCARD_ORIGINS', + 'NETWORK_REQUIRE_SECURE_COOKIE', + 'NETWORK_HSTS', + 'NETWORK_UPGRADE_INSECURE_REQUESTS', + 'COOKIE_SECURE', + 'COOKIE_SAME_SITE', + ]; + const before = { ...process.env }; + try { + for (const k of keys) delete process.env[k]; + process.env.NODE_ENV = 'production'; + assert.equal(networkConfig().allowHttp, false); + assert.equal(networkConfig().requireSecureCookie, true); + process.env.NETWORK_ALLOW_HTTP = 'true'; + process.env.NETWORK_ALLOW_WILDCARD_ORIGINS = 'true'; + process.env.NETWORK_ALLOW_HTTP_REDIRECTS = 'true'; + assert.equal(networkConfig().allowHttp, true); + assert.equal(networkConfig().allowWildcardOrigins, true); + assert.equal(networkConfig().allowHttpRedirects, true); + process.env.COOKIE_SECURE = 'false'; + process.env.COOKIE_SAME_SITE = 'none'; + assert.throws(() => networkConfig(), /requires/); + process.env.COOKIE_SAME_SITE = 'strict'; + process.env.NETWORK_ALLOW_HTTP = 'typo'; + assert.throws(() => networkConfig(), /true or false/); + } finally { + for (const k of keys) { + if (before[k] === undefined) delete process.env[k]; + else process.env[k] = before[k]; + } + } +}); +test('Host and Origin lists accept all networks or enforce exact configured entries', () => { + assert.equal(isNetworkOriginAllowed('http://192.0.2.15:5173', '*', true), true); + assert.equal(isNetworkOriginAllowed('https://elsewhere.example', '*', false), false); + assert.equal(isNetworkOriginAllowed('null', '*', true), false); + assert.equal(isNetworkOriginAllowed('file:///tmp', '*', true), false); + assert.equal( + isNetworkOriginAllowed('http://a.example', 'http://a.example, https://b.example', false), + true, + ); + assert.equal(isNetworkOriginAllowed('http://a.example.evil', 'http://a.example', false), false); + assert.equal(isNetworkHostAllowed('192.0.2.15:3100', '*'), true); + assert.equal(isNetworkHostAllowed('example.com:3100', 'example.com:3100'), true); + assert.equal(isNetworkHostAllowed('example.com:3101', 'example.com:3100'), false); + assert.equal(isNetworkHostAllowed(undefined, '*'), false); +}); diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 3803d4f..9a2adb6 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,6 +1,22 @@ -import { defineConfig } from 'vite'; +import { defineConfig, loadEnv } from 'vite'; import react from '@vitejs/plugin-react'; -export default defineConfig({ - plugins: [react()], - server: { port: 5173, proxy: { '/api': 'http://127.0.0.1:3100' } }, +import { resolve } from 'node:path'; +export default defineConfig(({ mode }) => { + const env = { ...loadEnv(mode, resolve(__dirname, '../api'), ''), ...process.env }; + const allowed = env.WEB_ALLOWED_HOSTS || '*'; + return { + plugins: [react()], + server: { + host: env.WEB_HOST || '0.0.0.0', + port: Number(env.WEB_PORT || 5173), + allowedHosts: + allowed.trim() === '*' + ? true + : allowed + .split(',') + .map((v) => v.trim()) + .filter(Boolean), + proxy: { '/api': env.WEB_API_PROXY || 'http://127.0.0.1:3100' }, + }, + }; }); diff --git a/docs/mcp.md b/docs/mcp.md index 01ac32f..1fec3fb 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -28,7 +28,7 @@ OAuth 使用发现元数据、动态注册的公开客户端、授权码、S256 ## 页面复制提示词配置 -在「设置与备份 → 连接 Agent」复制实际 MCP 地址和“Agent 使用教程”。支持远程 OAuth 的客户端填写此地址,选择 Streamable HTTP;在 WorthPath 网页登录,审核客户端名称、回调地址、资源,并选择连接权限。网页会回到已注册的精确回调地址并保留 state。客户端自行验证 state。 +在「设置与备份 → 连接 Agent → 开始连接」选择申请权限(默认 read,draft)及是否申请隐藏账户读取、修改权限(默认关闭),点击「复制 Codex 配置提示词」,发送给运行在本机、能够执行命令的 Codex。提示词明确授权它仅配置 worthpath 连接、使用 OAuth、保留其他配置并核对实际工具加载;命令由 Codex 执行,用户无需手动输入。用户仍须完成 WorthPath 网页登录和权限确认,当前会话无法加载新增工具时须重启客户端或新建会话。提示词不包含个人令牌,也不会修改账目。 权限选择只改变申请内容,不会立即改变现有连接授权;最终以网页确认和 connection_info 为准。这是可执行的配置指令,不是浏览器一键注册客户端;客户端缺少本机执行能力时需如实说明限制。 @@ -107,11 +107,12 @@ cd E:\WorthPath | 环境变量 | 本地示例 | 用途 | | ------------------- | ------------------------- | ------------------------------------ | -| MCP_PUBLIC_URL | http://localhost:3100/mcp | 规范资源 URL,路径必须 /mcp | +| MCP_PUBLIC_URL | http://localhost:3100/mcp | 连接页显示/复制地址与提示词、OAuth 规范资源 URL,路径必须 /mcp | +| MCP_ALLOWED_HOSTS | 留空 | Host 白名单(含端口),逗号分隔;`*` 允许所有 IP 和域名 | | MCP_WEB_URL | http://localhost:5173 | 网页授权及草稿确认入口 | | MCP_ALLOWED_ORIGINS | http://localhost:5173 | 精确 Origin 列表,逗号分隔,无通配符 | -本次没有新增环境变量。保留本机 .env;示例不含凭据。生产必须 HTTPS、COOKIE_SECURE=true、准确 WEB_ORIGIN。Host 匹配规范资源;带 Origin 的请求匹配白名单,无 Origin 的非浏览器客户端允许接入。不得用任意转发头构造授权地址。 +上线时应通过网络开关启用 HTTPS、Secure Cookie 并配置准确 WEB_ORIGIN;所有策略现由 apps/api/.env 控制。`MCP_ALLOWED_HOSTS` 未设置或留空时保留原有规则:允许规范资源 Host,非生产额外允许 localhost 和 127.0.0.1 对应端口。非空白名单替代默认列表,例如 `localhost:3100,127.0.0.1:3100,198.18.0.1:3100`;设置 `MCP_ALLOWED_HOSTS=*` 允许所有 IP 和域名,关闭 Host 防护,建议通过防火墙限制可访问的网络。修改 .env 后重启 API。该配置不改变 HTTPS 校验、OAuth 规范资源 URL、Origin 白名单或 Bearer 认证;MCP_PUBLIC_URL 和 MCP_WEB_URL 是否允许 HTTP 由 NETWORK_ALLOW_HTTP 控制。带 Origin 的请求匹配白名单,无 Origin 的非浏览器客户端允许接入。不得用任意转发头构造授权地址。 服务采用无状态 Streamable HTTP,请求结束关闭 transport/server;不共享用户认证上下文,不支持长期 GET SSE 或持久会话。代理转发规范 Host,禁用 MCP 响应缓冲,超时建议 330 秒;同时代理 /.well-known/、/authorize、/token、/register、/revoke 和 /api/。网站备份上传上限 512 MiB,ZIP 解压总计 1 GiB,代理配置对应 body 限制。日志不能保存 Authorization、密码、令牌或上传正文。 @@ -132,3 +133,11 @@ cd E:\WorthPath ### 独立审核页面与草稿预览 OAuth 审核使用 /agent/authorize,草稿确认使用 /agent/operation;旧查询参数链接仍兼容。审核页显示申请范围、最终权限与授权期限。草稿按中文字段展示账户名称、金额、日期、备注及已有记录,用户确认后执行;过期或状态发生变化时不能提交。能力页面支持搜索,技术说明默认折叠。 + +### 局域网 HTTP 接入 + +NETWORK_ALLOW_HTTP=true 时支持配置的 HTTP MCP 服务和网页审核地址;NETWORK_ALLOW_HTTP_REDIRECTS 控制非本机 HTTP 回调。OAuth SDK 同步使用 HTTP 开关,继续执行 Bearer、PKCE 和精确回调匹配。服务地址与网页地址应配置为客户端能访问的地址,修改配置后重启 API。 + +### 统一网络配置 + +所有网络策略及中文说明见 apps/api/.env.example。API 与 Vite 网页开发服务共同读取 apps/api/.env,修改后重启。当前 .env 使用全网卡监听、Host/Origin 通配符、HTTP 和 HTTP 回调,Cookie 保持 HttpOnly 与 SameSite=strict。NETWORK_ALLOW_WILDCARD_ORIGINS 控制是否允许来源通配符;API_ALLOWED_HOSTS、MCP_ALLOWED_HOSTS 与 WEB_ALLOWED_HOSTS 分别控制各入口。限流开关、窗口和阈值也在环境文件中。详见 docs/network-settings.md。 diff --git a/docs/network-settings.md b/docs/network-settings.md new file mode 100644 index 0000000..c3be596 --- /dev/null +++ b/docs/network-settings.md @@ -0,0 +1,26 @@ +# 网络访问配置 + +API 和网页开发服务共同读取 [环境配置模板](../apps/api/.env.example),模板中每个变量都有中文说明。真实配置位于 apps/api/.env,已被 Git 忽略,不提交数据库连接或密码。 + +当前配置允许所有本机 IPv4 网卡、任意请求 Host 和有效的 HTTP/HTTPS 浏览器来源,并允许 HTTP MCP 服务及 HTTP 客户端回调。OAuth 的资源地址和网页授权地址仍使用配置的固定地址;远程客户端需要能访问该地址。登录、令牌、用户隔离及权限检查继续生效。 + +| 配置范围 | 变量 | +| --- | --- | +| API 监听与主机 | API_HOST、PORT、API_ALLOWED_HOSTS | +| 网页开发服务 | WEB_HOST、WEB_PORT、WEB_ALLOWED_HOSTS、WEB_API_PROXY | +| 网站来源 | WEB_ORIGIN、NETWORK_ALLOW_WILDCARD_ORIGINS | +| MCP 地址与来源 | MCP_PUBLIC_URL、MCP_WEB_URL、MCP_ALLOWED_HOSTS、MCP_ALLOWED_ORIGINS、MCP_ALLOW_LOOPBACK_HOSTS | +| HTTP 和回调 | NETWORK_ALLOW_HTTP、NETWORK_ALLOW_HTTP_REDIRECTS | +| Cookie | COOKIE_SECURE、COOKIE_SAME_SITE、NETWORK_REQUIRE_SECURE_COOKIE | +| HTTPS 响应策略 | NETWORK_HSTS、NETWORK_UPGRADE_INSECURE_REQUESTS | +| 请求限流 | NETWORK_RATE_LIMIT_ENABLED、NETWORK_RATE_LIMIT_WINDOW_MS、NETWORK_AUTH_RATE_LIMIT_MAX、MCP_AUTH_RATE_LIMIT_MAX | + +## 上线调整 + +将 NODE_ENV 改为 production,按实际域名设置 Host/Origin 白名单和 HTTPS 固定地址;关闭 HTTP、非本机 HTTP 回调及来源通配符,启用 Secure Cookie、HSTS、HTTPS 子资源升级。完整配置说明在环境模板中。显式环境开关优先于 NODE_ENV,修改环境名不会覆盖已设置的开放开关。 + +HTTP 使用 COOKIE_SECURE=false;COOKIE_SAME_SITE=none 必须使用 Secure Cookie。同站网页经 /api 代理访问保持 strict 即可。API 和网页开发服务都需重启才能读取新值,生产静态页面的访问端口由反向代理或部署服务配置。公网能否连接仍取决于路由、反向代理和防火墙;本次未修改这些系统设置。 + +## 验证 + +生产构建通过,43 项单元测试与 6 项真实 HTTP OAuth/MCP 测试通过。当前 HTTP 服务的 OAuth 发现返回 200,任意测试 Host/Origin 的 API 和 MCP 预检均返回 204。测试使用独立服务与临时账号,未修改真实用户账目。 diff --git a/docs/update-agent-accounts-2026-10-04.md b/docs/update-agent-accounts-2026-10-04.md index be1d1f9..abfb10d 100644 --- a/docs/update-agent-accounts-2026-10-04.md +++ b/docs/update-agent-accounts-2026-10-04.md @@ -18,3 +18,5 @@ 最终验证:39 项单元测试(含工作区已有的 Host 配置测试)、20 项真实 MySQL 集成测试、6 项 OAuth/MCP 测试通过;生产构建与类型检查通过,25 项数据库迁移全部应用。 复验时当前环境的非本机 HTTP MCP 地址触发既有 HTTPS 校验。验证临时使用 localhost,未修改 .env;使用该非本机地址前仍须配置 HTTPS。 + +后续更新(2026-10-04 11:25,UTC+8):HTTP 限制已改为环境配置,当前全网络开放配置及上线调整见 docs/network-settings.md。 diff --git a/update.md b/update.md index 0768366..68a3085 100644 --- a/update.md +++ b/update.md @@ -77,3 +77,7 @@ - ~~优化MCP页面。当前的有点混乱,技能描述也是,进行大的修改~~ — 已完成并验证:2026-10-04 11:03(UTC+8),见 docs/update-agent-accounts-2026-10-04.md - ~~使用OAuth也要可以设置授权的时间(有固定的选项1 3 7 30day 1yaer 永久)~~ — 已完成并验证:2026-10-04 11:03(UTC+8),见 docs/update-agent-accounts-2026-10-04.md - ~~连接助手可以设置申请隐藏账号权限~~ — 已完成并验证:2026-10-04 11:03(UTC+8),见 docs/update-agent-accounts-2026-10-04.md + +- ~~允许 HTTP 正常访问~~ — 已完成并验证:2026-10-04 11:25(UTC+8) +- ~~将网络安全配置提取到 .env,当前允许所有网络地址访问,后续上线通过配置收紧~~ — 已完成并验证:2026-10-04 11:25(UTC+8),见 docs/network-settings.md +- ~~在 .env.example 用中文介绍每个配置项的作用~~ — 已完成:2026-10-04 11:25(UTC+8)