stylis icon indicating copy to clipboard operation
stylis copied to clipboard

Potential collision in `hash`

Open SukkaW opened this issue 2 years ago • 8 comments

cc @thysultan

stylis contains a dead simple hash function used for matching CSS properties, and I am wondering how safe it is. So I write a small PoC:

const { hash, charat } = require('stylis');
const { all } = require('known-css-properties');

function djb2a (value, length) {
	let h = 5381;
	for (let i = 0; i < length; i++) {
		h = ((h << 5) + h) ^ charat(value, i)
	}
	return h >>> 0
}

function getCollidedFromHashMap (obj) {
  return Object.fromEntries(Object.entries(obj).filter(([key, value]) => value.length > 1));
};

const stylisHashMap1 = {};

all.forEach(property => {
  if (property.startsWith('-')) {
    const key = hash(property, property.length);
    stylisHashMap1[key] = stylisHashMap1[key] || [];
    stylisHashMap1[key].push(property);
  }
});

const stylisHashMap2 = {};

all.forEach(property => {
  if (!property.startsWith('-')) {
    const key = hash(property, property.length);
    stylisHashMap2[key] = stylisHashMap2[key] || [];
    stylisHashMap2[key].push(property);
  }
});

const djb2aHashMap = {};

all.forEach(property => {
  if (!property.startsWith('-')) {
    const key = djb2a(property, property.length);
    djb2aHashMap[key] = djb2aHashMap[key] || [];
    djb2aHashMap[key].push(property);
  }
});

console.log('(stylis) Collided all known css properties with vendor prefixed only:');
console.log(getCollidedFromHashMap(stylisHashMap1));
console.log('(stylis) Collided all known css properties without vendor prefixed:');
console.log(getCollidedFromHashMap(stylisHashMap2));
console.log('(djb2a) Collided all known css properties:');
console.log(getCollidedFromHashMap(djb2aHashMap));

You can test the PoC out at ReplIt: https://replit.com/@SukkaW/QuarterlyMotherlyCollaborativesoftware

The result is that the stylis' built-in hash is not safe at all. And honestly, that is not a surprising result. The current hash function only takes in the first, the second, and the third characters and the length into the account. So any CSS properties that have the same length and the first three characters are the same will collided.

E.g. flex-flow, flex-grow, and flex-wrap all have the same hash 6060, while the stylis is matching 6060 for flex-grow:

https://github.com/thysultan/stylis/blob/55c363f844a20b983d1dcee68be35716b393ee5d/src/Prefixer.js#L65-L67

And mask-(border|origin|repeat) all have the same hash 6135, but the stylis only need to prefix mask-(repeat|origin):

https://github.com/thysultan/stylis/blob/55c363f844a20b983d1dcee68be35716b393ee5d/src/Prefixer.js#L19-L20

And transform and translate all have the same hash 4810, but the stylis only need to prefix transform:

https://github.com/thysultan/stylis/blob/55c363f844a20b983d1dcee68be35716b393ee5d/src/Prefixer.js#L27-L29

And many other collisions, like:

  • scroll-margin-top and scroll-snap-align all have the same hash 2647 but the stylis only needs to prefix the scroll-margin-top.
  • scroll-margin-left, scroll-padding-top and scroll-snap-margin all have the same hash 2391 but the stylis only needs to prefix the scroll-margin-left.

SukkaW avatar Apr 24 '22 02:04 SukkaW

Is it only mask-border, scroll-snap-align, scroll-padding-top, scroll-snap-margin that are getting extra prefixes?

thysultan avatar Apr 24 '22 04:04 thysultan

Is it only mask-border, scroll-snap-align, scroll-padding-top, scroll-snap-margin that are getting extra prefixes?

I only select a few from those collided. There are probably more since my PoC doesn't filter them out.

SukkaW avatar Apr 24 '22 04:04 SukkaW

Hashing collisions are expected in this case. We should narrow it down to only the ones that are prefixed in the switch block and that shouldn't, and then evaluate if it is within acceptable tolerance.

thysultan avatar Apr 24 '22 10:04 thysultan

I am doing a custom prefixer and re-use hash() function, the collision happened there:

console.log('hash:background-clip', hash('background-clip', 'background-clip'.length)); // 4215
console.log('hash:backdrop-filter', hash('backdrop-filter', 'backdrop-filter'.length)); // 4215

I would like to prefix only backdrop-filter, but due hash collision both will be prefixed without additional checks.

layershifter avatar Nov 08 '23 11:11 layershifter

once u know about the collision you could always discriminate inputs based on a unique character in all candidates

Andarist avatar Nov 08 '23 11:11 Andarist