fix: harden mobile presign contracts and authorization.

This commit is contained in:
zlwu committed 2026-10-11 13:22:04 +08:00
1 parent 0927a514d2
commit 06255d1e41
12 files changed
+324 -50

No files matched your search

+54 -12
View File
@@ -19,11 +19,30 @@ function assertContext(context) {
}
}
export function normalizeMarketEntityId(id, { rejectPathSeparators = false } = {}) {
if (id == null) throw new Error('业务实体 ID 无效')
const normalized = String(id).trim()
if (
!normalized ||
normalized.length > 64 ||
normalized === '.' ||
normalized === '..' ||
/[\u0000-\u001f\u007f-\u009f]/.test(normalized) ||
(rejectPathSeparators && /[\\/]/.test(normalized))
) {
throw new Error('业务实体 ID 无效')
}
return normalized
}
export function buildMarketResourcePath(context, id) {
assertContext(context)
const base = `/mini/market/${context.marketRole}/${RESOURCE_BY_MODULE[context.module]}`
const normalizedId = String(id ?? '').trim()
return normalizedId ? `${base}/${encodeURIComponent(normalizedId)}` : base
if (arguments.length < 2) return base
const normalizedId = normalizeMarketEntityId(id, {
rejectPathSeparators: context.module === 'presign'
})
return `${base}/${encodeURIComponent(normalizedId)}`
}
export function buildMarketFormOptionsPath(context) {
@@ -36,11 +55,14 @@ export function buildMarketAttachmentPath(context) {
return `/mini/market/${context.marketRole}/${context.module}/attachments`
}
export function buildMarketActionPath(context, action, id) {
export function buildMarketActionPath(context, action, id, data = {}) {
assertContext(context)
const roleBase = `/mini/market/${context.marketRole}`
const encodedId = encodeURIComponent(String(id ?? '').trim())
if (context.module === 'presign' && encodedId) {
const encodeId = () => encodeURIComponent(normalizeMarketEntityId(id, {
rejectPathSeparators: context.module === 'presign'
}))
if (context.module === 'presign') {
const encodedId = encodeId()
if (action === 'delete') return buildMarketResourcePath(context, id)
if (action === 'convert') return `${buildMarketResourcePath(context, id)}/convert`
if (action === 'create-transfer') {
@@ -56,15 +78,27 @@ export function buildMarketActionPath(context, action, id) {
}
if (context.module === 'payment') {
if (action === 'register-transfer') return `${roleBase}/payment-transfers`
if (['edit-transfer', 'submit', 'withdraw'].includes(action) && encodedId) {
if (['edit-transfer', 'submit', 'withdraw'].includes(action)) {
const encodedId = encodeId()
const suffix = action === 'edit-transfer' ? '' : `/${action}`
return `${roleBase}/payment-transfers/${encodedId}${suffix}`
}
}
if (context.module === 'sample' && ['storage-cert', 'insurance'].includes(action) && encodedId) {
if (context.module === 'protocol' && ['audit', 'delete'].includes(action)) {
encodeId()
const source = String(data?.source || '').trim()
if (!['cell_storage', 'research_donation'].includes(source)) {
throw new Error('协议业务来源无效')
}
const suffix = action === 'audit' ? '/audit' : ''
return `${buildMarketResourcePath(context, id)}${suffix}?source=${encodeURIComponent(source)}`
}
if (context.module === 'sample' && ['storage-cert', 'insurance'].includes(action)) {
encodeId()
return `${buildMarketResourcePath(context, id)}/${action}`
}
if (context.module === 'freight' && ['link', 'unlink'].includes(action) && encodedId) {
if (context.module === 'freight' && ['link', 'unlink'].includes(action)) {
encodeId()
return `${buildMarketResourcePath(context, id)}/${action}`
}
throw new Error('不支持的市场业务操作')
@@ -97,9 +131,14 @@ export async function fetchMarketFormOptions(context, params, { signal } = {}) {
export async function saveMarketRecord(context, { id, data }, { signal } = {}) {
const { request } = await import('../utils/http.js')
const normalizedId = id == null || id === ''
? ''
: normalizeMarketEntityId(id, { rejectPathSeparators: context?.module === 'presign' })
return request({
url: buildMarketResourcePath(context, id),
method: id ? 'PUT' : 'POST',
url: normalizedId
? buildMarketResourcePath(context, normalizedId)
: buildMarketResourcePath(context),
method: normalizedId ? 'PUT' : 'POST',
data,
signal
})
@@ -114,10 +153,13 @@ export function marketActionMethod(action) {
export async function performMarketAction(context, action, { id, data } = {}, { signal } = {}) {
const { request } = await import('../utils/http.js')
const requestData = context.module === 'protocol' && ['audit', 'delete'].includes(action)
? Object.fromEntries(Object.entries(data || {}).filter(([key]) => key !== 'source'))
: data
return request({
url: buildMarketActionPath(context, action, id),
url: buildMarketActionPath(context, action, id, data),
method: marketActionMethod(action),
data,
data: action === 'delete' && context.module === 'protocol' ? undefined : requestData,
signal
})
}
+19 -5
View File
@@ -31,17 +31,17 @@ const PRESIGN_TOP_ACTIONS = new Set([
'create',
'edit',
'convert',
'delete',
'create-transfer'
'delete'
])
const PRESIGN_ORDER_ACTIONS = new Set([
'edit',
'convert',
'delete',
'create-transfer'
'delete'
])
const PRESIGN_TRANSFER_CREATE_ACTIONS = new Set(['create-transfer'])
const PRESIGN_TRANSFER_ACTION_MAP = Object.freeze({
edit: 'edit-presign-transfer',
delete: 'delete-transfer',
@@ -123,9 +123,23 @@ export function collectAllowedActions(module, role, detail) {
return { ...metadata, code }
})
.filter(Boolean)
const createTransfer = (Array.isArray(detail?.transferAllowedActions)
? detail.transferAllowedActions
: [])
.map((action) => {
const code = typeof action === 'string'
? action
: String(action?.code || action?.action || action?.key || '').trim()
if (!PRESIGN_TRANSFER_CREATE_ACTIONS.has(code) || !frontendWhitelist.has(code)) return null
if (typeof action === 'string') return action
const { key: _key, action: _action, ...metadata } = action
return { ...metadata, code }
})
.filter(Boolean)
return [
...nested,
...top
...top,
...createTransfer
]
}
if (module !== 'payment') {
+52 -4
View File
@@ -8,8 +8,11 @@ import {
createPresignTransfer,
deletePresignOrder,
deletePresignTransfer,
fetchMarketDetail,
marketActionMethod,
buildMarketResourcePath,
normalizeMarketEntityId,
saveMarketRecord,
submitPresignTransfer,
updatePresignTransfer,
withdrawPresignTransfer
@@ -53,16 +56,16 @@ test('maps every presign write action to the fixed backend route with encoded id
'/mini/market/specialist/presign-orders/order%201'
)
assert.equal(
buildMarketActionPath(presign, 'create-transfer', 'order/1'),
'/mini/market/specialist/presign-orders/order%2F1/transfers'
buildMarketActionPath(presign, 'create-transfer', 'order 1'),
'/mini/market/specialist/presign-orders/order%201/transfers'
)
assert.equal(
buildMarketActionPath(presign, 'edit-presign-transfer', 'transfer 1'),
'/mini/market/specialist/presign-transfers/transfer%201'
)
assert.equal(
buildMarketActionPath(presign, 'delete-transfer', 'transfer/1'),
'/mini/market/specialist/presign-transfers/transfer%2F1'
buildMarketActionPath(presign, 'delete-transfer', 'transfer 1'),
'/mini/market/specialist/presign-transfers/transfer%201'
)
assert.equal(
buildMarketActionPath(presign, 'submit-transfer', 'transfer 1'),
@@ -74,6 +77,51 @@ test('maps every presign write action to the fixed backend route with encoded id
)
})
test('normalizes safe entity ids and rejects dangerous presign path segments', () => {
const presign = { marketRole: 'specialist', module: 'presign' }
const uuid = '123e4567-e89b-12d3-a456-426614174000'
assert.equal(normalizeMarketEntityId(` ${uuid} `), uuid)
assert.equal(buildMarketResourcePath(presign, ' o 1 '), '/mini/market/specialist/presign-orders/o%201')
assert.equal(buildMarketActionPath(presign, 'submit-transfer', 't 1'), '/mini/market/specialist/presign-transfers/t%201/submit')
assert.equal(
buildMarketActionPath({ marketRole: 'specialist', module: 'protocol' }, 'delete', 'p/1', { source: 'cell_storage' }),
'/mini/market/specialist/protocols/p%2F1?source=cell_storage'
)
for (const id of [null, undefined, '', ' ', '.', '..', 'a/control\u0000id', 'a/control\u009fid', 'x'.repeat(65)]) {
assert.throws(() => normalizeMarketEntityId(id), /ID/)
}
assert.throws(() => buildMarketResourcePath(presign, undefined), /ID/)
for (const id of ['order/1', 'order\\1']) {
assert.throws(() => buildMarketResourcePath(presign, id), /ID/)
assert.throws(() => buildMarketActionPath(presign, 'delete', id), /ID/)
}
})
test('invalid detail, save, and presign action ids fail before an HTTP request', async () => {
const presign = { marketRole: 'specialist', module: 'presign' }
const originalFetch = globalThis.fetch
let calls = 0
globalThis.fetch = async () => {
calls += 1
throw new Error('fetch should not run')
}
try {
await assert.rejects(fetchMarketDetail(presign, ' '), /ID/)
await assert.rejects(saveMarketRecord(presign, { id: ' ', data: {} }), /ID/)
await assert.rejects(deletePresignOrder(presign, '..'), /ID/)
await assert.rejects(createPresignTransfer(presign, 'order/1', {}), /ID/)
await assert.rejects(updatePresignTransfer(presign, 'transfer\\1', {}), /ID/)
await assert.rejects(deletePresignTransfer(presign, ''), /ID/)
await assert.rejects(submitPresignTransfer(presign, null), /ID/)
await assert.rejects(withdrawPresignTransfer(presign, 'x'.repeat(65)), /ID/)
assert.equal(calls, 0)
} finally {
globalThis.fetch = originalFetch
}
})
test('uses the backend HTTP method for each presign action', () => {
assert.equal(marketActionMethod('delete'), 'DELETE')
assert.equal(marketActionMethod('create-transfer'), 'POST')
@@ -0,0 +1,44 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { fetchMarketList, saveMarketRecord } from '../api/market.js'
const context = { marketRole: 'specialist', module: 'presign' }
test('market wrappers pass the caller abort signal through to fetch', async () => {
const originalFetch = globalThis.fetch
const originalSessionStorage = globalThis.sessionStorage
const calls = []
globalThis.sessionStorage = { getItem: () => 'test-token' }
globalThis.fetch = async (url, init) => {
calls.push({ url, init })
return {
status: 200,
json: async () => ({ code: 200, data: { ok: true } })
}
}
try {
const listController = new AbortController()
const saveController = new AbortController()
await fetchMarketList(context, { page: 1 }, { signal: listController.signal })
await saveMarketRecord(context, { id: '', data: { motherName: '新增' } }, {
signal: saveController.signal
})
await saveMarketRecord(context, { id: ' o 1 ', data: { motherName: '张女士' } }, {
signal: saveController.signal
})
assert.equal(calls[0].init.signal, listController.signal)
assert.equal(calls[1].init.signal, saveController.signal)
assert.equal(calls[1].init.method, 'POST')
assert.match(calls[1].url, /presign-orders$/)
assert.equal(calls[2].init.signal, saveController.signal)
assert.equal(calls[2].init.method, 'PUT')
assert.match(calls[2].url, /presign-orders\/o%201$/)
} finally {
globalThis.fetch = originalFetch
if (originalSessionStorage === undefined) delete globalThis.sessionStorage
else globalThis.sessionStorage = originalSessionStorage
}
})
+28 -5
View File
@@ -128,7 +128,7 @@ test('presign actions never exceed the backend authorization set', () => {
'finance-confirm',
'admin-only'
]),
['delete', 'create-transfer']
['delete']
)
assert.equal(
intersectAllowedActions('presign', 'specialist', ['edit']).includes('delete'),
@@ -149,7 +149,7 @@ test('presign order actions reject transfer-only backend action codes', () => {
'submit-transfer',
'withdraw-transfer'
]),
['create', 'edit', 'convert', 'delete', 'create-transfer']
['create', 'edit', 'convert', 'delete']
)
})
@@ -183,14 +183,17 @@ test('presign transfer actions accept only raw transfer authorization codes with
})
test('collects only backend-authorized presign order and nested transfer actions', () => {
const actions = collectAllowedActions('presign', 'manager', {
const detail = {
allowedActions: ['create', 'edit', 'convert', 'delete', 'withdraw-transfer', 'admin-only'],
transferAllowedActions: ['create-transfer', 'admin-only'],
transfers: [
{ id: 'draft', allowedActions: ['edit', 'delete', 'submit', 'finance-confirm'] },
{ id: 'pending', allowedActions: ['withdraw'] },
{ id: 'confirmed', allowedActions: [] }
]
})
}
const before = structuredClone(detail)
const actions = collectAllowedActions('presign', 'manager', detail)
assert.deepEqual(
actions.map((item) => [typeof item === 'string' ? item : item.code, item.transferId]),
@@ -201,9 +204,29 @@ test('collects only backend-authorized presign order and nested transfer actions
['withdraw-transfer', 'pending'],
['edit', undefined],
['convert', undefined],
['delete', undefined]
['delete', undefined],
['create-transfer', undefined]
]
)
assert.equal(actions.some((item) => item.code === 'finance-confirm'), false)
assert.equal(actions.some((item) => item.code === 'admin-only'), false)
assert.deepEqual(detail, before)
})
test('create transfer requires the dedicated backend authorization field', () => {
assert.deepEqual(
collectAllowedActions('presign', 'specialist', {
allowedActions: ['edit', 'create-transfer'],
transfers: []
}),
['edit']
)
assert.deepEqual(
collectAllowedActions('presign', 'specialist', {
allowedActions: [],
transferAllowedActions: ['create-transfer'],
transfers: []
}),
['create-transfer']
)
})
@@ -92,14 +92,17 @@ export function isPresignTransferLocked(transfer, orderOrStatus) {
}
function attachmentUrls(transfer) {
if (Array.isArray(transfer?.attachments)) {
return transfer.attachments
.filter((file) => !file?._uploading && !file?.failed && file?.status !== 'failed')
.map((file) => String(file?.fileUrl || file?.url || '').trim())
.filter(Boolean)
.join(',')
}
if (typeof transfer?.transferAttachments === 'string') {
return transfer.transferAttachments
}
return (Array.isArray(transfer?.attachments) ? transfer.attachments : [])
.filter((file) => !file?._uploading && !file?.failed && file?.status !== 'failed')
.map((file) => String(file?.fileUrl || file?.url || '').trim())
.filter(Boolean)
.join(',')
return ''
}
export function buildPresignTransferPayload(transfer = {}) {
@@ -171,3 +171,31 @@ test('builds a transfer payload without ids, status, timestamps, or finance fiel
remark: ''
})
})
test('uses the current attachment array instead of a stale backend URL string', () => {
assert.equal(buildPresignTransferPayload({
transferAttachments: 'old.pdf,removed.pdf',
attachments: [
{ fileUrl: 'old.pdf' },
{ url: 'added.pdf' },
{ fileUrl: 'uploading.pdf', _uploading: true },
{ fileUrl: 'failed.pdf', status: 'failed' }
]
}).transferAttachments, 'old.pdf,added.pdf')
assert.equal(buildPresignTransferPayload({
transferAttachments: 'old.pdf',
attachments: [{ fileUrl: 'added.pdf' }]
}).transferAttachments, 'added.pdf')
})
test('an explicit empty attachment array deletes every transfer attachment', () => {
assert.equal(buildPresignTransferPayload({
transferAttachments: 'old.pdf',
attachments: []
}).transferAttachments, '')
assert.equal(buildPresignTransferPayload({
transferAttachments: 'fallback.pdf'
}).transferAttachments, 'fallback.pdf')
})
@@ -42,14 +42,42 @@ export function formatPresignDateTime(value) {
return `${date} ${match[2]}:${match[3]}${seconds}`
}
function decimalParts(value) {
if (value == null) return null
if (typeof value === 'number' && !Number.isFinite(value)) return null
const text = String(value).trim()
if (!text) return null
const match = /^([+-]?)(?:(\d+)(?:\.(\d*))?|\.(\d+))(?:[eE]([+-]?\d+))?$/.exec(text)
if (!match) return null
const exponent = Number(match[5] || 0)
if (!Number.isSafeInteger(exponent) || Math.abs(exponent) > 100) return null
const integerDigits = match[2] || ''
const fractionDigits = match[3] ?? match[4] ?? ''
const digits = `${integerDigits}${fractionDigits}` || '0'
const decimalPosition = integerDigits.length + exponent
const whole = decimalPosition <= 0
? '0'
: `${digits.slice(0, decimalPosition)}${'0'.repeat(Math.max(0, decimalPosition - digits.length))}`
const fraction = decimalPosition <= 0
? `${'0'.repeat(-decimalPosition)}${digits}`
: digits.slice(decimalPosition)
return {
negative: match[1] === '-',
whole: whole.replace(/^0+(?=\d)/, '') || '0',
fraction
}
}
export function formatPresignMoney(value) {
if (value == null || String(value).trim() === '') return '-'
const amount = Number(value)
if (!Number.isFinite(amount)) return '-'
return `¥${new Intl.NumberFormat('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(amount)}`
const parts = decimalParts(value)
if (!parts) return '-'
const fraction = parts.fraction.padEnd(3, '0')
let cents = BigInt(parts.whole) * 100n + BigInt(fraction.slice(0, 2))
if (fraction[2] >= '5') cents += 1n
const integer = (cents / 100n).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')
const decimals = (cents % 100n).toString().padStart(2, '0')
const sign = parts.negative && cents !== 0n ? '-' : ''
return `¥${sign}${integer}.${decimals}`
}
export function presignStatusLabel(value) {
@@ -62,15 +90,19 @@ export function presignStatusTone(value) {
}
export function presignCardIndex(page, pageSize, position) {
const normalizedPage = Number(page)
const normalizedPageSize = Number(pageSize)
const normalizedPosition = Number(position)
if (
!Number.isInteger(normalizedPage) || normalizedPage < 1 ||
!Number.isInteger(normalizedPageSize) || normalizedPageSize < 1 ||
!Number.isInteger(normalizedPosition) || normalizedPosition < 1
) {
return '-'
const positiveInteger = (value) => {
if (typeof value === 'bigint') return value > 0n ? value : null
if (typeof value === 'number') {
return Number.isSafeInteger(value) && value > 0 ? BigInt(value) : null
}
const text = String(value ?? '').trim()
if (!/^\+?\d{1,256}$/.test(text)) return null
const parsed = BigInt(text)
return parsed > 0n ? parsed : null
}
return String((normalizedPage - 1) * normalizedPageSize + normalizedPosition)
const normalizedPage = positiveInteger(page)
const normalizedPageSize = positiveInteger(pageSize)
const normalizedPosition = positiveInteger(position)
if (normalizedPage == null || normalizedPageSize == null || normalizedPosition == null) return '-'
return ((normalizedPage - 1n) * normalizedPageSize + normalizedPosition).toString()
}
@@ -27,6 +27,9 @@ test('returns a safe placeholder for null, empty, or invalid dates', () => {
test('formats finite amounts with a currency prefix and two decimals', () => {
assert.equal(formatPresignMoney(28800), '¥28,800.00')
assert.equal(formatPresignMoney('0'), '¥0.00')
assert.equal(formatPresignMoney('-12.345'), '¥-12.35')
assert.equal(formatPresignMoney('9999999999999999.99'), '¥9,999,999,999,999,999.99')
assert.equal(formatPresignMoney('1.005'), '¥1.01')
for (const value of [null, undefined, '', 'invalid', Number.NaN, Number.POSITIVE_INFINITY]) {
assert.equal(formatPresignMoney(value), '-')
}
@@ -46,5 +49,10 @@ test('uses the Web presign status labels and mobile semantic tones', () => {
test('computes a stable one-based sequence across pages', () => {
assert.equal(presignCardIndex(1, 10, 1), '1')
assert.equal(presignCardIndex(2, 10, 3), '13')
assert.equal(
presignCardIndex('900719925474099300000', '10000000000000000000', '7'),
'9007199254740992999990000000000000000007'
)
assert.equal(presignCardIndex('bad', 10, 1), '-')
assert.equal(presignCardIndex(Number.MAX_SAFE_INTEGER + 1, 10, 1), '-')
})
+3 -2
View File
@@ -1,6 +1,6 @@
import { getToken } from './auth.js'
const API_BASE = (import.meta.env.VITE_API_BASE || '/api').replace(/\/$/, '')
const API_BASE = (import.meta.env?.VITE_API_BASE || '/api').replace(/\/$/, '')
function unwrap(body) {
if (body == null || typeof body !== 'object') {
@@ -10,7 +10,7 @@ function unwrap(body) {
throw new Error((body.message && String(body.message).trim()) || '请求失败')
}
export async function request({ url, method = 'GET', data, headers = {} }) {
export async function request({ url, method = 'GET', data, headers = {}, signal }) {
const path = url.startsWith('/') ? url : `/${url}`
const full = `${API_BASE}${path}`
const token = getToken()
@@ -19,6 +19,7 @@ export async function request({ url, method = 'GET', data, headers = {} }) {
}
const init = {
method: method.toUpperCase(),
signal,
headers: {
Authorization: `Bearer ${token}`,
...headers