feat: allow wildcard web origins for local tunnel testing
This commit is contained in:
1 parent
2a650853ee
commit
e4f4ff42e1
9 files changed
+53
-8
No files matched your search
@@ -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,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
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 项数据库集成测试、类型检查和构建通过;未执行外网穿透链路端到端验收。
|
||||
@@ -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 事务写入,不修改已有项目。总览和导出使用数据库事务读取一致的数据视图。
|
||||
|
||||
|
||||
Reference in new issue
Block a user