DEV Community

Cover image for Building session replay that masks input data by default
Zenovay
Zenovay

Posted on

Building session replay that masks input data by default

Session replay sounds scary because the naive version records everything a user types, including passwords and personal data. We built ours to mask sensitive input by default, so the safe path is the default path. Here is the approach.

What session replay actually is

It is not a video. Recording video would be huge and would capture everything. Instead you record the DOM and its mutations, then replay them in an iframe later. Much smaller, and crucially, you control what gets captured.

Step 1: serialize the initial DOM, masking as you go

function serializeNode(node: Node): SerializedNode {
  if (node.nodeType === Node.TEXT_NODE) {
    return { type: "text", value: node.textContent ?? "" };
  }
  const el = node as Element;
  const tag = el.tagName.toLowerCase();
  const attrs: Record<string, string> = {};
  for (const a of Array.from(el.attributes)) attrs[a.name] = a.value;
  if (tag === "input" || tag === "textarea") {
    attrs["value"] = maskValue((el as HTMLInputElement).value);
  }
  return { type: "element", tag, attrs, children: Array.from(el.childNodes).map(serializeNode) };
}
Enter fullscreen mode Exit fullscreen mode

Step 2: the masking rule is opt out, not opt in

function maskValue(value: string): string {
  // default: never record raw input. replace with a same length placeholder
  return "x".repeat(value.length);
}

const NEVER_RECORD = ["password", "email", "tel", "credit", "card", "ssn", "cvc"];

function shouldDrop(el: HTMLInputElement): boolean {
  const hay = (el.type + " " + el.name + " " + el.id + " " + el.autocomplete).toLowerCase();
  return NEVER_RECORD.some((k) => hay.includes(k));
}
Enter fullscreen mode Exit fullscreen mode

The point: a developer using our snippet does not have to remember to mask. Masking is what happens unless they explicitly mark a field as safe to record.

Step 3: record mutations, not frames

const observer = new MutationObserver((mutations) => {
  for (const m of mutations) {
    if (m.type === "attributes" && m.attributeName === "value") {
      const el = m.target as HTMLInputElement;
      record({ kind: "attr", id: nodeId(el), name: "value", value: maskValue(el.value) });
    }
    if (m.type === "childList") {
      m.addedNodes.forEach((n) => record({ kind: "add", parent: nodeId(m.target), node: serializeNode(n) }));
      m.removedNodes.forEach((n) => record({ kind: "remove", id: nodeId(n) }));
    }
  }
});

observer.observe(document.documentElement, {
  childList: true, subtree: true, attributes: true,
  attributeFilter: ["value", "class", "style"],
});
Enter fullscreen mode Exit fullscreen mode

Step 4: batch and send, do not flood

let buffer: ReplayEvent[] = [];

function record(e: ReplayEvent) {
  buffer.push(e);
  if (buffer.length >= 50) flush();
}

setInterval(flush, 5000);

function flush() {
  if (!buffer.length) return;
  navigator.sendBeacon("/replay", JSON.stringify(buffer));
  buffer = [];
}
Enter fullscreen mode Exit fullscreen mode

sendBeacon survives page unload, which a normal fetch does not.

Things that bit us

  • canvas and video cannot be serialized as DOM. We snapshot a placeholder.
  • Shadow DOM needs explicit traversal, it is not in the normal tree.
  • Cross origin iframes cannot be read at all. We mark them as opaque.

Takeaway

Session replay is a DOM recorder, not a screen recorder, which is exactly what lets you mask sensitive input by default. Make the safe behavior the default and developers do not have to think about it.

Disclosure: this is from building the replay feature in our analytics tool, Zenovay.

Top comments (0)