Skip to content

fix(security): cap POST compatibility arrays and harden safeHtml links#3607

Open
lspassos1 wants to merge 1 commit into
koala73:mainfrom
lspassos1:fix/post-get-array-noopener
Open

fix(security): cap POST compatibility arrays and harden safeHtml links#3607
lspassos1 wants to merge 1 commit into
koala73:mainfrom
lspassos1:fix/post-get-array-noopener

Conversation

@lspassos1
Copy link
Copy Markdown
Collaborator

@lspassos1 lspassos1 commented May 5, 2026

Summary

Closes #3550 by hardening the gateway POST-to-GET compatibility path and external links opened from sanitized HTML. The gateway now rejects excessive repeated array parameters before route matching, and sanitized _blank links always get noopener noreferrer while dropping unsafe opener tokens.

Root cause

The POST compatibility shim expanded JSON arrays into repeated GET query params without a per-key value cap, so a large array could create an oversized route/search-param workload. Separately, safeHtml() stripped event handlers and invalid URLs but did not normalize rel for _blank anchors, leaving reverse-tabnabbing protection dependent on caller-provided markup.

Changes

  • Add a 200-value per-key cap for POST-to-GET array expansion in server/gateway.ts.
  • Return a structured 400 response when a compatibility array exceeds the cap.
  • Keep the oversized-array response outside the JSON parse fallback path.
  • Add ensureNoopenerRel() and apply it to sanitized _blank anchors.
  • Remove opener from existing rel tokens while preserving safe rel tokens.
  • Add regression coverage for the gateway cap, rel normalization, and safeHtml() integration path.

Validation

  • npx tsx --test tests/premium-stock-gateway.test.mts tests/dom-utils.test.mts - 14/14 pass
  • npm run typecheck
  • npm run typecheck:api
  • npx biome lint server/gateway.ts src/utils/dom-utils.ts tests/premium-stock-gateway.test.mts tests/dom-utils.test.mts - exits 0; existing complexity/optional-chain warnings remain in server/gateway.ts
  • git diff --check -- server/gateway.ts src/utils/dom-utils.ts tests/premium-stock-gateway.test.mts tests/dom-utils.test.mts
  • npm run test:data - 7876/7876 pass

Risk

Low. The gateway change only affects the legacy POST compatibility fallback for GET handlers and fails closed when a single JSON array is unusually large. The link sanitizer change only strengthens _blank anchor output by adding standard reverse-tabnabbing protections.

@vercel
Copy link
Copy Markdown

vercel Bot commented May 5, 2026

@lspassos1 is attempting to deploy a commit to the World Monitor Team on Vercel.

A member of the Team first needs to authorize it.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 5, 2026

Greptile Summary

This PR adds two targeted hardening guards: a per-key array cap (200 values) in the POST-to-GET compatibility path in gateway.ts, and ensureNoopenerRel() in dom-utils.ts that forces noopener noreferrer on all <a target="_blank"> anchors processed by safeHtml().

  • server/gateway.ts: Extracts the existing 1 MiB body byte cap into a named constant alongside a new POST_TO_GET_MAX_ARRAY_VALUES_PER_KEY limit; a JSON body with any array exceeding 200 values now returns a structured 400 before any URL-append work occurs.
  • src/utils/dom-utils.ts: New ensureNoopenerRel() helper normalizes rel tokens (lowercases, removes opener, adds noopener and noreferrer), wired into safeHtml() immediately after href/style sanitization; safe tokens like nofollow are preserved.
  • Tests: tests/premium-stock-gateway.test.mts gains a regression case for the 201-element array limit; tests/dom-utils.test.mts unit-tests ensureNoopenerRel but does not exercise the safeHtml blank-target integration path.

Confidence Score: 4/5

Both guards are narrowly scoped and add strictly more restrictive behavior; the gateway change only affects the legacy POST compatibility fallback, and the dom-utils change only adds tokens to blank-target anchor rel attributes.

The emitRequest call and early return for oversized arrays sit inside the try block that silently swallows exceptions, so any future synchronous throw from the analytics call would suppress the 400 response. The safeHtml integration path for target=_blank also has no end-to-end test, leaving a gap where attribute-ordering regressions would go undetected.

server/gateway.ts (emitRequest placement inside try block) and tests/dom-utils.test.mts (missing safeHtml integration test)

Important Files Changed

Filename Overview
server/gateway.ts Extracts POST-to-GET body constants and adds a per-key array cap (200 values) that returns 400 early; the guard logic is placed inside the existing try-catch, which means an unexpected throw in emitRequest would be silently swallowed rather than surface as an error response.
src/utils/dom-utils.ts Adds ensureNoopenerRel() to normalize rel tokens and integrates it into safeHtml() for <a target="_blank"> anchors; implementation is correct and the ordering (strip unsafe attrs → sanitize href/style → enforce rel) is sound.
tests/dom-utils.test.mts New test file covers ensureNoopenerRel in isolation (null input and opener removal), but omits an end-to-end safeHtml test for the blank-target rel-enforcement path.
tests/premium-stock-gateway.test.mts Adds a regression test for the 201-element POST array triggering a 400 response; the test correctly verifies status, error message, parameter name, and maxValues.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Incoming POST request] --> B{Route match?}
    B -- Yes --> Z[Handle normally]
    B -- No --> C{Content-Length < 1 MiB?}
    C -- No --> D[Skip POST→GET conversion]
    C -- Yes --> E[Clone & parse JSON body]
    E -- parse error --> D
    E -- parsed --> F{For each key: is Array?}
    F -- Scalar --> G[searchParams.set]
    F -- Array --> H{length > 200?}
    H -- Yes --> I[Return 400 with parameter name]
    H -- No --> J[searchParams.append each scalar item]
    G --> K[Retry route match as GET]
    J --> K
    K -- matched --> Z
    K -- no match --> L[404 / 405]
