feat: configure network access policies through environment
This commit is contained in:
1 parent
91365ee315
commit
265f28e16d
17 files changed
+456
-58
No files matched your search
+82
-7
@@ -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
|
||||
@@ -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
@@ -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
@@ -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(() => {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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 } } });
|
||||
|
||||
@@ -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
@@ -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
@@ -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。
|
||||
@@ -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。测试使用独立服务与临时账号,未修改真实用户账目。
|
||||
@@ -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。
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user