From 053f6a4f3bb5f7b9f42fc499828faeeebbdfe3ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=9F=E6=9E=97=E9=93=96?= Date: Sat, 7 Nov 2026 13:17:39 +0800 Subject: [PATCH] fix(salary): trust configured origins behind reverse proxies --- .../positions/route.test.ts | 12 ++++ .../personnel-salary/route.test.ts | 13 ++++ .../PersonnelSalaryPanel.test.tsx | 31 +++++++++ .../PersonnelSalaryPanel.tsx | 8 ++- lib/__tests__/personnel-salary-http.test.ts | 69 +++++++++++++++++++ lib/personnel-salary-http.ts | 3 +- 6 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 lib/__tests__/personnel-salary-http.test.ts diff --git a/app/api/organization/performance-salary/positions/route.test.ts b/app/api/organization/performance-salary/positions/route.test.ts index 64e46bbc..ab7a0814 100644 --- a/app/api/organization/performance-salary/positions/route.test.ts +++ b/app/api/organization/performance-salary/positions/route.test.ts @@ -46,3 +46,15 @@ test('跨站写拒绝;错误权重不进事务', async () => { expect((await POST(req('POST', { name: '', dimensions: [] }))).status).toBe(400) expect(prisma.$transaction).not.toHaveBeenCalled() }) +test.each(['same-origin', 'cross-site'])('代理后岗位新增、编辑、删除按%s来源校验,拒绝时不写库', async site => { + user.mockResolvedValue({ id: 'admin', isActive: true, status: 'active', archivedAt: null, isSuperAdmin: true }) + const proxied = (method: string) => new NextRequest('https://0.0.0.0:3000/api/organization/performance-salary/positions', { + method, headers: { origin: 'https://sc.jiansun.vip', host: 'sc.jiansun.vip', 'x-forwarded-proto': 'https', 'sec-fetch-site': site }, body: '{}', + }) + const responses = await Promise.all([POST(proxied('POST')), PUT(proxied('PUT'), { params: { id: 'id' } }), DELETE(proxied('DELETE'), { params: { id: 'id' } })]) + for (const response of responses) { + expect(response.status).toBe(site === 'same-origin' ? 400 : 403) + expect(response.headers.get('cache-control')).toBe('private, no-store, max-age=0') + } + expect(prisma.$transaction).not.toHaveBeenCalled() +}) diff --git a/app/api/organization/personnel-salary/route.test.ts b/app/api/organization/personnel-salary/route.test.ts index 636ccd4a..0afe3857 100644 --- a/app/api/organization/personnel-salary/route.test.ts +++ b/app/api/organization/personnel-salary/route.test.ts @@ -48,3 +48,16 @@ test('跨站写入拒绝,合法超管的非法金额返回字段错误且不 expect((await invalid.json()).fields.baseSalary).toBeTruthy() expect(prisma.$transaction).not.toHaveBeenCalled() }) +test.each(['same-origin', 'cross-site'])('代理后人员新增、编辑、删除按%s来源校验,拒绝时不写库', async site => { + user.mockResolvedValue({ id: 'admin', isActive: true, status: 'active', archivedAt: null, isSuperAdmin: true }) + const proxied = (method: string) => new NextRequest('https://0.0.0.0:3000/api/organization/personnel-salary', { + method, headers: { origin: 'https://sc.jiansun.vip', host: 'sc.jiansun.vip', 'x-forwarded-proto': 'https', 'sec-fetch-site': site }, body: '{}', + }) + const responses = await Promise.all([POST(proxied('POST')), PUT(proxied('PUT'), { params: { id: 'id' } }), DELETE(proxied('DELETE'), { params: { id: 'id' } })]) + for (const response of responses) { + // Trusted requests reach body validation; cross-site requests stop before it. + expect(response.status).toBe(site === 'same-origin' ? 400 : 403) + expect(response.headers.get('cache-control')).toBe('private, no-store, max-age=0') + } + expect(prisma.$transaction).not.toHaveBeenCalled() +}) diff --git a/components/department-dashboard/PersonnelSalaryPanel.test.tsx b/components/department-dashboard/PersonnelSalaryPanel.test.tsx index dfeb0a7c..3bc91eb1 100644 --- a/components/department-dashboard/PersonnelSalaryPanel.test.tsx +++ b/components/department-dashboard/PersonnelSalaryPanel.test.tsx @@ -45,6 +45,20 @@ test('关闭人员弹窗后焦点回到仍存在的触发按钮', async () => { expect(screen.queryByRole('dialog')).not.toBeInTheDocument() expect(edit).toHaveFocus() }) +test('保存刷新移除原编辑按钮后,焦点回到人员区域标题', async () => { + render() + const edit = (await screen.findAllByRole('button', { name: '编辑测试甲的薪资基础' }))[0] + edit.focus() + fireEvent.click(edit) + let finishRefresh!: (value: ReturnType) => void + ;(fetch as jest.Mock).mockResolvedValueOnce(response({ ...row, revision: 2 })) + .mockImplementationOnce(() => new Promise(resolve => { finishRefresh = resolve })) + fireEvent.click(screen.getByRole('button', { name: '保存' })) + await waitFor(() => expect(edit).not.toBeInTheDocument()) + finishRefresh(response({ canEdit: true, records: [{ ...row, revision: 2 }] })) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + expect(screen.getByRole('heading', { name: '人员薪资基础部分' })).toHaveFocus() +}) test('并发冲突保留草稿,核对最新版本后才允许再次保存', async () => { render() fireEvent.click((await screen.findAllByRole('button', { name: '编辑测试甲的薪资基础' }))[0]) @@ -60,6 +74,23 @@ test('并发冲突保留草稿,核对最新版本后才允许再次保存', as await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) expect((fetch as jest.Mock).mock.calls.find(call => call[1]?.body?.includes('"revision":2'))).toBeTruthy() }) +test('来源拒绝保留人员草稿,恢复后可原样再次保存并显示成功反馈', async () => { + render() + fireEvent.click((await screen.findAllByRole('button', { name: '编辑测试甲的薪资基础' }))[0]) + fireEvent.change(screen.getByLabelText('基本工资 *'), { target: { value: '12.34' } }) + ;(fetch as jest.Mock).mockResolvedValueOnce(response({ error: '请求来源不受信任,请在本站重试。' }, 403)) + fireEvent.click(screen.getByRole('button', { name: '保存' })) + expect(await screen.findByRole('alert')).toHaveTextContent('请求来源不受信任,请在本站重试。') + expect(screen.getByLabelText('基本工资 *')).toHaveValue('12.34') + expect(screen.getByRole('button', { name: '保存' })).toBeEnabled() + ;(fetch as jest.Mock).mockResolvedValueOnce(response({ ...row, baseSalary: '12.34', revision: 2 })).mockResolvedValueOnce(response({ canEdit: true, records: [{ ...row, baseSalary: '12.34', revision: 2 }] })) + fireEvent.click(screen.getByRole('button', { name: '保存' })) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + expect(screen.getByRole('status')).toHaveTextContent('已保存') + const writes = (fetch as jest.Mock).mock.calls.filter(call => call[1]?.method === 'PUT') + expect(writes).toHaveLength(2) + expect(writes[1][1].body).toBe(writes[0][1].body) +}) test('表单金额错误阻止保存;删除需二次确认', async () => { render() fireEvent.click((await screen.findAllByRole('button', { name: '编辑测试甲的薪资基础' }))[0]) diff --git a/components/department-dashboard/PersonnelSalaryPanel.tsx b/components/department-dashboard/PersonnelSalaryPanel.tsx index 50b929ef..d41f160d 100644 --- a/components/department-dashboard/PersonnelSalaryPanel.tsx +++ b/components/department-dashboard/PersonnelSalaryPanel.tsx @@ -45,7 +45,13 @@ function Dialog({ title, children, onClose, busy = false }: { title: string; chi dialog.showModal() const previousOverflow = document.body.style.overflow document.body.style.overflow = 'hidden' - return () => { dialog.close(); document.body.style.overflow = previousOverflow; if (previousFocus.current?.isConnected) previousFocus.current.focus() } + return () => { + dialog.close() + document.body.style.overflow = previousOverflow + // Saving reloads the list, so its original edit button may have been removed. + const target = previousFocus.current?.isConnected ? previousFocus.current : document.getElementById('salary-base-title') + target?.focus() + } }, []) return { event.preventDefault(); if (!busy) closeRef.current() }} onClick={event => { if (event.target === event.currentTarget && !busy) closeRef.current() }} diff --git a/lib/__tests__/personnel-salary-http.test.ts b/lib/__tests__/personnel-salary-http.test.ts new file mode 100644 index 00000000..9ec43445 --- /dev/null +++ b/lib/__tests__/personnel-salary-http.test.ts @@ -0,0 +1,69 @@ +/** @jest-environment node */ +import { NextRequest } from 'next/server' +import { getServerSession } from 'next-auth' +import { prisma } from '@/lib/prisma' +import { salaryIdentity } from '@/lib/personnel-salary-http' + +jest.mock('next-auth', () => ({ getServerSession: jest.fn() })) +jest.mock('@/lib/auth', () => ({ authOptions: {} })) +jest.mock('@/lib/prisma', () => ({ prisma: { user: { findUnique: jest.fn() } } })) + +const session = getServerSession as jest.Mock +const user = prisma.user.findUnique as jest.Mock +const originKeys = ['NEXT_PUBLIC_APP_PUBLIC_URL', 'APP_PUBLIC_URL', 'NEXTAUTH_URL', 'CORS_ALLOWED_ORIGINS'] as const +const originalOrigins = Object.fromEntries(originKeys.map(key => [key, process.env[key]])) +const internalUrl = 'https://0.0.0.0:3000/api/organization/personnel-salary' +const request = (origin?: string, headers: Record = {}, url = internalUrl) => new NextRequest(url, { + method: 'POST', headers: { host: 'sc.jiansun.vip', 'x-forwarded-proto': 'https', ...(origin === undefined ? {} : { origin }), ...headers }, body: '{}', +}) + +beforeEach(() => { + jest.resetAllMocks() + process.env.NEXT_PUBLIC_APP_PUBLIC_URL = 'https://sc.jiansun.vip' + process.env.APP_PUBLIC_URL = 'https://sc.jiansun.vip' + process.env.NEXTAUTH_URL = 'https://sc.jiansun.vip' + process.env.CORS_ALLOWED_ORIGINS = 'https://sc.jiansun.vip,http://192.168.58.20:4320' + session.mockResolvedValue({ user: { id: 'admin' } }) + user.mockResolvedValue({ id: 'admin', isSuperAdmin: true, isActive: true, status: 'active', archivedAt: null }) +}) +afterAll(() => { + for (const key of originKeys) { + if (originalOrigins[key] === undefined) delete process.env[key] + else process.env[key] = originalOrigins[key] + } +}) + +test.each([ + ['公网代理', 'https://sc.jiansun.vip', {}, internalUrl], + ['显式默认端口', 'https://sc.jiansun.vip:443', {}, internalUrl], + ['已配置内网入口', 'http://192.168.58.20:4320', { host: '192.168.58.20:4320', 'x-forwarded-proto': 'http' }, internalUrl], + ['本地开发入口', 'http://localhost:4322', { host: 'localhost:4322', 'x-forwarded-proto': 'http' }, 'http://localhost:4322/api/organization/personnel-salary'], +] as [string, string, Record, string][])('%s允许合法写入来源,不使用内部监听地址判断', async (_name, origin, headers, url) => { + await expect(salaryIdentity(true, request(origin, { 'sec-fetch-site': 'same-origin', ...headers }, url))).resolves.toBe('admin') + expect(user).toHaveBeenCalledWith(expect.objectContaining({ where: { id: 'admin' } })) +}) + +test.each(['https://foreign.example', 'https://sc.jiansun.vip.foreign.example', 'null', 'not-a-url', 'https://sc.jiansun.vip/path', 'https://user:password@sc.jiansun.vip'])('拒绝陌生或非法来源%s', async origin => { + await expect(salaryIdentity(true, request(origin))).rejects.toMatchObject({ status: 403, message: '请求来源不受信任,请在本站重试。' }) +}) + +test.each([undefined, 'https://sc.jiansun.vip', 'http://192.168.58.20:4320'])('cross-site即使来源%s在白名单内也拒绝', async origin => { + await expect(salaryIdentity(true, request(origin, { 'sec-fetch-site': 'cross-site' }))).rejects.toMatchObject({ status: 403 }) +}) + +test('没有Origin的非浏览器请求沿用当前权限校验', async () => { + await expect(salaryIdentity(true, request())).resolves.toBe('admin') +}) + +test('可信来源仍拒绝未登录用户', async () => { + session.mockResolvedValue(null) + await expect(salaryIdentity(true, request('https://sc.jiansun.vip'))).rejects.toMatchObject({ status: 401 }) + expect(user).not.toHaveBeenCalled() +}) + +test('可信来源仍根据数据库当前权限拒绝只读账号和已撤销的超管', async () => { + session.mockResolvedValue({ user: { id: 'c041d8d8-93db-41f1-984d-5f76f114da48', isSuperAdmin: true } }) + user.mockResolvedValue({ id: 'c041d8d8-93db-41f1-984d-5f76f114da48', isSuperAdmin: false, isActive: true, status: 'active', archivedAt: null }) + await expect(salaryIdentity(true, request('https://sc.jiansun.vip'))).rejects.toMatchObject({ status: 403 }) + await expect(salaryIdentity()).resolves.toBe('c041d8d8-93db-41f1-984d-5f76f114da48') +}) diff --git a/lib/personnel-salary-http.ts b/lib/personnel-salary-http.ts index 38d1b1a6..169faa64 100644 --- a/lib/personnel-salary-http.ts +++ b/lib/personnel-salary-http.ts @@ -3,6 +3,7 @@ import { authOptions } from '@/lib/auth' import { NextRequest, NextResponse } from 'next/server' import { ZodError } from 'zod' import { SalaryError, requireSalaryAccess } from '@/lib/personnel-salary' +import { isAllowedCorsOrigin } from '@/lib/cors-origin' export const salaryHeaders = { 'Cache-Control': 'private, no-store, max-age=0', 'Pragma': 'no-cache', 'Vary': 'Cookie, Origin' } export function salaryJson(value: unknown, status = 200) { return NextResponse.json(value, { status, headers: salaryHeaders }) } @@ -12,7 +13,7 @@ export async function salaryIdentity(write = false, request?: NextRequest) { await requireSalaryAccess(session.user.id, write) if (write && request) { const origin = request.headers.get('origin') - if (request.headers.get('sec-fetch-site') === 'cross-site' || (origin && origin !== new URL(request.url).origin)) throw new SalaryError('请求来源不受信任,请在本站重试。', 403) + if (request.headers.get('sec-fetch-site') === 'cross-site' || !isAllowedCorsOrigin(request, origin)) throw new SalaryError('请求来源不受信任,请在本站重试。', 403) } return session.user.id }