fix(salary): trust configured origins behind reverse proxies

This commit is contained in:
江林铖 2026-11-07 13:17:39 +08:00
parent 0ab40c942a
commit 053f6a4f3b
6 changed files with 134 additions and 2 deletions

View File

@ -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()
})

View File

@ -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()
})

View File

@ -45,6 +45,20 @@ test('关闭人员弹窗后焦点回到仍存在的触发按钮', async () => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(edit).toHaveFocus()
})
test('保存刷新移除原编辑按钮后,焦点回到人员区域标题', async () => {
render(<PersonnelSalaryPanel />)
const edit = (await screen.findAllByRole('button', { name: '编辑测试甲的薪资基础' }))[0]
edit.focus()
fireEvent.click(edit)
let finishRefresh!: (value: ReturnType<typeof response>) => 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(<PersonnelSalaryPanel />)
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(<PersonnelSalaryPanel />)
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(<PersonnelSalaryPanel />)
fireEvent.click((await screen.findAllByRole('button', { name: '编辑测试甲的薪资基础' }))[0])

View File

@ -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 <dialog ref={ref} aria-labelledby="salary-dialog-title" aria-modal="true" onCancel={event => { event.preventDefault(); if (!busy) closeRef.current() }}
onClick={event => { if (event.target === event.currentTarget && !busy) closeRef.current() }}

View File

@ -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<string, string> = {}, 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, string>, 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')
})

View File

@ -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
}