Skip to content

Commit 6a26ad6

Browse files
committed
Add integrity check to webresources
Change-type: patch
1 parent e3d9eb7 commit 6a26ad6

File tree

4 files changed

+136
-13
lines changed

4 files changed

+136
-13
lines changed

package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,13 @@
6868
"typed-error": "^3.2.2"
6969
},
7070
"devDependencies": {
71+
"@aws-crypto/crc32": "^5.2.0",
72+
"@aws-crypto/crc32c": "^5.2.0",
73+
"@aws-sdk/crc64-nvme-crt": "^3.816.0",
74+
"@aws-sdk/types": "^3.804.0",
7175
"@balena/lint": "^9.1.6",
7276
"@balena/pinejs": "file:./",
73-
"@balena/pinejs-webresource-s3": "^2.1.1",
77+
"@balena/pinejs-webresource-s3": "2.2.0-build-experiment-with-integrity-checks-baf4fcd6a4847aa7e8ed4123a8ac3f1c74216398-1",
7478
"@faker-js/faker": "^9.6.0",
7579
"@types/busboy": "^1.5.4",
7680
"@types/chai": "^5.2.1",

src/webresource-handler/actions/commitUpload.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,14 @@ import {
1111
import type { Response } from '../../sbvr-api/sbvr-utils.js';
1212
import { api } from '../../sbvr-api/sbvr-utils.js';
1313
import { permissions } from '../../server-glue/module.js';
14+
import { getChecksumParams } from '../index.js';
1415
import { getMultipartUploadHandler } from '../multipartUpload.js';
1516

1617
const commitUploadAction = async ({
1718
request,
1819
tx,
1920
id,
21+
req,
2022
api: applicationApi,
2123
}: ODataActionArgs): Promise<Response> => {
2224
if (typeof id !== 'number') {
@@ -28,11 +30,14 @@ const commitUploadAction = async ({
2830
const multipartUpload = await getOngoingUpload(request, id, tx);
2931
const handler = getMultipartUploadHandler();
3032

33+
// @ts-expect-error - req.headers is there, we just need to tell them
34+
const checksumPayload = getChecksumParams(req.headers);
3135
const webresource = await handler.multipartUpload.commit({
3236
fileKey: multipartUpload.fileKey,
3337
uploadId: multipartUpload.uploadId,
3438
filename: multipartUpload.filename,
3539
providerCommitData: multipartUpload.providerCommitData,
40+
...checksumPayload,
3641
});
3742

3843
await Promise.all([

src/webresource-handler/index.ts

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,40 @@ import type WebresourceModel from './webresource.js';
2222
import { isMultipartUploadAvailable } from './multipartUpload.js';
2323
import { addAction } from '../sbvr-api/actions.js';
2424
import { beginUpload, commitUpload, cancelUpload } from './actions/index.js';
25+
import type { IncomingHttpHeaders } from 'node:http';
2526

2627
export * from './handlers/index.js';
2728

28-
export interface IncomingFile {
29+
// S3 only supports full checksums (on multipart uploads) for these algorithms
30+
// See: https://docs.aws.amazon.com/AmazonS3/latest/userguide/checking-object-integrity.html#ChecksumTypes
31+
export const supportedChecksumAlgorithms = [
32+
'CRC64NVME',
33+
'CRC32',
34+
'CRC32C',
35+
] as const;
36+
export type SupportedChecksumAlgorithm =
37+
(typeof supportedChecksumAlgorithms)[number];
38+
39+
type ChecksumPayload =
40+
| {
41+
checksum?: undefined;
42+
checksumAlgorithm?: undefined;
43+
}
44+
| {
45+
checksum: string;
46+
checksumAlgorithm: SupportedChecksumAlgorithm;
47+
};
48+
49+
export type IncomingFile = {
2950
fieldname: string;
3051
originalname: string;
3152
encoding: string;
3253
mimetype: string;
3354
stream: stream.Readable;
34-
}
55+
} & ChecksumPayload;
56+
57+
export const checksumHeaderName = 'x-pinejs-checksum';
58+
export const checksumAlgorithmHeaderName = 'x-pinejs-checksum-algorithm';
3559

3660
export interface UploadResponse {
3761
size: number;
@@ -57,12 +81,12 @@ export interface BeginMultipartUploadHandlerResponse {
5781
uploadId: string;
5882
}
5983

60-
export interface CommitMultipartUploadPayload {
84+
export type CommitMultipartUploadPayload = {
6185
fileKey: string;
6286
uploadId: string;
6387
filename: string;
6488
providerCommitData?: Record<string, any>;
65-
}
89+
} & ChecksumPayload;
6690

6791
export interface CancelMultipartUploadPayload {
6892
fileKey: string;
@@ -159,6 +183,31 @@ const getRequestUploadValidator = async (
159183
};
160184
};
161185

186+
export const getChecksumParams = (headers: IncomingHttpHeaders) => {
187+
const checksum = headers[checksumHeaderName];
188+
const checksumAlgorithm = headers[checksumAlgorithmHeaderName] as
189+
| SupportedChecksumAlgorithm
190+
| undefined;
191+
192+
if (checksum == null || checksumAlgorithm == null) {
193+
return { checksum: undefined, checksumAlgorithm: undefined };
194+
}
195+
196+
if (typeof checksum !== 'string' || typeof checksumAlgorithm !== 'string') {
197+
throw new errors.BadRequestError(
198+
`Invalid ${checksumHeaderName} or ${checksumAlgorithmHeaderName} header`,
199+
);
200+
}
201+
202+
if (!supportedChecksumAlgorithms.includes(checksumAlgorithm)) {
203+
throw new errors.BadRequestError(
204+
`Invalid ${checksumAlgorithmHeaderName} header value: ${checksumAlgorithm}`,
205+
);
206+
}
207+
208+
return { checksum, checksumAlgorithm };
209+
};
210+
162211
export const getUploaderMiddlware = (
163212
handler: WebResourceHandler,
164213
): Express.RequestHandler => {
@@ -218,14 +267,17 @@ export const getUploaderMiddlware = (
218267
filestream.resume();
219268
return;
220269
}
270+
const checksumPayload = getChecksumParams(req.headers);
221271
const file: IncomingFile = {
222272
originalname: info.filename,
223273
encoding: info.encoding,
224274
mimetype: info.mimeType,
225275
stream: filestream,
226276
fieldname,
277+
...checksumPayload,
227278
};
228279
const result = await handler.handleFile(file);
280+
229281
req.body[fieldname] = {
230282
filename: info.filename,
231283
content_type: info.mimeType,

test/06-webresource.test.ts

Lines changed: 70 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,16 @@ import {
2222
import { intVar, requiredVar } from '@balena/env-parsing';
2323
import { assertExists } from './lib/common.js';
2424
import { PineTest } from 'pinejs-client-supertest';
25-
import type { UploadPart } from '../out/webresource-handler/index.js';
25+
import {
26+
type SupportedChecksumAlgorithm,
27+
supportedChecksumAlgorithms,
28+
type UploadPart,
29+
} from '../out/webresource-handler/index.js';
30+
import { CrtCrc64Nvme } from '@aws-sdk/crc64-nvme-crt';
31+
import { AwsCrc32 } from '@aws-crypto/crc32';
32+
import { AwsCrc32c } from '@aws-crypto/crc32c';
33+
import type { Checksum } from '@aws-sdk/types';
34+
import { setTimeout } from 'timers/promises';
2635

2736
const pipeline = util.promisify(pipelineRaw);
2837

@@ -122,6 +131,41 @@ describe('06 webresources tests', function () {
122131
);
123132
});
124133

134+
supportedChecksumAlgorithms.forEach((checksumAlgorithm) => {
135+
it(`accepts a checksum ${checksumAlgorithm} header ${resourcePath}`, async () => {
136+
const { body: organization } = await supertest(testLocalServer)
137+
.post(`/${resourceName}/organization`)
138+
.set('x-pinejs-checksum-algorithm', checksumAlgorithm)
139+
.set(
140+
'x-pinejs-checksum',
141+
await getChecksum(checksumAlgorithm, filePath),
142+
)
143+
.field('name', 'John')
144+
.attach(resourcePath, filePath, { filename, contentType })
145+
.expect(201);
146+
147+
expect(organization[resourcePath].size).to.equals(fileSize);
148+
expect(organization[resourcePath].filename).to.equals(filename);
149+
expect(organization[resourcePath].content_type).to.equals(
150+
contentType,
151+
);
152+
});
153+
});
154+
155+
supportedChecksumAlgorithms.forEach((checksumAlgorithm) => {
156+
it(`fails if checksum ${checksumAlgorithm} is wrong`, async () => {
157+
await supertest(testLocalServer)
158+
.post(`/${resourceName}/organization`)
159+
.set('x-pinejs-checksum-algorithm', checksumAlgorithm)
160+
.set('x-pinejs-checksum', 'abc')
161+
.field('name', 'John')
162+
.attach(resourcePath, filePath, { filename, contentType })
163+
.expect(400);
164+
165+
expect(await isBucketEventuallyEmpty()).to.be.true;
166+
});
167+
});
168+
125169
it(`does not store ${resourcePath} if is bigger than PINEJS_WEBRESOURCE_MAXFILESIZE`, async () => {
126170
const { largeStream } = await getLargeFileStream(
127171
intVar('PINEJS_WEBRESOURCE_MAXFILESIZE') + 10 * 1024 * 1024,
@@ -1405,14 +1449,12 @@ const getKeyFromHref = (href: string): string => {
14051449
return splittedHref[splittedHref.length - 1];
14061450
};
14071451

1408-
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
1409-
14101452
const isBucketEventuallyEmpty = async (attempts = 5, retryDelay = 1000) => {
14111453
for (let attempt = 0; attempt < attempts; attempt++) {
14121454
if ((await listAllFilesInBucket()).length === 0) {
14131455
return true;
14141456
}
1415-
await delay(retryDelay);
1457+
await setTimeout(retryDelay);
14161458
}
14171459

14181460
return false;
@@ -1430,7 +1472,7 @@ const isEventuallyDeleted = async (
14301472
if (!fileExists) {
14311473
return true;
14321474
}
1433-
await delay(retryDelay);
1475+
await setTimeout(retryDelay);
14341476
}
14351477

14361478
return false;
@@ -1557,8 +1599,8 @@ const awaitForDeletionTasks = async (
15571599
initialDelay = 500,
15581600
) => {
15591601
// Even the creation of deletion tasks happens in second plan
1560-
// so we give it a initial delay for the tasks to be created
1561-
await delay(initialDelay);
1602+
// so we give it a initial setTimeout for the tasks to be created
1603+
await setTimeout(initialDelay);
15621604
for (let i = 0; i < attempts; i++) {
15631605
const { body: pendingDeletions } = await pineTask.get({
15641606
resource: 'task',
@@ -1575,7 +1617,7 @@ const awaitForDeletionTasks = async (
15751617
return;
15761618
}
15771619

1578-
await delay(retryDelay);
1620+
await setTimeout(retryDelay);
15791621
}
15801622

15811623
throw new Error('Failed to await for webresource deletions');
@@ -1650,3 +1692,23 @@ const uploadParts = async (parts: UploadPart[]) => {
16501692
})),
16511693
};
16521694
};
1695+
1696+
const getChecksum = async (
1697+
algorithm: SupportedChecksumAlgorithm,
1698+
filePath: string,
1699+
): Promise<string> => {
1700+
const fileBuffer = await fs.readFile(filePath);
1701+
let calculate: Checksum;
1702+
if (algorithm === 'CRC32') {
1703+
calculate = new AwsCrc32();
1704+
} else if (algorithm === 'CRC32C') {
1705+
calculate = new AwsCrc32c();
1706+
} else if (algorithm === 'CRC64NVME') {
1707+
calculate = new CrtCrc64Nvme();
1708+
} else {
1709+
throw new Error(`Unsupported algorithm: ${algorithm}`);
1710+
}
1711+
1712+
calculate.update(fileBuffer);
1713+
return Buffer.from(await calculate.digest()).toString('base64');
1714+
};

0 commit comments

Comments
 (0)