Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/evm-stringent-abi-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@aragon/gov-ui-kit": minor
---

Enforce EVM-stringent validation on `ProposalActionsDecoder` inputs: validate signed `int*` values (previously unvalidated), check that numeric values fit the bit-width of their type (e.g. `uint8` must be 0-255), verify EIP-55 address checksums (all-lowercase addresses are still accepted), and strip negative signs from unsigned number inputs. Adds `signedNumber` and `numberRange` messages to the `proposalActionsDecoder.validation` copy.
2 changes: 2 additions & 0 deletions src/modules/assets/copy/modulesCopy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ export const modulesCopy = {
address: (label: string) => `${label} is not a valid address.`,
bytes: (label: string) => `${label} is not a valid bytes value.`,
unsignedNumber: (label: string) => `${label} is not a valid uint value.`,
signedNumber: (label: string) => `${label} is not a valid int value.`,
numberRange: (label: string, type: string) => `${label} is out of range for ${type}.`,
},
},
proposalActionChangeMembers: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ describe('<ProposalActionsDecoderTextFieldEdit /> component', () => {
expect(onChange).toHaveBeenCalledWith('tru');
});

it('triggers the onChange callback removing the non-numberic values when type is a number', async () => {
it('triggers the onChange callback removing the non-numeric values and negative signs when type is an unsigned number', async () => {
const onChange = jest.fn();
const parameter = { name: 'uintParam', type: 'uint32', value: undefined };
const controllerReturn = {
Comment thread
harryburger marked this conversation as resolved.
Expand All @@ -143,6 +143,22 @@ describe('<ProposalActionsDecoderTextFieldEdit /> component', () => {
} as useFormContext.UseFormContextReturn);
render(createTestComponent({ parameter }));
await userEvent.type(screen.getByRole('textbox'), '1');
expect(onChange).toHaveBeenCalledWith('321');
});

it('triggers the onChange callback keeping one leading negative sign when type is a signed number', async () => {
const onChange = jest.fn();
const parameter = { name: 'intParam', type: 'int32', value: undefined };
const controllerReturn = {
fieldState: { error: undefined },
field: { value: 'ab--32.', onChange },
} as unknown as ReactHookForm.UseControllerReturn;
useControllerSpy.mockReturnValue(controllerReturn);
useFormContextSpy.mockReturnValue({
watch: () => controllerReturn.field.value as unknown,
} as useFormContext.UseFormContextReturn);
render(createTestComponent({ parameter }));
await userEvent.type(screen.getByRole('textbox'), '1');
expect(onChange).toHaveBeenCalledWith('-321');
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,9 @@ export const ProposalActionsDecoderTextFieldEdit: React.FC<IProposalActionsDecod
: newValue.toLocaleLowerCase();
onChange(parsedValue);
} else if (proposalActionsDecoderUtils.isNumberType(type)) {
// Allow only numbers and one "-" negative sign
const newValue = event.target.value.replace(/[^0-9-]/g, '').replace(/(?!^-)-/g, '');
// Allow only numbers and, for signed types only, one leading "-" negative sign
const negativeSignRegex = proposalActionsDecoderUtils.isSignedNumberType(type) ? /(?!^-)-/g : /-/g;
const newValue = event.target.value.replace(/[^0-9-]/g, '').replace(negativeSignRegex, '');
onChange(newValue);
} else {
onChange(event);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,17 @@ describe('ProposalActionsDecoder utils', () => {
const validateAddressSpy = jest.spyOn(addressUtils, 'isAddress');
const validateBytesSpy = jest.spyOn(proposalActionsDecoderUtils, 'validateBytes');
const validateUnsignedNumberSpy = jest.spyOn(proposalActionsDecoderUtils, 'validateUnsignedNumber');
const validateSignedNumberSpy = jest.spyOn(proposalActionsDecoderUtils, 'validateSignedNumber');
const validateNumberRangeSpy = jest.spyOn(proposalActionsDecoderUtils, 'validateNumberRange');

afterEach(() => {
validateRequiredSpy.mockReset();
validateBooleanSpy.mockReset();
validateAddressSpy.mockReset();
validateBytesSpy.mockReset();
validateUnsignedNumberSpy.mockReset();
validateSignedNumberSpy.mockReset();
validateNumberRangeSpy.mockReset();
});

afterAll(() => {
Expand All @@ -38,6 +42,8 @@ describe('ProposalActionsDecoder utils', () => {
validateAddressSpy.mockRestore();
validateBytesSpy.mockRestore();
validateUnsignedNumberSpy.mockRestore();
validateSignedNumberSpy.mockRestore();
validateNumberRangeSpy.mockRestore();
});

const buildValidateValueParams = (params?: Partial<IGetValidationRulesParams>): IGetValidationRulesParams => ({
Expand All @@ -49,6 +55,8 @@ describe('ProposalActionsDecoder utils', () => {
address: () => 'address-error',
bytes: () => 'bytes-error',
unsignedNumber: () => 'uint-error',
signedNumber: () => 'int-error',
numberRange: () => 'range-error',
},
...params,
});
Expand Down Expand Up @@ -119,6 +127,38 @@ describe('ProposalActionsDecoder utils', () => {
const params = buildValidateValueParams({ type: 'uint16' });
expect(proposalActionsDecoderUtils.validateValue('10', params)).toBeTruthy();
});

it('returns range error message when value has uint type and does not fit its bit-width', () => {
validateUnsignedNumberSpy.mockReturnValue(true);
validateNumberRangeSpy.mockReturnValue(false);
const params = buildValidateValueParams({ type: 'uint8' });
expect(proposalActionsDecoderUtils.validateValue('300', params)).toEqual('range-error');
});

it('returns int error message when value has int type and is not valid', () => {
validateSignedNumberSpy.mockReturnValue(false);
const params = buildValidateValueParams({ type: 'int256' });
expect(proposalActionsDecoderUtils.validateValue('-', params)).toEqual('int-error');
});

it('returns range error message when value has int type and does not fit its bit-width', () => {
validateSignedNumberSpy.mockReturnValue(true);
validateNumberRangeSpy.mockReturnValue(false);
const params = buildValidateValueParams({ type: 'int8' });
expect(proposalActionsDecoderUtils.validateValue('-129', params)).toEqual('range-error');
});

it('returns true when value has int type and is valid', () => {
const params = buildValidateValueParams({ type: 'int32' });
expect(proposalActionsDecoderUtils.validateValue('-5', params)).toBeTruthy();
});

it('validates addresses with strict checksum validation', () => {
const value = '0x0B2a45c2bCb56dA84920585f985087973c715364';
const params = buildValidateValueParams({ type: 'address' });
proposalActionsDecoderUtils.validateValue(value, params);
expect(validateAddressSpy).toHaveBeenCalledWith(value, { strict: true });
});
});

describe('validateRequired', () => {
Expand Down Expand Up @@ -179,6 +219,7 @@ describe('ProposalActionsDecoder utils', () => {
describe('validateUnsignedNumber', () => {
it('returns false when value is not a valid uint value', () => {
expect(proposalActionsDecoderUtils.validateUnsignedNumber(undefined)).toBeFalsy();
expect(proposalActionsDecoderUtils.validateUnsignedNumber('')).toBeFalsy();
expect(proposalActionsDecoderUtils.validateUnsignedNumber('-1')).toBeFalsy();
expect(proposalActionsDecoderUtils.validateUnsignedNumber('79.11')).toBeFalsy();
});
Expand All @@ -189,6 +230,52 @@ describe('ProposalActionsDecoder utils', () => {
});
});

describe('validateSignedNumber', () => {
it('returns false when value is not a valid int value', () => {
expect(proposalActionsDecoderUtils.validateSignedNumber(undefined)).toBeFalsy();
expect(proposalActionsDecoderUtils.validateSignedNumber('')).toBeFalsy();
expect(proposalActionsDecoderUtils.validateSignedNumber('-')).toBeFalsy();
expect(proposalActionsDecoderUtils.validateSignedNumber('79.11')).toBeFalsy();
expect(proposalActionsDecoderUtils.validateSignedNumber('1-2')).toBeFalsy();
});
Comment thread
Copilot marked this conversation as resolved.

it('returns true when value is a valid int value', () => {
expect(proposalActionsDecoderUtils.validateSignedNumber('-1')).toBeTruthy();
expect(proposalActionsDecoderUtils.validateSignedNumber('0')).toBeTruthy();
expect(proposalActionsDecoderUtils.validateSignedNumber('8645312')).toBeTruthy();
});
});

describe('validateNumberRange', () => {
it('returns false when value does not fit the bit-width of the type', () => {
expect(proposalActionsDecoderUtils.validateNumberRange('uint8', '256')).toBeFalsy();
expect(proposalActionsDecoderUtils.validateNumberRange('uint16', '65536')).toBeFalsy();
expect(proposalActionsDecoderUtils.validateNumberRange('int8', '128')).toBeFalsy();
expect(proposalActionsDecoderUtils.validateNumberRange('int8', '-129')).toBeFalsy();
expect(proposalActionsDecoderUtils.validateNumberRange('uint', '-1')).toBeFalsy();
expect(proposalActionsDecoderUtils.validateNumberRange('uint256', (2n ** 256n).toString())).toBeFalsy();
});

it('returns true when value fits the bit-width of the type', () => {
expect(proposalActionsDecoderUtils.validateNumberRange('uint8', '255')).toBeTruthy();
expect(proposalActionsDecoderUtils.validateNumberRange('int8', '-128')).toBeTruthy();
expect(proposalActionsDecoderUtils.validateNumberRange('int8', '127')).toBeTruthy();
expect(proposalActionsDecoderUtils.validateNumberRange('uint', (2n ** 256n - 1n).toString())).toBeTruthy();
expect(proposalActionsDecoderUtils.validateNumberRange('int', (-(2n ** 255n)).toString())).toBeTruthy();
});

it('returns false when value cannot be parsed as an integer', () => {
expect(proposalActionsDecoderUtils.validateNumberRange('uint8', 'abc')).toBeFalsy();
});

it('returns false when the type has an invalid bit-width', () => {
expect(proposalActionsDecoderUtils.validateNumberRange('uint0', '0')).toBeFalsy();
expect(proposalActionsDecoderUtils.validateNumberRange('uint12', '0')).toBeFalsy();
expect(proposalActionsDecoderUtils.validateNumberRange('uint264', '0')).toBeFalsy();
expect(proposalActionsDecoderUtils.validateNumberRange('int9999', '0')).toBeFalsy();
});
});

describe('isArrayType', () => {
it('returns false when type is not an array type', () => {
expect(proposalActionsDecoderUtils.isArrayType('uint')).toBeFalsy();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ export type NestedProposalActionFormValues = DeepPartial<IProposalAction> | Reco

class ProposalActionsDecoderUtils {
private bytesRegex = /^0x[0-9a-fA-F]*$/;
private unsignedNumberRegex = /^[0-9]*$/;
private unsignedNumberRegex = /^[0-9]+$/;
private signedNumberRegex = /^-?[0-9]+$/;

Comment thread
harryburger marked this conversation as resolved.
getFieldName = (name: string, prefix?: string) => (prefix == null ? name : `${prefix}.${name}`);

Expand All @@ -42,13 +43,22 @@ class ProposalActionsDecoderUtils {
return this.validateBoolean(value) || errorMessages.boolean(label);
}
if (type === 'address') {
return addressUtils.isAddress(value?.toString()) || errorMessages.address(label);
return addressUtils.isAddress(value?.toString(), { strict: true }) || errorMessages.address(label);
}
if (type.startsWith('bytes')) {
return this.validateBytes(type, value) || errorMessages.bytes(label);
}
if (this.isUnsignedNumberType(type)) {
return this.validateUnsignedNumber(value) || errorMessages.unsignedNumber(label);
if (!this.validateUnsignedNumber(value)) {
return errorMessages.unsignedNumber(label);
}
return this.validateNumberRange(type, value) || errorMessages.numberRange(label, type);
}
if (this.isSignedNumberType(type)) {
if (!this.validateSignedNumber(value)) {
return errorMessages.signedNumber(label);
}
return this.validateNumberRange(type, value) || errorMessages.numberRange(label, type);
}

return undefined;
Expand All @@ -75,6 +85,35 @@ class ProposalActionsDecoderUtils {
validateUnsignedNumber = (value?: ProposalActionsFieldValue): boolean =>
value != null && this.unsignedNumberRegex.test(value.toString());

validateSignedNumber = (value?: ProposalActionsFieldValue): boolean =>
value != null && this.signedNumberRegex.test(value.toString());

// Checks that the value fits the bit-width of the number type (e.g. 0 to 2^8-1 for uint8, -2^7 to 2^7-1 for int8).
// Only call after the value passed the unsigned / signed number format validation.
validateNumberRange = (type: string, value?: ProposalActionsFieldValue): boolean => {
const isSigned = this.isSignedNumberType(type);
const sizeString = type.slice(isSigned ? 3 : 4);
const size = sizeString.length === 0 ? 256 : Number.parseInt(sizeString, 10);

// ABI integer sizes are limited to 8..256 bits in steps of 8 (bare uint / int alias to 256 bits).
if (Number.isNaN(size) || size < 8 || size > 256 || size % 8 !== 0) {
return false;
}

Comment thread
harryburger marked this conversation as resolved.
let parsedValue: bigint;
try {
parsedValue = BigInt(value?.toString() ?? '');
} catch {
return false;
}

const bits = BigInt(size);
const minValue = isSigned ? -(2n ** (bits - 1n)) : 0n;
const maxValue = isSigned ? 2n ** (bits - 1n) - 1n : 2n ** bits - 1n;

return parsedValue >= minValue && parsedValue <= maxValue;
};

isArrayType = (type: string) => type.endsWith('[]');

isTupleType = (type: string) => type === 'tuple';
Expand Down
Loading