From 7bd1895d4d712832c19be4d16c3ef071b44761d6 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Thu, 29 Oct 2026 15:46:36 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E6=97=A5=E5=BF=97=E6=95=8F=E6=84=9F?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=E8=BF=87=E6=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/sheep/utils/file-access-core.ts | 34 +++++++++++- src/sheep/utils/file-access.ts | 8 +++ test-vectors/file-upload.json | 32 ++++++++++++ tests/file-upload.test.cjs | 80 +++++++++++++++++++++++++++++ 4 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 test-vectors/file-upload.json create mode 100644 tests/file-upload.test.cjs diff --git a/src/sheep/utils/file-access-core.ts b/src/sheep/utils/file-access-core.ts index 1b91f6d..3092202 100644 --- a/src/sheep/utils/file-access-core.ts +++ b/src/sheep/utils/file-access-core.ts @@ -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() + 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 { + 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(/&/gi, '&').replace(/"/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) : ''; }) diff --git a/src/sheep/utils/file-access.ts b/src/sheep/utils/file-access.ts index 7ca5873..53b9167 100644 --- a/src/sheep/utils/file-access.ts +++ b/src/sheep/utils/file-access.ts @@ -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, }) diff --git a/test-vectors/file-upload.json b/test-vectors/file-upload.json new file mode 100644 index 0000000..bcab051 --- /dev/null +++ b/test-vectors/file-upload.json @@ -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" + } +] diff --git a/tests/file-upload.test.cjs b/tests/file-upload.test.cjs new file mode 100644 index 0000000..ed15a67 --- /dev/null +++ b/tests/file-upload.test.cjs @@ -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} +})