feat:日志敏感信息过滤

This commit is contained in:
dzc committed 2026-10-29 15:46:36 +08:00
1 parent 0fafe8762f
commit 7bd1895d4d
4 files changed
+153 -1

No files matched your search

+33 -1
View File
@@ -21,10 +21,19 @@ export function fileAccessSignature(file: string, app: string, kid: string, ts:
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(key) || CryptoJS.enc.Base64.parse(key).sigBytes < 32) throw new Error('Invalid file access key')
return CryptoJS.HmacSHA256(fileAccessCanonical(file, app, kid, ts), CryptoJS.enc.Base64.parse(key)).toString(CryptoJS.enc.Hex)
}
export function fileUploadCanonical(target: string, app: string, kid: string, ts: number, nonce: string): string {
if (!['upload', 'inner-upload', 'qn-upload'].includes(target)) throw new Error('Invalid file upload target')
return ['v1', 'oss-file-upload', 'POST', target, app, kid, String(ts), nonce].join('\n')
}
export function fileUploadSignature(target: string, app: string, kid: string, ts: number, nonce: string, key: string): string {
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(key) || CryptoJS.enc.Base64.parse(key).sigBytes < 32) throw new Error('Invalid file upload key')
return CryptoJS.HmacSHA256(fileUploadCanonical(target, app, kid, ts, nonce), CryptoJS.enc.Base64.parse(key)).toString(CryptoJS.enc.Hex)
}
export function createFileAccess(options: FileAccessOptions) {
const now = options.now || Date.now
let offset = 0, ttl = 1800
const cache = new Map<string, { url: string; ts: number }>()
let uploadSequence = 0
const base = () => (options.baseUrl() || '').replace(/\/+$/, '')
const allowed = () => [origin(base()), ...(options.allowedOrigins || []).map(origin)].filter(Boolean)
function resolve(source?: string | null): string {
@@ -88,13 +97,36 @@ export function createFileAccess(options: FileAccessOptions) {
const query = head.slice(at + 1).split('&').filter(p => !FILE_ACCESS_FIELDS.includes(decode(p.split('=')[0])))
return head.slice(0, at) + (query.length ? '?' + query.join('&') : '') + hash
}
function uploadTarget(source: string): string | null {
const resolved = resolve(source), host = origin(resolved)
if ((host && !allowed().includes(host)) || (/^[a-z][a-z0-9+.-]*:/i.test(resolved) && !host)) return null
const path = (host ? resolved.replace(/^https?:\/\/[^/?#]+/i, '') : resolved).split(/[?#]/)[0]
const prefix = base().replace(/^https?:\/\/[^/?#]+/i, '')
for (const [target, suffix] of [['upload', 'upload'], ['inner-upload', 'inner-upload'], ['qn-upload', 'qn/upload']]) {
if (['/sys-file/', '/admin/sys-file/', '/api/sys-file/', '/api/admin/sys-file/', prefix + '/admin/sys-file/', prefix + '/sys-file/']
.some(start => path === start + suffix)) return target
}
return null
}
function uploadHeaders(source: string, fixedNonce?: string): Record<string, string> {
const target = uploadTarget(source)
if (!target) return {}
const seconds = Math.floor((now() + offset) / 1000)
// The nonce is a unique request identifier, not a secret; HMAC provides authorization.
const nonce = fixedNonce || Math.floor(now()).toString(36) + '-' + (++uploadSequence).toString(36) + '-'
+ Array.from({ length: 4 }, () => Math.floor(Math.random() * 0x100000000).toString(16).padStart(8, '0')).join('')
return {
'X-File-App-Id': options.appId, 'X-File-Key-Id': options.kid, 'X-File-Timestamp': String(seconds), 'X-File-Nonce': nonce,
'X-File-Signature': fileUploadSignature(target, options.appId, options.kid, seconds, nonce, options.key),
}
}
function synchronize(serverTime: number, ttlSeconds = ttl, roundTripMs = 0) {
if (!Number.isFinite(serverTime) || serverTime <= 0) return
offset = serverTime + roundTripMs / 2 - now()
if (Number.isFinite(ttlSeconds) && ttlSeconds > 300 && ttlSeconds <= 86400) ttl = ttlSeconds
cache.clear()
}
return { url, resolve, strip, protected: (s?: string | null) => !!parse(s), synchronize, clear: () => cache.clear(), ttl: () => ttl }
return { uploadHeaders, uploadTarget, url, resolve, strip, protected: (s?: string | null) => !!parse(s), synchronize, clear: () => cache.clear(), ttl: () => ttl }
}
const unescapeAttribute = (s: string) => s.replace(/&amp;/gi, '&').replace(/&quot;/gi, '"').replace(/&#(?:39|x27);/gi, "'").replace(/&#(x[0-9a-f]+|\d+);/gi, (_, n) => { const code = n[0].toLowerCase() === 'x' ? parseInt(n.slice(1), 16) : Number(n); return code <= 0x10ffff ? String.fromCodePoint(code) : ''; })
+8
View File
@@ -22,6 +22,7 @@ export const accessFileUrl = (source?: string | null) => isProtectedFile(source)
export const fileUrls = (sources: any) => Array.isArray(sources) ? sources.map(accessFileUrl) : []
export const originalFileUrl = (source?: string | null) => client.strip(source)
export const resolveFileUrl = (source?: string | null) => client.resolve(source)
export const fileUploadHeaders = (source: string) => client.uploadHeaders(source)
export const fileHtml = (html: string) => rewriteFileHtml(html, accessFileUrl)
export const stripFileHtml = (html: string) => rewriteFileHtml(html, originalFileUrl)
export const fileNodes = (nodes: any) => rewriteFileNodes(nodes, accessFileUrl)
@@ -63,6 +64,13 @@ export async function retryFileAccess(source?: string | null) {
return true
}
export function installFileAccess(app: any) {
if (typeof uni.addInterceptor === 'function') {
uni.addInterceptor('uploadFile', {
invoke(options: any) {
options.header = { ...(options.header || {}), ...fileUploadHeaders(options.url) }
},
})
}
Object.assign(app.config.globalProperties, {
$fileUrl: accessFileUrl, $fileUrls: fileUrls, $fileHtml: fileHtml, $fileNodes: fileNodes, $fileRetry: retryFileAccess,
})
+32
View File
@@ -0,0 +1,32 @@
[
{
"target": "upload",
"appId": "admin",
"kid": "v1",
"ts": 1793232000,
"nonce": "00112233445566778899aabbccddeeff",
"key": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
"canonical": "v1\noss-file-upload\nPOST\nupload\nadmin\nv1\n1793232000\n00112233445566778899aabbccddeeff",
"sign": "75fde91ab548250139f260680aeb2e51f831d29d8e4fb3cfb28898528fd3a2a8"
},
{
"target": "qn-upload",
"appId": "h5",
"kid": "v1",
"ts": 1793232000,
"nonce": "ffeeddccbbaa99887766554433221100",
"key": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
"canonical": "v1\noss-file-upload\nPOST\nqn-upload\nh5\nv1\n1793232000\nffeeddccbbaa99887766554433221100",
"sign": "01cef848210b1bc31f5fe7679428b36d8e0f15b9f5517472a023d4d2c008086c"
},
{
"target": "inner-upload",
"appId": "service",
"kid": "v1",
"ts": 1793232000,
"nonce": "0123456789abcdef0123456789abcdef",
"key": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
"canonical": "v1\noss-file-upload\nPOST\ninner-upload\nservice\nv1\n1793232000\n0123456789abcdef0123456789abcdef",
"sign": "0d49bb992e5138cdb7a3f2bd7c83dfd60aedeae2b205898c4a6211c794f35aae"
}
]
+80
View File
@@ -0,0 +1,80 @@
const {test} = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const Module = require('node:module')
const ts = require('typescript')
const root = path.resolve(__dirname, '..')
const folder = fs.existsSync(path.join(root,'src/utils/file-access-core.ts')) ? 'src/utils' : 'src/sheep/utils'
const corePath = path.join(root,folder,'file-access-core.ts')
const compile = source => ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2020}}).outputText
const core = new Module(corePath,module); core.filename=corePath; core.paths=Module._nodeModulePaths(path.dirname(corePath))
core._compile(compile(fs.readFileSync(corePath,'utf8')),corePath)
const vectors=JSON.parse(fs.readFileSync(path.join(root,'test-vectors/file-upload.json'),'utf8'))
for(const v of vectors) test('upload signature vector: '+v.target,()=>{
assert.equal(core.exports.fileUploadCanonical(v.target,v.appId,v.kid,v.ts,v.nonce),v.canonical)
assert.equal(core.exports.fileUploadSignature(v.target,v.appId,v.kid,v.ts,v.nonce,v.key),v.sign)
})
test('upload credentials bind the purpose and target, renew per request and never leak to other hosts or imports',()=>{
let time=1793232000000
const client=core.exports.createFileAccess({appId:'admin',kid:'v1',key:vectors[0].key,baseUrl:()=> 'https://files.example/gateway/api',now:()=>time,allowedOrigins:['https://legacy.example']})
for(const [source,target] of [['/api/admin/sys-file/upload','upload'],['/sys-file/inner-upload','inner-upload'],['https://legacy.example/admin/sys-file/qn/upload','qn-upload'],['https://files.example/gateway/api/admin/sys-file/upload','upload']]){
const headers=client.uploadHeaders(source,vectors[0].nonce)
assert.equal(headers['X-File-Signature'],core.exports.fileUploadSignature(target,'admin','v1',1793232000,vectors[0].nonce,vectors[0].key))
assert.equal(Object.values(headers).includes(vectors[0].key),false)
}
const first=client.uploadHeaders('/admin/sys-file/upload'), second=client.uploadHeaders('/admin/sys-file/upload')
assert.notEqual(first['X-File-Nonce'],second['X-File-Nonce'])
assert.notEqual(first['X-File-Signature'],second['X-File-Signature'])
assert.equal(first['X-File-Timestamp'],'1793232000')
client.synchronize(time+86400000,1800,0)
assert.equal(client.uploadHeaders('/admin/sys-file/upload')['X-File-Timestamp'],'1793318400')
for(const source of ['https://outside.example/admin/sys-file/upload','https://files.example.evil/admin/sys-file/upload','/admin/sys-file/oss/file?fileName=x','/admin/user/import','data:upload','blob:upload']){
assert.deepEqual(client.uploadHeaders(source),{})
}
assert.notEqual(core.exports.fileAccessSignature('a.jpg','admin','v1',1793232000,vectors[0].key),first['X-File-Signature'])
})
function runtime(){
const filename=path.join(root,folder,'file-access.ts')
const env={VITE_FILE_ACCESS_APP_ID:'admin',VITE_FILE_ACCESS_KEY_ID:'v1',VITE_FILE_ACCESS_KEY:vectors[0].key,VITE_IMAGE_BASE_PATH:'https://files.example/api',VITE_IMAGE_DEV_PATH:'https://files.example/api',VITE_APP_DEV_SERVICE:'https://files.example/api',VITE_APP_PROD_IMAGE:'https://files.example/api',VITE_APP_PROD_SERVICE:'https://files.example/api'}
const loaded=new Module(filename,module);loaded.filename=filename;loaded.paths=core.paths
loaded.require=name=>name==='./file-access-core'?core.exports:name==='./app'?{isDevEnv:()=>true}:core.require(name)
loaded._compile(compile(fs.readFileSync(filename,'utf8').replace(/import\.meta\.env/g,'('+JSON.stringify(env)+')')),filename)
return loaded.exports
}
if(folder==='src/utils') test('the real Axios interceptor signs each file upload and preserves login and tenant headers',()=>{
const api=runtime();let onRequest
const axios={create:()=>({interceptors:{request:{use(fn){onRequest=fn}},response:{use(){}}}})}
const filename=path.join(root,'src/utils/request.ts');const loaded=new Module(filename,module);loaded.filename=filename;loaded.paths=core.paths
loaded.require=name=>{
if(name==='axios')return {default:axios}
if(name==='./file-access')return api
if(name==='./other')return {default:{adaptationUrl:url=>url}}
if(name==='/@/utils/storage')return {Session:{getToken:()=> 'user-token',getTenant:()=> '42'}}
if(name==='/@/hooks/message')return {}
return core.require(name)
}
loaded._compile(compile(fs.readFileSync(filename,'utf8')),filename)
const first=onRequest({method:'post',url:'/admin/sys-file/upload',headers:{},data:{file:'unchanged'}})
const second=onRequest({method:'post',url:'/admin/sys-file/upload',headers:{},data:{file:'unchanged'}})
assert.equal(first.headers.Authorization,'Bearer user-token');assert.equal(first.headers['TENANT-ID'],'42')
assert.ok(first.headers['X-File-Signature']);assert.notEqual(first.headers['X-File-Nonce'],second.headers['X-File-Nonce'])
assert.deepEqual(first.data,{file:'unchanged'})
const other=onRequest({method:'post',url:'https://outside.example/admin/sys-file/upload',headers:{}})
assert.equal(other.headers['X-File-Signature'],undefined)
})
else test('the actual uni upload interceptor signs photo, video and rich text calls without changing form data',()=>{
const previous=global.uni;let interceptor
global.uni={addInterceptor(name,hook){assert.equal(name,'uploadFile');interceptor=hook},onNetworkStatusChange(){}}
try{
const api=runtime();api.installFileAccess({config:{globalProperties:{}},mixin(){}})
const first={url:'/api/admin/sys-file/upload',header:{Authorization:'Bearer user','TENANT-ID':'42'},formData:{dir:'photos'},filePath:'local-photo.png'}
const second={...first,header:{...first.header}}
interceptor.invoke(first);interceptor.invoke(second)
assert.ok(first.header['X-File-Signature']);assert.notEqual(first.header['X-File-Nonce'],second.header['X-File-Nonce'])
assert.equal(first.header.Authorization,'Bearer user');assert.equal(first.header['TENANT-ID'],'42')
assert.deepEqual(first.formData,{dir:'photos'});assert.equal(first.filePath,'local-photo.png')
const other={url:'https://outside.example/upload',header:{Authorization:'other'}};interceptor.invoke(other)
assert.deepEqual(other.header,{Authorization:'other'})
}finally{global.uni=previous}
})