openlang

A typed merge helper still walks into `__proto__`

The settings import uses a small recursive merge. The parameter types look careful, but they only describe what the program expects. The loop walks whatever keys the parsed file actually has.

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 defines __proto__ as an ordinary own property, so Object.keys returns it. Then target[key] on a plain object reads the inherited accessor and gets Object.prototype, the ??= leaves it alone because it isn't nullish, and the recursive call writes isAdmin onto the prototype every object shares. The Partial<Settings> annotation went through without a word, since JSON.parse returns any.

Two fixes I'm weighing. Skip the dangerous key names inside the loop, or build every level of the target with Object.create(null) so there's no accessor to hit. The second feels more thorough, and every object downstream code receives then has no toString. Has anyone kept the null-prototype version for long?

The conversation continues 2

Follow the reasoning
  1. Permalink

    Skipping names turns into a list that's never finished. Tried it. Skip __proto__ and a file holding {"constructor": {"prototype": {"isAdmin": true}}} still lands on Object.prototype, because target.constructor on a plain object is Object and its prototype is the shared one. Now the list has three names and I don't trust it to stop there.

    From the screen side the whole thing is odd anyway. The import dialog shows two settings, theme and font size. User picks a file, the dialog says "Imported", and it only ever needed two keys. Read those two out of the parsed object, check each, drop the rest. Nothing walks keys the screen doesn't know about.

  2. Permalink

    Did that for theme and font size, a decoder that reads the two known keys and ignores everything else. One part of the file doesn't fit that shape, though. shortcuts maps names the user chose to key combinations, so there's no list of keys to read. That part now goes into a Map.

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

    A shortcut named __proto__ is just an entry, shortcuts.get("__proto__") returns its value, and no prototype is involved. The merge helper is gone from the import path.

Other sides of the same question

  1. Read the axioms of a dependency's theorem before you cite it

    A theorem that compiles is not yet a theorem with a proof. sorry closes any goal, and Lean accepts the file with a warning. I…

    Replies 1lean-4
  2. Testing a Go channel handoff without sleep

    Most handoff tests I run into end with a sleep and a hopeful assertion. This one can do without. A request carries an identif…

    Replies 23go
  3. Why did the compiler delete my signed overflow check?

    if (x + 1 < x) on a signed int is not an overflow guard. Signed overflow is undefined behavior in C, so the compiler may assu…

    Replies 4c