From 246c3023af8dc18f4e737829c1ff31d53bcf9877 Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Tue, 18 Aug 2026 18:07:31 +0200 Subject: [PATCH 1/3] fix(datasource-customizer): reject a non data-uri filter value on a binary field --- .../src/decorators/binary/collection.ts | 19 +++++++++++++++++-- .../test/decorators/binary/collection.test.ts | 15 +++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/packages/datasource-customizer/src/decorators/binary/collection.ts b/packages/datasource-customizer/src/decorators/binary/collection.ts index 2f8652acbc..195e4c3ecd 100644 --- a/packages/datasource-customizer/src/decorators/binary/collection.ts +++ b/packages/datasource-customizer/src/decorators/binary/collection.ts @@ -16,7 +16,12 @@ import type { RecordData, } from '@forestadmin/datasource-toolkit'; -import { CollectionDecorator, SchemaUtils, TypeGetter } from '@forestadmin/datasource-toolkit'; +import { + CollectionDecorator, + SchemaUtils, + TypeGetter, + ValidationError, +} from '@forestadmin/datasource-toolkit'; import { filetypemime } from 'magic-bytes.js'; /** @@ -245,7 +250,17 @@ export default class BinaryCollectionDecorator extends CollectionDecorator { if (toBackend) { const string = value as string; - return useHex ? Buffer.from(string, 'hex') : Buffer.from(string.split(',')[1], 'base64'); + if (useHex) return Buffer.from(string, 'hex'); + + const payload = string.split(',')[1]; + + if (payload === undefined) { + throw new ValidationError( + `Expected a data uri of the form "data:;base64,", received "${string}"`, + ); + } + + return Buffer.from(payload, 'base64'); } const buffer = value as Buffer; diff --git a/packages/datasource-customizer/test/decorators/binary/collection.test.ts b/packages/datasource-customizer/test/decorators/binary/collection.test.ts index 5c773ec2a3..1219c24ef5 100644 --- a/packages/datasource-customizer/test/decorators/binary/collection.test.ts +++ b/packages/datasource-customizer/test/decorators/binary/collection.test.ts @@ -8,6 +8,7 @@ import { Filter, PaginatedFilter, Projection, + ValidationError, } from '@forestadmin/datasource-toolkit'; import * as factories from '@forestadmin/datasource-toolkit/dist/test/__factories__'; @@ -238,6 +239,20 @@ describe('BinaryCollectionDecorator', () => { }); }); + describe('list filtering a binary column with a value that is not a data uri', () => { + it('should reject the filter instead of crashing on an undefined base64 payload', async () => { + const caller = factories.caller.build(); + const filter = new PaginatedFilter({ + conditionTree: new ConditionTreeLeaf('cover', 'Equal', 'Anthony'), + }); + + await expect(decoratedBook.list(caller, filter, new Projection('id'))).rejects.toThrow( + ValidationError, + ); + expect(books.list).not.toHaveBeenCalled(); + }); + }); + describe('list with a more complex filter', () => { // Build condition tree (30303030 is the hex representation of 0000) const conditionTree = new ConditionTreeBranch('Or', [ From 9f56533616bfbd7966a8fb1aed0003b3140f838e Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Tue, 18 Aug 2026 18:09:23 +0200 Subject: [PATCH 2/3] refactor(datasource-customizer): parse binary filter values with the shared data-uri parser --- .../src/decorators/binary/collection.ts | 12 ++---------- .../test/decorators/binary/collection.test.ts | 12 ++++++++++++ 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/datasource-customizer/src/decorators/binary/collection.ts b/packages/datasource-customizer/src/decorators/binary/collection.ts index 195e4c3ecd..9e498103f0 100644 --- a/packages/datasource-customizer/src/decorators/binary/collection.ts +++ b/packages/datasource-customizer/src/decorators/binary/collection.ts @@ -20,7 +20,7 @@ import { CollectionDecorator, SchemaUtils, TypeGetter, - ValidationError, + parseDataUri, } from '@forestadmin/datasource-toolkit'; import { filetypemime } from 'magic-bytes.js'; @@ -252,15 +252,7 @@ export default class BinaryCollectionDecorator extends CollectionDecorator { if (useHex) return Buffer.from(string, 'hex'); - const payload = string.split(',')[1]; - - if (payload === undefined) { - throw new ValidationError( - `Expected a data uri of the form "data:;base64,", received "${string}"`, - ); - } - - return Buffer.from(payload, 'base64'); + return parseDataUri(string).buffer; } const buffer = value as Buffer; diff --git a/packages/datasource-customizer/test/decorators/binary/collection.test.ts b/packages/datasource-customizer/test/decorators/binary/collection.test.ts index 1219c24ef5..66fd48b455 100644 --- a/packages/datasource-customizer/test/decorators/binary/collection.test.ts +++ b/packages/datasource-customizer/test/decorators/binary/collection.test.ts @@ -251,6 +251,18 @@ describe('BinaryCollectionDecorator', () => { ); expect(books.list).not.toHaveBeenCalled(); }); + + it('should reject a data uri that is not base64, rather than decode it to garbage', async () => { + const caller = factories.caller.build(); + const filter = new PaginatedFilter({ + conditionTree: new ConditionTreeLeaf('cover', 'Equal', 'data:text/plain,hello'), + }); + + await expect(decoratedBook.list(caller, filter, new Projection('id'))).rejects.toThrow( + ValidationError, + ); + expect(books.list).not.toHaveBeenCalled(); + }); }); describe('list with a more complex filter', () => { From 88c00369e787b7815a753686ad8aefab5f453f6d Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Tue, 18 Aug 2026 18:21:32 +0200 Subject: [PATCH 3/3] fix(datasource-customizer): reject a malformed hex or non-string binary value --- .../src/decorators/binary/collection.ts | 21 +++++++++-- .../test/decorators/binary/collection.test.ts | 36 ++++++++++++++++++- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/packages/datasource-customizer/src/decorators/binary/collection.ts b/packages/datasource-customizer/src/decorators/binary/collection.ts index 9e498103f0..b1e17dedb5 100644 --- a/packages/datasource-customizer/src/decorators/binary/collection.ts +++ b/packages/datasource-customizer/src/decorators/binary/collection.ts @@ -20,6 +20,7 @@ import { CollectionDecorator, SchemaUtils, TypeGetter, + ValidationError, parseDataUri, } from '@forestadmin/datasource-toolkit'; import { filetypemime } from 'magic-bytes.js'; @@ -242,17 +243,31 @@ export default class BinaryCollectionDecorator extends CollectionDecorator { return value; } + private parseHex(value: string): Buffer { + if (!/^([0-9a-f]{2})*$/i.test(value)) { + throw new ValidationError( + `Expected an even-length hex string for a binary field, got "${value}"`, + ); + } + + return Buffer.from(value, 'hex'); + } + private async convertScalar( toBackend: boolean, useHex: boolean, value: unknown, ): Promise { if (toBackend) { - const string = value as string; + if (typeof value !== 'string') { + throw new ValidationError( + `Expected a string for a binary field, got ${typeof value}: ${JSON.stringify(value)}`, + ); + } - if (useHex) return Buffer.from(string, 'hex'); + if (useHex) return this.parseHex(value); - return parseDataUri(string).buffer; + return parseDataUri(value).buffer; } const buffer = value as Buffer; diff --git a/packages/datasource-customizer/test/decorators/binary/collection.test.ts b/packages/datasource-customizer/test/decorators/binary/collection.test.ts index 66fd48b455..99afb9cb84 100644 --- a/packages/datasource-customizer/test/decorators/binary/collection.test.ts +++ b/packages/datasource-customizer/test/decorators/binary/collection.test.ts @@ -259,10 +259,44 @@ describe('BinaryCollectionDecorator', () => { }); await expect(decoratedBook.list(caller, filter, new Projection('id'))).rejects.toThrow( - ValidationError, + /must be a data uri/, + ); + expect(books.list).not.toHaveBeenCalled(); + }); + }); + + describe('list filtering a hex binary column with a value that is not hex', () => { + it('should reject it rather than query for an empty buffer', async () => { + const caller = factories.caller.build(); + const filter = new PaginatedFilter({ + conditionTree: new ConditionTreeLeaf('id', 'Equal', 'Anthony'), + }); + + await expect(decoratedBook.list(caller, filter, new Projection('id'))).rejects.toThrow( + /even-length hex string/, ); expect(books.list).not.toHaveBeenCalled(); }); + + it('should reject an odd-length hex string, which silently drops its last digit', async () => { + const caller = factories.caller.build(); + const filter = new PaginatedFilter({ + conditionTree: new ConditionTreeLeaf('id', 'Equal', '303'), + }); + + await expect(decoratedBook.list(caller, filter, new Projection('id'))).rejects.toThrow( + /even-length hex string/, + ); + }); + }); + + describe('writing a non-string value into a binary column', () => { + it('should reject it with a validation error rather than an opaque TypeError', async () => { + const caller = factories.caller.build(); + + await expect(decoratedBook.create(caller, [{ cover: 42 }])).rejects.toThrow(ValidationError); + expect(books.create).not.toHaveBeenCalled(); + }); }); describe('list with a more complex filter', () => {