openlang

Типизированный merge всё равно заходит в `__proto__`

Импорт настроек использует небольшой рекурсивный merge. Типы параметров выглядят аккуратно, но описывают лишь то, чего ждёт программа. Цикл проходит по тем ключам, которые реально есть в разобранном файле.

type Settings = { theme: "light" | "dark"; fontSize: number };

function merge(target: any, src: any): void {
  for (const key of Object.keys(src)) {
    const v = src[key];
    if (typeof v === "object" && v !== null) {
      target[key] ??= {};
      merge(target[key], v);
    } else {
      target[key] = v;
    }
  }
}

const saved: Partial<Settings> = JSON.parse('{"__proto__": {"isAdmin": true}}');
merge({}, saved);
console.log(({} as any).isAdmin); // true

JSON.parse определяет __proto__ как обычное собственное свойство, и Object.keys его возвращает. Дальше target[key] на обычном объекте читает унаследованный аксессор и получает Object.prototype, ??= его не трогает, потому что это не nullish, а рекурсивный вызов записывает isAdmin в прототип, общий для всех объектов. Аннотация Partial<Settings> прошла без единого слова, ведь JSON.parse возвращает any.

Есть два исправления на выбор. Пропускать опасные имена ключей внутри цикла или строить каждый уровень цели через Object.create(null), чтобы не было аксессора, на который можно наткнуться. Второе кажется основательнее, но тогда у каждого объекта, который получает код ниже, нет toString. Кто-нибудь долго жил с версией на null-прототипе?

Разговор продолжается 2

Следуя доводам
  1. Ссылка на ответ

    Пропуск имён превращается в список, который никогда не закончен. Проверено. Пропустите __proto__, и файл с {"constructor": {"prototype": {"isAdmin": true}}} всё равно попадает в Object.prototype, потому что target.constructor у обычного объекта - это Object, а его prototype как раз общий. Теперь в списке три имени, и что на этом всё, мне не верится.

    Со стороны экрана это вообще странно. Диалог импорта показывает две настройки, тему и размер шрифта. Пользователь выбирает файл, диалог пишет "Импортировано", а нужны были всего два ключа. Прочитайте эти два из разобранного объекта, проверьте каждый, остальное выбросьте. Тогда ничто не ходит по ключам, о которых экран не знает.

  2. Ссылка на ответ

    Для темы и размера шрифта так и сделано, декодер читает два известных ключа и игнорирует всё остальное. Но одна часть файла в эту форму не влезает. shortcuts связывает имена, которые выбрал пользователь, с сочетаниями клавиш, так что списка ключей для чтения нет. Эта часть теперь идёт в Map.

    const shortcuts = new Map<string, string>(
      Object.entries(raw.shortcuts).filter(
        (e): e is [string, string] => typeof e[1] === "string",
      ),
    );

    Сочетание с именем __proto__ - просто запись, shortcuts.get("__proto__") возвращает её значение, и прототип ни при чём. Из пути импорта merge убран.

Другие стороны того же вопроса

  1. Аксиомы чужой теоремы стоит прочитать до того, как на неё ссылаться

    Теорема, которая компилируется, ещё не теорема с доказательством. sorry закрывает любую цель, и Lean принимает файл с предупр…

    Ответы 1lean-4
  2. Тест передачи через канал Go без sleep

    Большинство тестов передачи, которые мне попадаются, кончаются sleep и проверкой на удачу. Этот обходится без них. Запрос нес…

    Ответы 23go
  3. Почему компилятор удалил мою проверку знакового переполнения?

    if (x + 1 < x) для знакового int не защищает от переполнения. Знаковое переполнение в C ведёт к неопределённому поведению, по…

    Ответы 4c