feat: allow wildcard web origins for local tunnel testing

This commit is contained in:
陈煜 committed 2026-10-01 17:58:28 +08:00
1 parent 2a650853ee
commit e4f4ff42e1
9 files changed
+53 -8

No files matched your search

+2
View File
@@ -40,3 +40,5 @@ pnpm check:staged # git add 后,提交前检查本地凭证和禁止提交的
设计见 [数据模型与结构](docs/architecture.md),已验证范围与后续工作见 [首版验收](docs/acceptance.md)。生产需要 HTTPS、COOKIE_SECURE=true、WEB_ORIGIN 为准确站点地址;前端构建由反向代理托管并代理 /api。API 生产启动:在 `apps/api` 中执行 `node dist/main.js`。生产认证限速需迁移到共享存储,数据库用户需最小权限,部署与运行监控尚未配置。
备份下载为 ZIP:manifest、settings、currencies、accounts、assets、debts、history、links、rates 各一个 JSON 文件,包含隐藏项目及全部历史,不含密码或会话凭据。导入先上传并预览,再确认追加;旧 JSON 备份继续兼容。不限制项目、历史、关联或汇率条数;上传文件最大 512 MB,ZIP 解压总计最大 1 GB,用于保护服务器资源。
内网穿透临时测试:本地 `apps/api/.env` 可设置 `WEB_ORIGIN=*` 并重启 API,允许来自任意 HTTP/HTTPS 站点的浏览器写入请求,仍需登录身份并校验数据归属。缺失或无效 Origin 仍拒绝;`NODE_ENV=production` 禁止此通配符。测试结束后恢复准确的站点地址。
+1
View File
@@ -1,4 +1,5 @@
DATABASE_URL="mysql://USER:PASSWORD@HOST:3306/worthpath"
PORT=3100
# Local tunnel testing may use WEB_ORIGIN=*. Production requires an exact origin.
WEB_ORIGIN=http://localhost:5173
COOKIE_SECURE=false
+19 -1
View File
@@ -22,6 +22,20 @@ import { credentials } from './validation';
export type UserRequest = Request & { userId: string; sessionId: string; revealed: boolean };
const Public = () => SetMetadata('public', true);
const digest = (s: string) => createHash('sha256').update(s).digest('hex');
export function allowedOrigin(
origin: string | undefined,
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;
}
}
@Injectable()
export class AuthService {
private attempts = new Map<string, { count: number; until: number }>();
@@ -87,7 +101,11 @@ export class AuthGuard implements CanActivate {
const req = ctx.switchToHttp().getRequest<UserRequest>();
if (
!['GET', 'HEAD', 'OPTIONS'].includes(req.method) &&
req.headers.origin !== process.env.WEB_ORIGIN
!allowedOrigin(
req.headers.origin,
process.env.WEB_ORIGIN,
process.env.NODE_ENV === 'production',
)
)
throw new ForbiddenException('请求来源不受信任');
if (this.reflector.get<boolean>('public', ctx.getHandler())) return true;
+2
View File
@@ -48,6 +48,8 @@ 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 === '*')
throw Error('Production requires an explicit web origin');
if (process.env.NODE_ENV === 'production' && process.env.COOKIE_SECURE !== 'true')
throw Error('Production requires secure cookies');
const app = await NestFactory.create(AppModule, { logger: false, bodyParser: false });
+16
View File
@@ -6,6 +6,22 @@ import { positionInput, date, amount, businessDate, toBusinessDate } from '../sr
import { validateBackup } from '../src/backup';
import { RatesService } from '../src/rates';
import { Database } from '../src/database';
import { allowedOrigin } from '../src/auth';
test('development wildcard accepts HTTP origins while exact and production checks stay strict', () => {
assert.equal(allowedOrigin('https://tunnel.example', '*'), true);
assert.equal(allowedOrigin('http://example.test:8080', '*'), true);
for (const origin of [
undefined,
'null',
'file://host',
'https://example.test/path',
'https://name:secret@example.test',
])
assert.equal(allowedOrigin(origin, '*'), false);
assert.equal(allowedOrigin('https://tunnel.example', '*', true), false);
assert.equal(allowedOrigin('https://tunnel.example', 'http://localhost:5173'), false);
assert.equal(allowedOrigin('http://localhost:5173', 'http://localhost:5173'), true);
});
const rev = (amount: string, day: string) => ({
id: randomUUID(),
amount,
+3 -3
View File
@@ -6,7 +6,7 @@ import { PrismaClient } from '@prisma/client';
import { today } from '../src/validation';
import { readBackupZip } from '../src/zip';
const base = process.env.TEST_API_URL || 'http://127.0.0.1:3100/api',
origin = process.env.WEB_ORIGIN!;
origin = process.env.WEB_ORIGIN === '*' ? 'http://localhost:5173' : process.env.WEB_ORIGIN!;
test('real MySQL: authentication, isolation, history, backup and atomic failures', async () => {
const db = new PrismaClient(),
created: { id: string; username: string }[] = [];
@@ -53,10 +53,10 @@ test('real MySQL: authentication, isolation, history, backup and atomic failures
'Content-Type': 'application/json',
Origin: 'https://untrusted.invalid',
},
body: JSON.stringify({ baseCurrency: 'USD' }),
body: JSON.stringify({ showSidebar: true }),
})
).status,
403,
process.env.WEB_ORIGIN === '*' ? 200 : 403,
);
const make = async (
kind: string,
+6 -2
View File
@@ -12,7 +12,7 @@ test('privacy, minute history, backup-gated clear and idle sessions remain user
const res = await fetch(base + path, {
method,
headers: {
Origin: process.env.WEB_ORIGIN!,
Origin: process.env.WEB_ORIGIN === '*' ? 'http://localhost:5173' : process.env.WEB_ORIGIN!,
Cookie: cookie,
...(data ? { 'Content-Type': 'application/json' } : {}),
},
@@ -131,7 +131,11 @@ test('privacy, minute history, backup-gated clear and idle sessions remain user
form.append('file', new Blob([content]), 'backup.zip');
const res = await fetch(base + '/backup/upload', {
method: 'POST',
headers: { Origin: process.env.WEB_ORIGIN!, Cookie: cookie },
headers: {
Origin:
process.env.WEB_ORIGIN === '*' ? 'http://localhost:5173' : process.env.WEB_ORIGIN!,
Cookie: cookie,
},
body: form,
});
return { status: res.status, data: await res.json() };
+3 -1
View File
@@ -5,7 +5,7 @@
已验证:
- 后端和前端类型检查、生产构建通过。
- 13 项单元测试通过:Decimal 精度、负债符号、缺失汇率、历史更正、汇率/余额归因、大额金额、输入和备份校验、固定公共汇率请求与失败保护。
- 14 项单元测试通过:Decimal 精度、负债符号、缺失汇率、历史更正、汇率/余额归因、大额金额、输入和备份校验、固定公共汇率请求与失败保护。
- 真实 MySQL 集成测试通过:注册、密码哈希、登录退出、会话失效、同源写入限制、双用户资源与历史隔离、客户端 userId 拒绝、信用卡负债、债务关联不重复求和、余额变化、同日多次更新、更正、还款金额和负债类型校验、归档金额编辑限制。
- 备份不含认证数据;预览验证、明确确认、恢复后 ID 重建与关联保留、重复导入拒绝、已导入项目修改后重复导入仍拒绝、两请求并发导入仅一次成功,失败没有留下部分项目。
- 四次可追踪迁移已应用,数据库结构处于最新状态,未运行 reset/db push。
@@ -31,3 +31,5 @@
- 浏览器已验证隐藏账户、密码解锁、分钟展示、侧栏隐藏后的手机导航、真实备份下载及清空下一步、一分钟无操作退出;未对实际用户执行清空操作。
- ZIP 分文件内容及摘要校验、损坏/缺失/未知文件拒绝、真实上传与当前会话令牌隔离、确认后恢复已通过。超过 1000 个项目、单项目 10000 条历史、总计 20000 条历史的校验通过。未做 512 MB 边界和超大规模恢复压力测试。
- 手动汇率写入 API 返回 404,界面入口已移除;已有历史汇率未删除。
内网穿透测试更新:支持本地 `WEB_ORIGIN=*`;HTTP/HTTPS 来源探测均通过来源校验,缺失或 null 来源拒绝。精确来源模式及生产禁止通配符的单元校验通过。此轮 14 项单元测试、2 项数据库集成测试、类型检查和构建通过;未执行外网穿透链路端到端验收。
+1 -1
View File
@@ -8,7 +8,7 @@ Revision 保存按业务日期生效的绝对金额,每次金额更新新增
趋势按日期重放金额和当时可用汇率。汇率导致的变化和余额导致的变化分别归因;没有可用汇率时总额标记不完整,绝不默认为 1。归档项目仍参与统计,归档仅停止编辑,避免归档导致财富凭空消失。
认证采用 bcrypt 密码哈希和 HttpOnly 随机会话 Cookie;数据库只保存会话令牌 SHA-256 摘要。所有资源查询由会话用户范围限定。写入要求同源 Origin,登录限速;生产必须 HTTPS 并启用安全 Cookie。
认证采用 bcrypt 密码哈希和 HttpOnly 随机会话 Cookie;数据库只保存会话令牌 SHA-256 摘要。所有资源查询由会话用户范围限定。写入默认要求准确的 WEB_ORIGIN;本地穿透测试允许设置 `WEB_ORIGIN=*` 接受任意合法 HTTP/HTTPS 来源(仍拒绝缺失来源),生产模式禁止通配符。登录限速;生产必须 HTTPS 并启用安全 Cookie。
备份采用 version=3 ZIP(多个可读 JSON 文件),包含项目、历史、关系、币种、本位币、汇率和导入来源,不包含用户认证数据。首版导入只追加完整的新项目并重映射 ID;项目 ID 和 importedFromId 识别重复,用户 + importedFromId 有唯一索引,项目修改后仍拒绝原备份重复导入;不同 ID 的同名项目允许共存。汇率冲突拒绝;已有本位币不自动更改,空空间恢复备份本位币。完整验证后以 Serializable 事务写入,不修改已有项目。总览和导出使用数据库事务读取一致的数据视图。