Browser / DOM

Framework-free browser code — the sinks and leaks a framework normally hides.

What this is

Framework-free browser code — the sinks and leaks a framework normally hides.

How to onboard it

A repository picks up this stack by onboarding on a profile that includes it. redline init detects the profile from what is in the repository, so in most cases this is automatic:

terminal
$ npx redlinegate init  # detects the profile
$ npx redlinegate init --profile web-vanilla  # or name one

One profile pulls these rules in: web-vanilla.

Once onboarded, your files match this stack when they fit any of these globs:

  • **/*.js
  • **/*.mjs
  • **/*.ts
  • **/*.html

How to use it — 22 rules

Nothing to run. Once your profile includes Browser / DOM, redline init renders these rules into your repository's AI tooling and the reviewer applies them on every pull request. When a review comment cites one of these ids, this table is where to look up what it catches and why.

Rule idSeverityCatches
dom/innerhtml-sinkBLOCKER`innerHTML` / `outerHTML` assigned anything not a hard-coded literal. Concatenated markup around a variable is the sink, even when the variable "comes from our own API". Build nodes, or set `textContent`.
dom/insert-adjacent-html-sinkBLOCKER`insertAdjacentHTML`, `document.write`, or `Range.createContextualFragment` on a built string. Same sink as `innerHTML`, with less scrutiny.
dom/set-attribute-event-handlerBLOCKER`setAttribute` of an `on*` handler, or a `src`/`href` whose scheme is not checked. `javascript:` and `data:text/html` both execute; an allow-list of `https:` (and `mailto:` where meant) is the check.
dom/postmessage-no-origin-checkBLOCKERA `message` listener that reads `event.data` without first comparing `event.origin` to an expected origin. Any page that can get a handle to the window can post to it.
dom/postmessage-wildcard-targetBLOCKER`postMessage(data, '*')`. The payload goes to whatever document currently occupies that frame. Name the target origin.
dom/url-input-into-sinkBLOCKER`location.hash`, `location.search` or a `data-*` attribute reaching markup, navigation or `eval` without validation. It is the most attacker-controllable input a page has.
dom/open-redirectBLOCKER`location.href` / `location.assign` / `window.open` given a user-supplied URL with no same-origin or allow-list check.
dom/dynamic-script-injectionBLOCKERA `<script>` element whose `src` is built from external input, or `new Function` / `eval` over fetched content.
dom/listener-never-removedHIGH`addEventListener` on `window`, `document` or a long-lived node with no matching `removeEventListener` (or `{ signal }`) when the widget is torn down. The handler keeps the whole closure alive and fires against detached state.
dom/observer-never-disconnectedHIGH`IntersectionObserver`, `ResizeObserver`, `MutationObserver` or `matchMedia` listener created without a `disconnect()` path.
dom/timer-never-clearedHIGH`setInterval`/`setTimeout` retained past teardown.
dom/fetch-no-abortHIGHA `fetch` that can be superseded (search-as-you-type, tab switch, re-render) started without an `AbortController` — the stale response arrives last and wins.
dom/unparsed-json-boundaryHIGH`JSON.parse` of a `data-*` attribute, a storage value or a response body with no `try`/`catch` and no shape check. All three are external input; a throw here takes the whole script down.
dom/layout-thrashHIGHReading a layout property (`offsetHeight`, `getBoundingClientRect`) and writing a style in the same loop. Each pair forces a synchronous reflow; batch reads, then writes, inside `requestAnimationFrame`.
dom/document-wide-query-in-loopHIGH`document.querySelectorAll` (or `getElementById`) re-run per iteration or per event. Hoist the lookup; the DOM is not a cache.
dom/scroll-resize-unthrottledHIGH`scroll`, `resize`, `mousemove` or `pointermove` handlers doing layout or network work with no throttle and no `{ passive: true }` — this is jank you can measure.
dom/form-submit-not-preventedHIGHAn `submit`/`click` handler doing async work without `preventDefault`, or preventing it without ever re-enabling the control — double submits or a permanently dead button.
dom/prefer-event-delegationSUGGESTIONOne delegated listener on a container over one per row for lists that change.
dom/prefer-classlistSUGGESTION`classList.add`/`toggle` over string surgery on `className`.
dom/prefer-abort-signal-timeoutSUGGESTION`AbortSignal.timeout(ms)` over a manual `setTimeout` plus `controller.abort()`.
dom/prefer-target-blank-noopenerSUGGESTION`rel="noopener"` on `target="_blank"` links; also state it explicitly on `window.open`.
dom/prefer-datasetSUGGESTION`el.dataset.x` over `getAttribute('data-x')`.

Expected output

A finding from this file, and every finding Redline produces, opens with a machine-readable first line — severity, then the rule id in brackets, then the problem in one line:

a finding from this file
Redline/BLOCKER [dom/innerhtml-sink]: <one-line problem>

Ids are aggregated per rule, which is how the organisation finds out which rules earn their place and which only generate noise — so a finding without a valid id cannot be measured and counts as untagged. Of the 22 rules here, 8 BLOCKER, 9 HIGH and 5 SUGGESTION. Only a BLOCKER must not merge; a SUGGESTION may be dismissed without justification, and is never upgraded to get attention.

If a rule here fires constantly on code your team has deliberately decided to allow, that is the signal to raise with the standards owner — the rule id is what makes that conversation measurable — not to argue it away comment by comment.

How to edit it

