feat: configure network access policies through environment

This commit is contained in:
陈煜 committed 2026-10-04 11:27:34 +08:00
1 parent 91365ee315
commit 265f28e16d
17 files changed
+456 -58

No files matched your search

+82 -7
View File
@@ -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
+1 -1
View File
@@ -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",
+12 -15
View File
@@ -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<string, { count: number; until: number }>();
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('请求来源不受信任');
+26 -4
View File
@@ -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(() => {
+16
View File
@@ -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());
}
+7 -10
View File
@@ -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');
+28 -12
View File
@@ -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;
}
+57
View File
@@ -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());
}
+30
View File
@@ -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);
});
+69
View File
@@ -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];
}
}
});
+9 -2
View File
@@ -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 } } });
+55
View File
@@ -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);
});
+20 -4
View File
@@ -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' },
},
};
});
+12 -3
View File
@@ -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。
+26
View File
@@ -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。测试使用独立服务与临时账号,未修改真实用户账目。
+2
View File
@@ -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。
+4
View File
@@ -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)