Loading

Reviews (1): Last reviewed commit: "fix(security): harden post compatibility..." | Re-trigger Greptile

Comment thread tests/dom-utils.test.mts
Comment on lines +1 to +14
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';

import { ensureNoopenerRel } from '../src/utils/dom-utils.ts';

describe('dom-utils safe link helpers', () => {
it('adds noopener and noreferrer for blank-target links (#3550)', () => {
assert.equal(ensureNoopenerRel(null), 'noopener noreferrer');
});

it('preserves safe rel tokens while removing opener (#3550)', () => {
assert.equal(ensureNoopenerRel('nofollow OPENER'), 'nofollow noopener noreferrer');
});
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 safeHtml blank-target path untested

The new tests exercise ensureNoopenerRel in isolation, but the safeHtml integration — where the function is actually wired to strip opener and inject noopener noreferrer on <a target="_blank"> nodes — has no coverage. A regression in the safeHtml attribute-sanitization ordering (e.g., rel stripped before the blank-target guard runs) would go undetected because no test exercises the full safeHtml('<a href="https://x.com" target="_blank" rel="opener">…</a>') path.

Comment thread server/gateway.ts
Comment on lines 615 to 631
for (const [k, v] of Object.entries(body as Record<string, unknown>)) {
if (Array.isArray(v)) v.forEach((item) => { if (isScalar(item)) url.searchParams.append(k, String(item)); });
else if (isScalar(v)) url.searchParams.set(k, String(v));
if (Array.isArray(v)) {
if (v.length > POST_TO_GET_MAX_ARRAY_VALUES_PER_KEY) {
emitRequest(400, 'ok', null);
return new Response(JSON.stringify({
error: 'Too many values for POST compatibility parameter',
parameter: k,
maxValues: POST_TO_GET_MAX_ARRAY_VALUES_PER_KEY,
}), {
status: 400,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
v.forEach((item) => { if (isScalar(item)) url.searchParams.append(k, String(item)); });
} else if (isScalar(v)) url.searchParams.set(k, String(v));
}
} catch { /* non-JSON body — skip POST→GET conversion */ }
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 emitRequest called inside try block before early return

emitRequest is invoked from within the try body. If the function ever begins to throw synchronously (e.g., after a future refactor of the usage module), the exception would be swallowed by the surrounding catch { /* non-JSON body */ } block, silently skipping the 400 response and letting execution fall through to attempt a GET route match against the partially-built URL. Moving the analytics call and return outside the try block (similar to how every other emitRequest+return pair in this function is structured) avoids that coupling.

Suggested change
for (const [k, v] of Object.entries(body as Record<string, unknown>)) {
if (Array.isArray(v)) v.forEach((item) => { if (isScalar(item)) url.searchParams.append(k, String(item)); });
else if (isScalar(v)) url.searchParams.set(k, String(v));
if (Array.isArray(v)) {
if (v.length > POST_TO_GET_MAX_ARRAY_VALUES_PER_KEY) {
emitRequest(400, 'ok', null);
return new Response(JSON.stringify({
error: 'Too many values for POST compatibility parameter',
parameter: k,
maxValues: POST_TO_GET_MAX_ARRAY_VALUES_PER_KEY,
}), {
status: 400,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
v.forEach((item) => { if (isScalar(item)) url.searchParams.append(k, String(item)); });
} else if (isScalar(v)) url.searchParams.set(k, String(v));
}
} catch { /* non-JSON body — skip POST→GET conversion */ }
let oversizedKey: string | null = null;
for (const [k, v] of Object.entries(body as Record<string, unknown>)) {
if (Array.isArray(v)) {
if (v.length > POST_TO_GET_MAX_ARRAY_VALUES_PER_KEY) {
oversizedKey = k;
break;
}
v.forEach((item) => { if (isScalar(item)) url.searchParams.append(k, String(item)); });
} else if (isScalar(v)) url.searchParams.set(k, String(v));
}
if (oversizedKey !== null) {
emitRequest(400, 'ok', null);
return new Response(JSON.stringify({
error: 'Too many values for POST compatibility parameter',
parameter: oversizedKey,
maxValues: POST_TO_GET_MAX_ARRAY_VALUES_PER_KEY,
}), {
status: 400,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
} catch { /* non-JSON body — skip POST→GET conversion */ }

Cap POST-to-GET array expansion per body key before compatibility routing builds large query strings. Also normalize safeHtml blank-target anchor rel values so sanitized HTML cannot preserve opener access.
@lspassos1 lspassos1 force-pushed the fix/post-get-array-noopener branch from 128661b to cd71792 Compare May 5, 2026 18:32
@lspassos1
Copy link
Copy Markdown
Collaborator Author

Addressed the two review items in cd71792:

  • Moved the oversized-array 400 response outside the JSON parse try/catch, so malformed JSON still only skips POST-to-GET conversion while a valid oversized array reliably fails closed.
  • Added a safeHtml() integration regression for _blank anchors with rel="opener", covering both rel normalization and unsafe attribute removal.

Re-ran the focused tests, typecheck/typecheck:api, targeted Biome lint, diff check, and the full data test suite (npm run test:data, 7876/7876 pass).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[SECURITY] Hardening: cap POST→GET array expansion + auto-add rel=noopener in safeHtml

1 participant