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 javascript-typescript-browser  # or name one

One profile pulls these rules in: javascript-typescript-browser.

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

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

How to use it — 28 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/remove-listener-fresh-functionHIGH`removeEventListener` given an inline arrow, `function` expression or fresh `.bind()` result. That function was never added, so nothing is removed and the listener keeps firing against torn-down state. Keep the bound reference in a field, or pass `{ signal }` from an `AbortController`.
dom/on-property-clobbers-handlerHIGH`window.onload =`, `document.onkeydown =` or another `on*` property assigned on a global target. Assignment replaces the host page's or another script's handler — an embed setting `window.onerror` disables the page's error tracking. Use `addEventListener`.
dom/fetch-get-with-bodyHIGH`fetch()` / `new Request()` with a `body` and no method, or with `GET`/`HEAD`. fetch throws a `TypeError` before sending, so the search never returns. Set the method the endpoint expects, or move the data into the query string.
dom/implicit-window-globalHIGHAn undeclared `event`, `name`, `status`, `length`, `top`, `parent`, `open` or `close` that resolves to a `window` property. It runs without error and reads the wrong thing — `name` is `window.name`, which the referring page controls. Declare the parameter or local; write `window.x` when the global is meant.
dom/unchecked-query-resultHIGHA `querySelector` / `getElementById` result dereferenced with no null check. A shared script on a page without that element throws on load and every later handler in the file stops running. Check and return early, or scope the script to its page.
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 28 rules here, 8 BLOCKER, 15 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 · 81 lines · 6.5 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.
- `dom/remove-listener-fresh-function` — **`removeEventListener` given an inline arrow, `function` expression or
  fresh `.bind()` result.** That function was never added, so nothing is removed and the listener keeps firing
  against torn-down state. Keep the bound reference in a field, or pass `{ signal }` from an `AbortController`.
- `dom/on-property-clobbers-handler` — **`window.onload =`, `document.onkeydown =` or another `on*` property
  assigned on a global target.** Assignment replaces the host page's or another script's handler — an embed
  setting `window.onerror` disables the page's error tracking. Use `addEventListener`.
- `dom/fetch-get-with-body` — **`fetch()` / `new Request()` with a `body` and no method, or with `GET`/`HEAD`.**
  fetch throws a `TypeError` before sending, so the search never returns. Set the method the endpoint expects,
  or move the data into the query string.
- `dom/implicit-window-global` — **An undeclared `event`, `name`, `status`, `length`, `top`, `parent`, `open` or
  `close` that resolves to a `window` property.** It runs without error and reads the wrong thing — `name` is
  `window.name`, which the referring page controls. Declare the parameter or local; write `window.x` when the
  global is meant.
- `dom/unchecked-query-result` — **A `querySelector` / `getElementById` result dereferenced with no null
  check.** A shared script on a page without that element throws on load and every later handler in the file
  stops running. Check and return early, or scope the script to its page.
- `dom/document-cookie-string` — **`document.cookie` assigned a string built from a variable.** An unencoded `;`
  truncates the cookie or injects attributes (`; Max-Age=0`), and hand-built strings drop `Secure`/`SameSite`.
  Use `cookieStore.set({…})` or a maintained library, and `encodeURIComponent` the value.

## 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')`.