From 791b561fb6fa309950b77577bc0a44cbe95ca6f4 Mon Sep 17 00:00:00 2001 From: Mortimer Date: Thu, 1 Oct 2026 15:43:17 -0600 Subject: [PATCH 1/2] fix(upload): send a whole-file SHA1 checksum with every upload Browser uploads carried no checksum, so the server could not tell a damaged upload from a good one: overlapping or spliced chunks were stored silently. tus-js-client can also resume a different file's partial upload when name, type, size, mtime and endpoint all match, splicing the two files together. Add an Uppy pre-processor that computes the SHA1 of the bytes that are actually sent (after vault encryption) in a web worker, reading the file in 8 MiB slices with hash-wasm since crypto.subtle cannot hash incrementally. The checksum goes into the tus Upload-Metadata as `checksum: sha1 `, and into the `OC-Checksum: SHA1:` header for plain PUT uploads. The server verifies it and rejects mismatching uploads. If a file cannot be hashed, that file fails instead of being uploaded without a checksum. The upload info shows "Calculating checksum..." for a file while it is hashed. --- packages/web-pkg/package.json | 1 + .../src/composables/upload/useUpload.ts | 10 ++ .../src/services/uppy/checksum/index.ts | 2 + .../src/services/uppy/checksum/plugin.ts | 75 ++++++++++ .../src/services/uppy/checksum/sha1.ts | 51 +++++++ .../src/services/uppy/checksum/worker.ts | 24 ++++ .../web-pkg/src/services/uppy/uppyService.ts | 20 ++- .../unit/composables/upload/useUpload.spec.ts | 37 +++++ .../tests/unit/services/uppy/checksum.spec.ts | 129 ++++++++++++++++++ .../unit/services/uppy/uppyService.spec.ts | 51 +++++++ .../web-runtime/src/components/UploadInfo.vue | 17 +++ .../tests/unit/components/UploadInfo.spec.ts | 17 +++ pnpm-lock.yaml | 8 ++ 13 files changed, 440 insertions(+), 2 deletions(-) create mode 100644 packages/web-pkg/src/services/uppy/checksum/index.ts create mode 100644 packages/web-pkg/src/services/uppy/checksum/plugin.ts create mode 100644 packages/web-pkg/src/services/uppy/checksum/sha1.ts create mode 100644 packages/web-pkg/src/services/uppy/checksum/worker.ts create mode 100644 packages/web-pkg/tests/unit/services/uppy/checksum.spec.ts create mode 100644 packages/web-pkg/tests/unit/services/uppy/uppyService.spec.ts diff --git a/packages/web-pkg/package.json b/packages/web-pkg/package.json index c2ccaa1b696..14e8e14a266 100644 --- a/packages/web-pkg/package.json +++ b/packages/web-pkg/package.json @@ -96,6 +96,7 @@ "dompurify": "^3.3.3", "filesize": "^11.0.14", "fuse.js": "^7.1.0", + "hash-wasm": "^4.12.0", "highlight.js": "^11.12.0", "lodash-es": "^4.17.23", "lowlight": "^3.3.0", diff --git a/packages/web-pkg/src/composables/upload/useUpload.ts b/packages/web-pkg/src/composables/upload/useUpload.ts index b467888ba21..46d053c5e2a 100644 --- a/packages/web-pkg/src/composables/upload/useUpload.ts +++ b/packages/web-pkg/src/composables/upload/useUpload.ts @@ -43,6 +43,15 @@ export function useUpload(options: UploadOptions) { return headers } + // the checksum meta field is ' ', a PUT carries it as 'OC-Checksum: :' + function getChecksumHeader(file: OcUppyFile): Record { + const [algorithm, checksum] = file?.meta?.checksum?.split(' ') ?? [] + if (!algorithm || !checksum) { + return {} + } + return { 'OC-Checksum': `${algorithm.toUpperCase()}:${checksum}` } + } + const tusOptions = computed(() => { const options: OcTusOptions = { onBeforeRequest: (req, file) => @@ -82,6 +91,7 @@ export function useUpload(options: UploadOptions) { endpoint: '', headers: (file) => ({ 'x-oc-mtime': ((file?.data as File)?.lastModified / 1000).toFixed(0), + ...getChecksumHeader(file), ...getHeaders() }) } diff --git a/packages/web-pkg/src/services/uppy/checksum/index.ts b/packages/web-pkg/src/services/uppy/checksum/index.ts new file mode 100644 index 00000000000..2f4ad0ef7f8 --- /dev/null +++ b/packages/web-pkg/src/services/uppy/checksum/index.ts @@ -0,0 +1,2 @@ +export * from './plugin' +export * from './sha1' diff --git a/packages/web-pkg/src/services/uppy/checksum/plugin.ts b/packages/web-pkg/src/services/uppy/checksum/plugin.ts new file mode 100644 index 00000000000..707146ac2e9 --- /dev/null +++ b/packages/web-pkg/src/services/uppy/checksum/plugin.ts @@ -0,0 +1,75 @@ +import Uppy, { BasePlugin } from '@uppy/core' +import type { OcUppyBody, OcUppyFile, OcUppyMeta } from '../uppyService' +import { computeSha1 } from './sha1' + +/** + * Uppy pre-processor that computes the SHA1 of every file and stores it in the `checksum` meta + * field as `sha1 `, the format the server expects in the tus `Upload-Metadata` header. + * The server compares it with the checksum of the received bytes and rejects the upload on a + * mismatch, so a damaged or spliced upload fails instead of being stored. + * + * Pre-processors run when the upload starts, after other code (e.g. vault encryption) has + * replaced `file.data`, so the hash covers the bytes that are actually sent. + */ +export class UploadChecksumPlugin extends BasePlugin { + constructor(uppy: Uppy, opts?: object) { + super(uppy, opts) + this.id = 'UploadChecksum' + this.type = 'modifier' + } + + prepare = async (fileIDs: string[]) => { + for (const file of this.uppy.getFilesByIds(fileIDs)) { + if (!this.needsChecksum(file)) { + continue + } + + // file.size may be null while file.data.size is always set for local files + const size = file.data.size + this.uppy.emit('preprocess-progress', file, { mode: 'determinate', message: '', value: 0 }) + try { + const checksum = await computeSha1(file.data as Blob, { + onProgress: (bytesHashed) => { + this.uppy.emit('preprocess-progress', this.uppy.getFile(file.id), { + mode: 'determinate', + message: '', + value: size ? bytesHashed / size : 1 + }) + } + }) + this.uppy.setFileMeta(file.id, { + ...this.uppy.getFile(file.id).meta, + checksum: `sha1 ${checksum}` + }) + } catch (error) { + // never upload without a checksum, fail this file instead + this.uppy.log( + `[UploadChecksum] failed to compute checksum of ${file.name}: ${error}`, + 'error' + ) + this.uppy.emit( + 'upload-error', + this.uppy.getFile(file.id), + error instanceof Error ? error : new Error(String(error)) + ) + } + this.uppy.emit('preprocess-complete', this.uppy.getFile(file.id)) + } + } + + private needsChecksum(file: OcUppyFile) { + // folders have no content, remote files (e.g. from companion) have no local data to hash, + // and a retried upload keeps the checksum it already has + return ( + !file.meta.isFolder && !file.isRemote && !file.error && !file.meta.checksum && !!file.data + ) + } + + install() { + this.uppy.addPreProcessor(this.prepare) + } + + uninstall() { + this.uppy.removePreProcessor(this.prepare) + } +} diff --git a/packages/web-pkg/src/services/uppy/checksum/sha1.ts b/packages/web-pkg/src/services/uppy/checksum/sha1.ts new file mode 100644 index 00000000000..1035743c385 --- /dev/null +++ b/packages/web-pkg/src/services/uppy/checksum/sha1.ts @@ -0,0 +1,51 @@ +import ChecksumWorker from './worker?worker' + +export const CHECKSUM_CHUNK_SIZE = 8 * 1024 * 1024 + +export type ChecksumWorkerRequest = { + blob: Blob + chunkSize: number +} + +export type ChecksumWorkerResponse = + | { type: 'progress'; bytesHashed: number } + | { type: 'done'; checksum: string } + | { type: 'error'; message: string } + +/** + * Computes the hex encoded SHA1 of a blob in a web worker, so hashing large files does not + * block the UI. + */ +export function computeSha1( + blob: Blob, + { + chunkSize = CHECKSUM_CHUNK_SIZE, + onProgress + }: { chunkSize?: number; onProgress?: (bytesHashed: number) => void } = {} +): Promise { + return new Promise((resolve, reject) => { + const worker = new (ChecksumWorker as unknown as new () => Worker)() + + worker.onmessage = (e: MessageEvent) => { + const message = e.data + switch (message.type) { + case 'progress': + onProgress?.(message.bytesHashed) + return + case 'done': + worker.terminate() + resolve(message.checksum) + return + case 'error': + worker.terminate() + reject(new Error(message.message)) + } + } + worker.onerror = (e) => { + worker.terminate() + reject(new Error(e.message || 'checksum worker failed')) + } + + worker.postMessage({ blob, chunkSize } satisfies ChecksumWorkerRequest) + }) +} diff --git a/packages/web-pkg/src/services/uppy/checksum/worker.ts b/packages/web-pkg/src/services/uppy/checksum/worker.ts new file mode 100644 index 00000000000..8df14ac925b --- /dev/null +++ b/packages/web-pkg/src/services/uppy/checksum/worker.ts @@ -0,0 +1,24 @@ +import { createSHA1 } from 'hash-wasm' +import type { ChecksumWorkerRequest, ChecksumWorkerResponse } from './sha1' + +function post(message: ChecksumWorkerResponse) { + postMessage(message) +} + +// Hashes the blob slice by slice, so that large files never have to be loaded into memory +// at once. crypto.subtle.digest cannot hash incrementally, so hash-wasm is used instead. +self.onmessage = async (e: MessageEvent) => { + const { blob, chunkSize } = e.data + try { + const hasher = await createSHA1() + hasher.init() + for (let offset = 0; offset < blob.size; offset += chunkSize) { + const end = Math.min(offset + chunkSize, blob.size) + hasher.update(new Uint8Array(await blob.slice(offset, end).arrayBuffer())) + post({ type: 'progress', bytesHashed: end }) + } + post({ type: 'done', checksum: hasher.digest('hex') }) + } catch (error) { + post({ type: 'error', message: error instanceof Error ? error.message : String(error) }) + } +} diff --git a/packages/web-pkg/src/services/uppy/uppyService.ts b/packages/web-pkg/src/services/uppy/uppyService.ts index 8c79a212f91..ea2e8f6e4c0 100644 --- a/packages/web-pkg/src/services/uppy/uppyService.ts +++ b/packages/web-pkg/src/services/uppy/uppyService.ts @@ -7,6 +7,7 @@ import { eventBus } from '../eventBus' import DropTarget from './DropTarget/plugin' import { Resource, urlJoin } from '@opencloud-eu/web-client' import { generateFileID, Body, MinimalRequiredUppyFile } from '@uppy/utils' +import { UploadChecksumPlugin } from './checksum' type UppyServiceTopics = | 'uploadStarted' @@ -21,6 +22,8 @@ type UppyServiceTopics = | 'drag-over' | 'drag-out' | 'drop' + | 'preprocess-progress' + | 'preprocess-complete' export type uppyHeaders = { [name: string]: string | number @@ -45,6 +48,8 @@ type FileWithPath = File & { export type OcUppyMeta = { name?: string mtime?: number + // whole-file checksum as ' ', set by the UploadChecksum plugin + checksum?: string // current space & folder spaceId: string spaceName: string @@ -70,8 +75,10 @@ export type OcUppyMeta = { export type OcUppyBody = Body // Meta fields safe to put in the tus `Upload-Metadata` header. This should -// only include fields that are part of the TUS spec. -export const TUS_ALLOWED_META_FIELDS: (keyof OcUppyMeta)[] = ['name', 'mtime'] +// only include fields that are part of the TUS spec, or that the server needs. +// `checksum` is a hash of the transmitted bytes (the ciphertext for vault uploads), +// so it does not reveal any path or cleartext information. +export const TUS_ALLOWED_META_FIELDS: (keyof OcUppyMeta)[] = ['name', 'mtime', 'checksum'] export type OcUppyFile = UppyFile type OcUppyPlugin = typeof BasePlugin @@ -127,6 +134,9 @@ export class UppyService { } }) + // compute a whole-file checksum before every upload, see UploadChecksumPlugin + this.uppy.use(UploadChecksumPlugin) + this.setUpEvents() } @@ -269,6 +279,12 @@ export class UppyService { this.uppy.on('upload-progress', (file, progress) => { this.publish('upload-progress', { file, progress }) }) + this.uppy.on('preprocess-progress', (file, progress) => { + this.publish('preprocess-progress', { file, progress }) + }) + this.uppy.on('preprocess-complete', (file) => { + this.publish('preprocess-complete', file) + }) this.uppy.on('cancel-all', () => { this.publish('uploadCancelled') this.clearInputs() diff --git a/packages/web-pkg/tests/unit/composables/upload/useUpload.spec.ts b/packages/web-pkg/tests/unit/composables/upload/useUpload.spec.ts index 3d33df2d91d..11e8902fabc 100644 --- a/packages/web-pkg/tests/unit/composables/upload/useUpload.spec.ts +++ b/packages/web-pkg/tests/unit/composables/upload/useUpload.spec.ts @@ -1,7 +1,44 @@ +import { mock } from 'vitest-mock-extended' +import { XHRUploadOptions } from '@uppy/xhr-upload' +import { defaultComponentMocks, getComposableWrapper } from '@opencloud-eu/web-test-helpers' import { useUpload } from '../../../../src/composables/upload' +import { CapabilityStore } from '../../../../src/composables' +import { OcUppyBody, OcUppyFile, OcUppyMeta, UppyService } from '../../../../src/services/uppy' describe('useUpload', () => { it('should be valid', () => { expect(useUpload).toBeDefined() }) + + describe('plain PUT uploads', () => { + it('send the checksum as OC-Checksum header', () => { + const headers = getXhrHeaders({ + meta: { checksum: 'sha1 aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d' } + } as OcUppyFile) + expect(headers['OC-Checksum']).toBe('SHA1:aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d') + }) + + it('send no OC-Checksum header without a checksum', () => { + const headers = getXhrHeaders({ meta: {} } as OcUppyFile) + expect(headers).not.toHaveProperty('OC-Checksum') + }) + }) }) + +function getXhrHeaders(file: OcUppyFile) { + const uppyService = mock() + const mocks = defaultComponentMocks() + // a max chunk size of 0 means no tus support, so the plain PUT (XHR) uploader is used + const capabilities = { + files: { tus_support: { max_chunk_size: 0, extension: '' } } + } as unknown as Partial + + getComposableWrapper(() => useUpload({ uppyService }), { + mocks, + provide: mocks, + pluginOptions: { piniaOptions: { capabilityState: { capabilities } } } + }) + + const options = uppyService.useXhr.mock.calls[0][0] as XHRUploadOptions + return (options.headers as (file: OcUppyFile) => Record)(file) +} diff --git a/packages/web-pkg/tests/unit/services/uppy/checksum.spec.ts b/packages/web-pkg/tests/unit/services/uppy/checksum.spec.ts new file mode 100644 index 00000000000..fb37a6e9d5a --- /dev/null +++ b/packages/web-pkg/tests/unit/services/uppy/checksum.spec.ts @@ -0,0 +1,129 @@ +import { createHash, randomBytes } from 'node:crypto' +import Uppy, { BasePlugin } from '@uppy/core' +import { computeSha1, UploadChecksumPlugin } from '../../../../src/services/uppy/checksum' +import { OcUppyBody, OcUppyMeta } from '../../../../src/services/uppy' + +const sha1Hex = (data: Uint8Array) => createHash('sha1').update(data).digest('hex') + +// an uploader that records the files it was asked to upload instead of sending them +class RecordingUploader extends BasePlugin { + uploaded: { name: string; meta: OcUppyMeta }[] = [] + + constructor(uppy: Uppy) { + super(uppy, {}) + this.id = 'RecordingUploader' + this.type = 'uploader' + } + + upload = (fileIDs: string[]) => { + for (const file of this.uppy.getFilesByIds(fileIDs)) { + if (file.error) { + continue + } + this.uploaded.push({ name: file.name, meta: file.meta }) + this.uppy.emit('upload-success', file, { status: 200, body: {}, uploadURL: '' }) + } + return Promise.resolve() + } + + install() { + this.uppy.addUploader(this.upload) + } + + uninstall() { + this.uppy.removeUploader(this.upload) + } +} + +const createUppy = () => { + const uppy = new Uppy() + uppy.use(UploadChecksumPlugin) + uppy.use(RecordingUploader) + return { uppy, uploader: uppy.getPlugin('RecordingUploader') as RecordingUploader } +} + +describe('upload checksums', () => { + // happy-dom's Blob loses its content when structured-cloned into the emulated worker, + // browsers keep it. Pass messages by reference in these tests. + beforeAll(() => { + vi.stubEnv('VITEST_WEB_WORKER_CLONE', 'none') + }) + afterAll(() => { + vi.unstubAllEnvs() + }) + + describe('computeSha1', () => { + it('computes the SHA1 of a blob', async () => { + expect(await computeSha1(new Blob(['hello']))).toBe( + 'aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d' + ) + }) + + it('computes the SHA1 of an empty blob', async () => { + expect(await computeSha1(new Blob([]))).toBe('da39a3ee5e6b4b0d3255bfef95601890afd80709') + }) + + it('hashes the blob slice by slice', async () => { + const data = new Uint8Array(randomBytes(3 * 1024 + 17)) + const progress: number[] = [] + + const checksum = await computeSha1(new Blob([data]), { + chunkSize: 1024, + onProgress: (bytesHashed) => progress.push(bytesHashed) + }) + + expect(checksum).toBe(sha1Hex(data)) + // the emulated worker may deliver a message more than once without cloning + expect([...new Set(progress)]).toEqual([1024, 2048, 3072, 3 * 1024 + 17]) + }) + }) + + describe('UploadChecksumPlugin', () => { + it('sets the checksum meta field before the upload starts', async () => { + const { uppy, uploader } = createUppy() + uppy.addFile({ name: 'hello.txt', data: new Blob(['hello']) }) + + const result = await uppy.upload() + + expect(result.failed).toHaveLength(0) + expect(uploader.uploaded).toHaveLength(1) + expect(uploader.uploaded[0].meta.checksum).toBe( + 'sha1 aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d' + ) + }) + + it('hashes the data that is actually uploaded', async () => { + const { uppy, uploader } = createUppy() + const id = uppy.addFile({ name: 'secret.txt', data: new Blob(['cleartext']) }) + // e.g. a vault upload replaces the content with ciphertext before the upload starts + uppy.setFileState(id, { data: new Blob(['ciphertext']) }) + + await uppy.upload() + + expect(uploader.uploaded[0].meta.checksum).toBe( + 'sha1 ' + sha1Hex(new TextEncoder().encode('ciphertext')) + ) + }) + + it('does not hash folders', async () => { + const { uppy, uploader } = createUppy() + uppy.addFile({ name: 'folder', data: new Blob([]), meta: { isFolder: true } as OcUppyMeta }) + + await uppy.upload() + + expect(uploader.uploaded[0].meta.checksum).toBeUndefined() + }) + + it('fails the file instead of uploading it without a checksum', async () => { + const { uppy, uploader } = createUppy() + // data that cannot be read, so hashing fails + uppy.addFile({ name: 'broken.txt', data: { size: 6 } as unknown as Blob }) + uppy.addFile({ name: 'fine.txt', data: new Blob(['fine']) }) + + const result = await uppy.upload() + + expect(uploader.uploaded.map(({ name }) => name)).toEqual(['fine.txt']) + expect(result.failed.map(({ name }) => name)).toEqual(['broken.txt']) + }) + }) +}) diff --git a/packages/web-pkg/tests/unit/services/uppy/uppyService.spec.ts b/packages/web-pkg/tests/unit/services/uppy/uppyService.spec.ts new file mode 100644 index 00000000000..2584595e630 --- /dev/null +++ b/packages/web-pkg/tests/unit/services/uppy/uppyService.spec.ts @@ -0,0 +1,51 @@ +import { Language } from 'vue3-gettext' +import { getAllowedMetaFields } from '@uppy/core/utils' +import type { TusOptions } from '@uppy/tus' +import { OcUppyBody, OcUppyMeta, UppyService } from '../../../../src/services/uppy' + +describe('UppyService', () => { + beforeAll(() => { + // happy-dom's Blob loses its content when cloned into the emulated checksum worker + vi.stubEnv('VITEST_WEB_WORKER_CLONE', 'none') + }) + afterAll(() => { + vi.unstubAllEnvs() + }) + + it('puts the checksum but no path information into the tus Upload-Metadata', async () => { + const uppyService = new UppyService({ + language: { $gettext: (msg: string) => msg } as unknown as Language + }) + uppyService.useTus({ chunkSize: Infinity, uploadDataDuringCreation: false, headers: {} }) + // the transport itself is not under test (tus-js-client can't send Blobs in node) + vi.spyOn(console, 'error').mockImplementation(() => undefined) + + const id = uppyService.uppy.addFile({ + name: 'hello.txt', + data: new Blob(['hello']), + meta: { + mtime: 1700000000, + relativeFolder: 'secret', + relativePath: '/secret/hello.txt', + currentFolder: '/private/folder', + routeDriveAliasAndItem: 'personal/admin/private/folder' + } as OcUppyMeta + }) + uppyService.uppy.setFileState(id, { tus: { endpoint: 'https://example.org/dav/spaces/1' } }) + await uppyService.uploadFiles() + + // the Upload-Metadata entries, built the way @uppy/tus builds them + const { allowedMetaFields } = uppyService.getPlugin('Tus').opts as TusOptions< + OcUppyMeta, + OcUppyBody + > + const file = uppyService.uppy.getFile(id) + const metadataKeys = getAllowedMetaFields( + allowedMetaFields, + file.meta as unknown as Record + ) + + expect(file.meta.checksum).toBe('sha1 aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d') + expect([...metadataKeys].sort()).toEqual(['checksum', 'mtime', 'name']) + }) +}) diff --git a/packages/web-runtime/src/components/UploadInfo.vue b/packages/web-runtime/src/components/UploadInfo.vue index 7ff379d142c..8713bd5b651 100644 --- a/packages/web-runtime/src/components/UploadInfo.vue +++ b/packages/web-runtime/src/components/UploadInfo.vue @@ -536,6 +536,10 @@ function retryUploads() { } function getUploadItemMessage(item: UploadResult) { + if (item.status === 'preparing') { + return $gettext('Calculating checksum...') + } + const error = unref(errors)[item.meta.uploadId] if (!error) { @@ -623,6 +627,19 @@ uppyService.subscribe('addedForUpload', (files: OcUppyFile[]) => { } } }) +// the upload checksum is computed before a file is uploaded, which can take a while for big files +uppyService.subscribe('preprocess-progress', ({ file }: { file: OcUppyFile }) => { + const item = unref(uploads)[file?.meta.uploadId] + if (item && !item.status) { + item.status = 'preparing' + } +}) +uppyService.subscribe('preprocess-complete', (file: OcUppyFile) => { + const item = unref(uploads)[file?.meta.uploadId] + if (item?.status === 'preparing') { + item.status = undefined + } +}) uppyService.subscribe('uploadCompleted', () => { runningUploads.value -= 1 diff --git a/packages/web-runtime/tests/unit/components/UploadInfo.spec.ts b/packages/web-runtime/tests/unit/components/UploadInfo.spec.ts index e98794ac379..8be7b0691c2 100644 --- a/packages/web-runtime/tests/unit/components/UploadInfo.spec.ts +++ b/packages/web-runtime/tests/unit/components/UploadInfo.spec.ts @@ -242,6 +242,23 @@ describe('UploadInfo component', () => { expect(infoMessages.at(0).text()).toBe('Unknown error') expect(infoMessages.at(1).text()).toBe('Unknown error') }) + it('should show that a file is being prepared while its checksum is calculated', async () => { + const { wrapper, mocks } = getShallowWrapper() + const file = { name: 'file', path: '/', type: 'file', meta: { uploadId: '1' } } + ;(wrapper.vm as any).showInfo = true + ;(wrapper.vm as any).infoExpanded = true + ;(wrapper.vm as any).uploads = { '1': file as unknown as OcUppyFile } + const subscriber = (topic: string) => + mocks.$uppyService.subscribe.mock.calls.find(([t]) => t === topic)[1] + + subscriber('preprocess-progress')({ file, progress: { value: 0.5 } }) + await nextTick() + expect(wrapper.find(selectors.message).text()).toBe('Calculating checksum...') + + subscriber('preprocess-complete')(file) + await nextTick() + expect(wrapper.find(selectors.message).exists()).toBeFalsy() + }) it('folder is clickable', async () => { const { wrapper } = getShallowWrapper() ;(wrapper.vm as any).showInfo = true diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 061d3352f89..010ce0cfd1f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1095,6 +1095,9 @@ importers: fuse.js: specifier: ^7.1.0 version: 7.5.0 + hash-wasm: + specifier: ^4.12.0 + version: 4.12.0 highlight.js: specifier: ^11.12.0 version: 11.12.0 @@ -3464,6 +3467,9 @@ packages: resolution: {integrity: sha512-Bb33KbowVTIj5s7Ked1OsqHUeCpz//tPwR+E2zJgJKo9Z5XolZ9b6bdUgjmYlwnWhoOQKoTd1TYToZGn5mAYOg==} engines: {node: '>= 0.8'} + hash-wasm@4.12.0: + resolution: {integrity: sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ==} + hash.js@1.1.7: resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==} @@ -7504,6 +7510,8 @@ snapshots: safe-buffer: 5.2.1 to-buffer: 1.2.2 + hash-wasm@4.12.0: {} + hash.js@1.1.7: dependencies: inherits: 2.0.4 From f86c4d5d546efb1d6a662c423570b52d2051ecdd Mon Sep 17 00:00:00 2001 From: Mortimer Date: Thu, 1 Oct 2026 19:24:47 -0600 Subject: [PATCH 2/2] fix(upload): hash with JavaScript when the CSP blocks WebAssembly hash-wasm needs 'wasm-unsafe-eval' in the Content-Security-Policy's script-src to compile its WebAssembly module. OpenCloud's default CSP does not allow it, so computing the checksum failed and every browser upload was rejected before it started. The checksum worker now tries hash-wasm once and, if WebAssembly cannot be compiled, uses the pure JavaScript js-sha1 implementation instead. Both produce the same SHA1; the JavaScript one is about 7 times slower. Deployments that add 'wasm-unsafe-eval' to script-src get the fast path. --- packages/web-pkg/package.json | 1 + .../src/services/uppy/checksum/worker.ts | 30 ++++++++++++++++--- .../tests/unit/services/uppy/checksum.spec.ts | 28 +++++++++++++++++ pnpm-lock.yaml | 8 +++++ 4 files changed, 63 insertions(+), 4 deletions(-) diff --git a/packages/web-pkg/package.json b/packages/web-pkg/package.json index 14e8e14a266..d2b33f33a37 100644 --- a/packages/web-pkg/package.json +++ b/packages/web-pkg/package.json @@ -98,6 +98,7 @@ "fuse.js": "^7.1.0", "hash-wasm": "^4.12.0", "highlight.js": "^11.12.0", + "js-sha1": "^0.7.0", "lodash-es": "^4.17.23", "lowlight": "^3.3.0", "luxon": "^3.7.2", diff --git a/packages/web-pkg/src/services/uppy/checksum/worker.ts b/packages/web-pkg/src/services/uppy/checksum/worker.ts index 8df14ac925b..63dd9b347fc 100644 --- a/packages/web-pkg/src/services/uppy/checksum/worker.ts +++ b/packages/web-pkg/src/services/uppy/checksum/worker.ts @@ -1,23 +1,45 @@ import { createSHA1 } from 'hash-wasm' +import { sha1 } from 'js-sha1' import type { ChecksumWorkerRequest, ChecksumWorkerResponse } from './sha1' +type Hasher = { + update(data: Uint8Array): void + hex(): string +} + function post(message: ChecksumWorkerResponse) { postMessage(message) } +// hash-wasm is several times faster, but compiling WebAssembly needs 'wasm-unsafe-eval' in the +// Content-Security-Policy's script-src. Without it, compiling throws before any data is hashed, +// and the pure JavaScript implementation is used instead. Both produce the same SHA1. +async function createHasher(): Promise { + try { + const hasher = await createSHA1() + hasher.init() + return { update: (data) => hasher.update(data), hex: () => hasher.digest('hex') } + } catch (error) { + console.info( + `[UploadChecksum] WebAssembly is not available (${error instanceof Error ? error.message : error}), using the slower JavaScript SHA1` + ) + const hasher = sha1.create() + return { update: (data) => hasher.update(data), hex: () => hasher.hex() } + } +} + // Hashes the blob slice by slice, so that large files never have to be loaded into memory -// at once. crypto.subtle.digest cannot hash incrementally, so hash-wasm is used instead. +// at once. crypto.subtle.digest cannot hash incrementally. self.onmessage = async (e: MessageEvent) => { const { blob, chunkSize } = e.data try { - const hasher = await createSHA1() - hasher.init() + const hasher = await createHasher() for (let offset = 0; offset < blob.size; offset += chunkSize) { const end = Math.min(offset + chunkSize, blob.size) hasher.update(new Uint8Array(await blob.slice(offset, end).arrayBuffer())) post({ type: 'progress', bytesHashed: end }) } - post({ type: 'done', checksum: hasher.digest('hex') }) + post({ type: 'done', checksum: hasher.hex() }) } catch (error) { post({ type: 'error', message: error instanceof Error ? error.message : String(error) }) } diff --git a/packages/web-pkg/tests/unit/services/uppy/checksum.spec.ts b/packages/web-pkg/tests/unit/services/uppy/checksum.spec.ts index fb37a6e9d5a..87acb9cd958 100644 --- a/packages/web-pkg/tests/unit/services/uppy/checksum.spec.ts +++ b/packages/web-pkg/tests/unit/services/uppy/checksum.spec.ts @@ -78,6 +78,34 @@ describe('upload checksums', () => { }) }) + describe('when WebAssembly is blocked, e.g. by the Content-Security-Policy', () => { + beforeEach(() => { + const blocked = () => { + throw new Error('Compiling or instantiating WebAssembly module violates the CSP') + } + vi.stubGlobal('WebAssembly', { + compile: () => Promise.reject(new Error('blocked by CSP')), + instantiate: () => Promise.reject(new Error('blocked by CSP')), + Module: blocked, + Instance: blocked, + Memory: blocked + }) + vi.spyOn(console, 'info').mockImplementation(() => undefined) + }) + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('computes the same SHA1 with the JavaScript implementation', async () => { + const data = new Uint8Array(randomBytes(3 * 1024 + 17)) + + const checksum = await computeSha1(new Blob([data]), { chunkSize: 1024 }) + + expect(checksum).toBe(sha1Hex(data)) + expect(console.info).toHaveBeenCalledWith(expect.stringContaining('JavaScript SHA1')) + }) + }) + describe('UploadChecksumPlugin', () => { it('sets the checksum meta field before the upload starts', async () => { const { uppy, uploader } = createUppy() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 010ce0cfd1f..64d535eb547 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1101,6 +1101,9 @@ importers: highlight.js: specifier: ^11.12.0 version: 11.12.0 + js-sha1: + specifier: ^0.7.0 + version: 0.7.0 lodash-es: specifier: ^4.17.23 version: 4.18.1 @@ -3663,6 +3666,9 @@ packages: js-cookie@3.0.8: resolution: {integrity: sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==} + js-sha1@0.7.0: + resolution: {integrity: sha512-oQZ1Mo7440BfLSv9TX87VNEyU52pXPVG19F9PL3gTgNt0tVxlZ8F4O6yze3CLuLx28TxotxvlyepCNaaV0ZjMw==} + js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -7691,6 +7697,8 @@ snapshots: js-cookie@3.0.8: {} + js-sha1@0.7.0: {} + js-tokens@10.0.0: {} json-parse-even-better-errors@3.0.2: {}