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:
$ 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 id | Severity | Catches |
|---|---|---|
dom/innerhtml-sink | BLOCKER | `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-sink | BLOCKER | `insertAdjacentHTML`, `document.write`, or `Range.createContextualFragment` on a built string. Same sink as `innerHTML`, with less scrutiny. |
dom/set-attribute-event-handler | BLOCKER | `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 | BLOCKER | 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. |
dom/postmessage-wildcard-target | BLOCKER | `postMessage(data, '*')`. The payload goes to whatever document currently occupies that frame. Name the target origin. |
dom/url-input-into-sink | BLOCKER | `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 | BLOCKER | `location.href` / `location.assign` / `window.open` given a user-supplied URL with no same-origin or allow-list check. |
dom/dynamic-script-injection | BLOCKER | A `<script>` element whose `src` is built from external input, or `new Function` / `eval` over fetched content. |
dom/listener-never-removed | HIGH | `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 | HIGH | `IntersectionObserver`, `ResizeObserver`, `MutationObserver` or `matchMedia` listener created without a `disconnect()` path. |
dom/timer-never-cleared | HIGH | `setInterval`/`setTimeout` retained past teardown. |
dom/fetch-no-abort | HIGH | 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 | HIGH | `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 | HIGH | 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 | HIGH | `document.querySelectorAll` (or `getElementById`) re-run per iteration or per event. Hoist the lookup; the DOM is not a cache. |
dom/scroll-resize-unthrottled | HIGH | `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 | HIGH | 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/prefer-event-delegation | SUGGESTION | One delegated listener on a container over one per row for lists that change. |
dom/prefer-classlist | SUGGESTION | `classList.add`/`toggle` over string surgery on `className`. |
dom/prefer-abort-signal-timeout | SUGGESTION | `AbortSignal.timeout(ms)` over a manual `setTimeout` plus `controller.abort()`. |
dom/prefer-target-blank-noopener | SUGGESTION | `rel="noopener"` on `target="_blank"` links; also state it explicitly on `window.open`. |
dom/prefer-dataset | SUGGESTION | `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:
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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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
# 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')`.