diff --git a/.changeset/nice-cherries-reply.md b/.changeset/nice-cherries-reply.md new file mode 100644 index 00000000000..2ca3223eee1 --- /dev/null +++ b/.changeset/nice-cherries-reply.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-solidity': minor +--- + +`Strings`: Add `espaceJSON` that escapes special characters in JSON strings. diff --git a/contracts/utils/Strings.sol b/contracts/utils/Strings.sol index 9e5f1877b99..7fa6f778aad 100644 --- a/contracts/utils/Strings.sol +++ b/contracts/utils/Strings.sol @@ -15,6 +15,14 @@ library Strings { bytes16 private constant HEX_DIGITS = "0123456789abcdef"; uint8 private constant ADDRESS_LENGTH = 20; + uint256 private constant SPECIAL_CHARS_LOOKUP = + (1 << 0x08) | // backspace + (1 << 0x09) | // tab + (1 << 0x0a) | // newline + (1 << 0x0c) | // form feed + (1 << 0x0d) | // carriage return + (1 << 0x22) | // double quote + (1 << 0x5c); // backslash /** * @dev The `value` string doesn't fit in the specified `length`. @@ -426,6 +434,41 @@ library Strings { return value; } + /** + * @dev Escape special characters in JSON strings. This can be useful to prevent JSON injection in NFT metadata. + */ + function escapeJSON(string memory input) internal pure returns (string memory) { + bytes memory buffer = bytes(input); + bytes memory output = new bytes(2 * buffer.length); // worst case scenario + uint256 outputLength = 0; + + for (uint256 i; i < buffer.length; ++i) { + bytes1 char = buffer[i]; + if (((SPECIAL_CHARS_LOOKUP & (1 << uint8(char))) != 0)) { + output[outputLength++] = "\\"; + if (char == 0x08) output[outputLength++] = "b"; + else if (char == 0x09) output[outputLength++] = "t"; + else if (char == 0x0A) output[outputLength++] = "n"; + else if (char == 0x0C) output[outputLength++] = "f"; + else if (char == 0x0D) output[outputLength++] = "r"; + else if (char == 0x5C) output[outputLength++] = "\\"; + else if (char == 0x22) { + // solhint-disable-next-line quotes + output[outputLength++] = '"'; + } + } else { + output[outputLength++] = char; + } + } + // write the actual length and deallocate unused memory + assembly ("memory-safe") { + mstore(output, outputLength) + mstore(0x40, add(output, shl(5, shr(5, add(outputLength, 63))))) + } + + return string(output); + } + /** * @dev Reads a bytes32 from a bytes array without bounds checking. * diff --git a/test/utils/Strings.test.js b/test/utils/Strings.test.js index 0b2d87190d3..8d5e2401ab1 100644 --- a/test/utils/Strings.test.js +++ b/test/utils/Strings.test.js @@ -339,4 +339,11 @@ describe('Strings', function () { } }); }); + + describe('Escape JSON string', function () { + for (const input of ['', 'a', '{"a":"b/c"}', 'a\tb\nc\\d"e\rf/g\fh\bi']) + it(`escape ${JSON.stringify(input)}`, async function () { + await expect(this.mock.$escapeJSON(input)).to.eventually.equal(JSON.stringify(input).slice(1, -1)); + }); + }); });