Rules are edited in standards/stacks/dom.md and nowhere else. The rendered copies in AGENTS.md, .github/copilot-instructions.md and .github/instructions/ are generated and are overwritten by the next render. A change here propagates to every onboarded repository as a pull request, so treat it as a production change.

  1. Edit the markdownstandards/ is the only place a human edits a rule. Everything under AGENTS.md, .github/copilot-instructions.md and .github/instructions/ is rendered from it and is overwritten by the next render.
  2. node scripts/assign-rule-ids.mjsAssigns a permanent <stack>/<slug> id to any new rule bullet and rewrites the file in place. Do not invent an id by hand. CI runs the same script with --check and fails if a rule is missing one.
  3. node scripts/render-self.mjsRe-renders this repository's own artifacts from the edited source. CI runs it with --check, so stale checked-in output fails the build.
  4. Bump standards/manifest.json → versionRequired in the same pull request as the rule change. Sync pull requests quote the version, so a repository's rendered artifacts always name where they came from.
  5. Add a CHANGELOG.md entryAlso in the same pull request. A standards change with no measurement is an opinion — record the seed score alongside it.
  6. node scripts/validate.mjsThe bundle self-check CI runs: manifest integrity, well-formed rule ids, the severity output contract surviving your edit, glob portability.

Rule ids are permanent. Rewording a rule is fine and keeps its id; renaming or removing an id orphans every historical telemetry record that cited it.

The full file

standards/stacks/dom.md · 62 lines · 4.6 KB
# Browser / DOM Review Rules

**Scope:** browser code with no component framework — progressive-enhancement scripts,
widgets, embeds, web components, and anything talking to the DOM directly. A framework
repository does not install these rules: React, Angular, Vue and Svelte each close most
of these holes by default, and repeating them there is noise.

These are the failures a framework normally hides. Without one, they are the whole
attack surface.

## BLOCKER — request changes

- `dom/innerhtml-sink` — **`innerHTML` / `outerHTML` assigned anything not a hard-coded literal.** Concatenated markup around a variable is the sink, even when the variable "comes from our own API". Build nodes, or set `textContent`.

  ```js
  // WRONG
  el.innerHTML = `<a href="${url}">${name}</a>`;
  // RIGHT
  const a = document.createElement('a');
  a.href = url;          // still validate the scheme
  a.textContent = name;
  ```

- `dom/insert-adjacent-html-sink` — **`insertAdjacentHTML`, `document.write`, or `Range.createContextualFragment` on a built string.** Same sink as `innerHTML`, with less scrutiny.
- `dom/set-attribute-event-handler` — **`setAttribute` of an `on*` handler, or a `src`/`href` whose scheme is not checked.** `javascript:` and `data:text/html` both execute; an allow-list of `https:` (and `mailto:` where meant) is the check.
- `dom/postmessage-no-origin-check` — **A `message` listener that reads `event.data` without first comparing `event.origin` to an expected origin.** Any page that can get a handle to the window can post to it.

  ```js
  // WRONG
  window.addEventListener('message', (e) => apply(e.data));
  // RIGHT
  window.addEventListener('message', (e) => {
    if (e.origin !== TRUSTED_ORIGIN) return;
    apply(e.data);
  });
  ```

- `dom/postmessage-wildcard-target` — **`postMessage(data, '*')`.** The payload goes to whatever document currently occupies that frame. Name the target origin.
- `dom/url-input-into-sink` — **`location.hash`, `location.search` or a `data-*` attribute reaching markup, navigation or `eval` without validation.** It is the most attacker-controllable input a page has.
- `dom/open-redirect` — **`location.href` / `location.assign` / `window.open` given a user-supplied URL** with no same-origin or allow-list check.
- `dom/dynamic-script-injection` — **A `<script>` element whose `src` is built from external input**, or `new Function` / `eval` over fetched content.

## HIGH

- `dom/listener-never-removed` — `addEventListener` on `window`, `document` or a long-lived node with no matching `removeEventListener` (or `{ signal }`) when the widget is torn down. The handler keeps the whole closure alive and fires against detached state.
- `dom/observer-never-disconnected` — `IntersectionObserver`, `ResizeObserver`, `MutationObserver` or `matchMedia` listener created without a `disconnect()` path.
- `dom/timer-never-cleared` — `setInterval`/`setTimeout` retained past teardown.
- `dom/fetch-no-abort` — A `fetch` that can be superseded (search-as-you-type, tab switch, re-render) started without an `AbortController` — the stale response arrives last and wins.
- `dom/unparsed-json-boundary` — `JSON.parse` of a `data-*` attribute, a storage value or a response body with no `try`/`catch` and no shape check. All three are external input; a throw here takes the whole script down.
- `dom/layout-thrash` — Reading a layout property (`offsetHeight`, `getBoundingClientRect`) and writing a style in the same loop. Each pair forces a synchronous reflow; batch reads, then writes, inside `requestAnimationFrame`.
- `dom/document-wide-query-in-loop` — `document.querySelectorAll` (or `getElementById`) re-run per iteration or per event. Hoist the lookup; the DOM is not a cache.
- `dom/scroll-resize-unthrottled` — `scroll`, `resize`, `mousemove` or `pointermove` handlers doing layout or network work with no throttle and no `{ passive: true }` — this is jank you can measure.
- `dom/form-submit-not-prevented` — An `submit`/`click` handler doing async work without `preventDefault`, or preventing it without ever re-enabling the control — double submits or a permanently dead button.

## SUGGESTION

- `dom/prefer-event-delegation` — One delegated listener on a container over one per row for lists that change.
- `dom/prefer-classlist` — `classList.add`/`toggle` over string surgery on `className`.
- `dom/prefer-abort-signal-timeout` — `AbortSignal.timeout(ms)` over a manual `setTimeout` plus `controller.abort()`.
- `dom/prefer-target-blank-noopener` — `rel="noopener"` on `target="_blank"` links; also state it explicitly on `window.open`.
- `dom/prefer-dataset` — `el.dataset.x` over `getAttribute('data-x')`.