Skip to content

Commit 4f97aeb

Browse files
fix(rest): escape the API docs viewer URL value for its HTML attribute context (ruling 3C) (#25093)
Fixes #25077 The API docs viewer (`GET {base}/docs`, `packages/rest`) builds the sibling openapi-document URL from the request and places it into a quoted HTML attribute of the served page. It interpolated that value into the attribute with no escaping, so a character the attribute context interprets reached the served markup as itself rather than as the matching HTML entity. **Reach (abstract, per the #9387 disclosure ruling).** Reachable by an authenticated request through a mounted host; an anonymous request is refused before the render. Measured through both `IHttpServer` hosts this repo mounts these routes through. A URL carrying none of the interpreted characters is served byte-identically. **The fix.** The value is escaped for its HTML attribute context at that one outlet — ruling 3C on #25040: every outlet that renders a value into HTML escapes it by the context it lands in. `packages/rest` had no HTML escaper; a package it depends on exports none and the repo's only escaper is private to another package this one does not import, so the fix adds a small local escaper in `packages/rest`. It is not re-exported, so the package's published surface is unchanged. Normal URLs stay byte-identical. **The outlet-list pin.** `html-outlets.pin.test.ts` enumerates this package's HTML outlets by a source scan, asserts the list equals the declared set (today: one), asserts the declared outlet escapes the value for its context, and carries a positive control proving the scan finds the known outlet. It matches the shape and name of PR #25076's per-package outlet-list pin. **Evidence.** The escaping pin is red on a hash-equal-restored ablation of the fix (the escaper reduced to identity) and green after; the byte-identical pin stays green under the identity ablation, as it should. The tests are named by what they assert — "a value carrying attribute-interpreted characters is escaped for the attribute context" and "an ordinary URL is served byte-identical" — and their inputs are the minimum characters the context interprets. A `patch` changeset for `@objectstack/rest` is included. Clause-②: no Held off (out of scope): the page's other content; `packages/spec/**`; PR #25076's files; the record export path (#25078, dispatched in parallel). --- _Generated by [Claude Code](https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3782b2d commit 4f97aeb

4 files changed

Lines changed: 273 additions & 1 deletion

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
fix(rest): the API docs viewer escapes the request-derived openapi URL for the attribute it lands in
6+
7+
The `GET {base}/docs` viewer page builds the sibling openapi URL from the request and places it into a `data-url="…"` attribute of a script element. It interpolated that value into the attribute with no escaping, so a character the attribute context interprets reached the served markup as itself rather than as the matching HTML entity. The value is now escaped for its attribute context (ruling 3C on #25040: every outlet that renders a value into HTML escapes it by the context it lands in): the five characters a quoted attribute or element text interprets become their entities, and a browser decodes each back to the original character for the page script. A URL carrying none of those characters is served byte-identically.
8+
9+
Clause-②: no

‎packages/rest/src/discovery-routes.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@
6767
// half is the class's `probeMcpServeable`, reached through the host.
6868
import { isMcpServerEnabled } from '@objectstack/types';
6969
import { handleRouteError, sendThrownError } from './error-response.js';
70+
// [#25077] The docs viewer page escapes the value it places into its attribute.
71+
import { escapeHtml } from './html-escape.js';
7072
import { logError } from './log.js';
7173
// [#5040 E6] The `api`-metadata endpoints the OpenAPI document folds in.
7274
import { enrichOpenApiWithEndpoints } from './openapi-endpoints.js';
@@ -690,6 +692,10 @@ export function registerOpenApiEndpoints(this: OpenApiRouteHost, basePath: strin
690692
// Strip the trailing /docs to get the API base.
691693
const apiBase = reqPath.replace(/\/docs\/?$/, '');
692694
const specUrl = `${apiBase}/openapi.json`;
695+
// [#25077] Escaped for the attribute it lands in (ruling 3C on
696+
// #25040: every outlet that renders a value into HTML escapes it
697+
// by context). A path without HTML-special characters renders
698+
// byte for byte as before. `html-outlets.pin.test.ts` pins both.
693699
const html = `<!doctype html>
694700
<html>
695701
<head>
@@ -698,7 +704,7 @@ export function registerOpenApiEndpoints(this: OpenApiRouteHost, basePath: strin
698704
<title>ObjectStack API Docs</title>
699705
</head>
700706
<body>
701-
<script id="api-reference" data-url="${specUrl}"></script>
707+
<script id="api-reference" data-url="${escapeHtml(specUrl)}"></script>
702708
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
703709
</body>
704710
</html>`;

‎packages/rest/src/html-escape.ts‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25077] The HTML escaper this package's HTML outlets call — ruling 3C on
5+
* #25040: every outlet that renders a value into HTML escapes it by the context
6+
* it lands in.
7+
*
8+
* It covers the two contexts an outlet here may place a value into: element
9+
* text and a QUOTED attribute value (single or double quotes). The five
10+
* characters those contexts interpret become their entities, and every other
11+
* character passes through untouched, so a value carrying none of the five
12+
* renders byte for byte as it did unescaped. A browser decodes each entity back
13+
* to its character, so what the page's script reads from the DOM is the
14+
* original value.
15+
*
16+
* ⛔ Not for an unquoted attribute, a `<script>` or `<style>` body, a URL scheme
17+
* decision or an event-handler attribute: each of those contexts interprets
18+
* other characters and needs its own encoder. Place a value only into one of
19+
* the two contexts above.
20+
*
21+
* Why a local module rather than a shared one: no package this one depends on
22+
* exports an HTML escaper, and the repo's other escaper is private to the mail
23+
* template engine, which this package does not import. It is not re-exported
24+
* from the package entry, so it adds nothing to the published surface.
25+
*
26+
* The census in `html-outlets.pin.test.ts` lists this file as a non-outlet: it
27+
* renders nothing itself.
28+
*/
29+
30+
const HTML_ENTITY: Readonly<Record<string, string>> = {
31+
'&': '&amp;',
32+
'<': '&lt;',
33+
'>': '&gt;',
34+
'"': '&quot;',
35+
"'": '&#39;',
36+
};
37+
38+
const HTML_SPECIAL = /[&<>"']/g;
39+
40+
/** Escape `value` for HTML element text or a quoted HTML attribute value. */
41+
export function escapeHtml(value: string): string {
42+
return value.replace(HTML_SPECIAL, (ch) => HTML_ENTITY[ch]);
43+
}
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25077] Every outlet in this package that renders a value into HTML escapes
5+
* it by the context it lands in — ruling 3C on #25040.
6+
*
7+
* ## The rule
8+
*
9+
* The burden sits on the OUTLET: the code that places a value into markup. A
10+
* value reaching an HTML outlet may carry any character, and nothing upstream
11+
* strips or refuses it, so the outlet escapes it for the context it lands in —
12+
* element text or a quoted attribute value. This package has exactly one such
13+
* outlet today: the `GET {base}/docs` viewer in `discovery-routes.ts`, which
14+
* places the request-derived openapi URL into a `data-url="…"` attribute.
15+
*
16+
* ## The list, and the pin on the list itself
17+
*
18+
* {@link OUTLETS} names every file of this package that renders a value into
19+
* HTML, each with a pin that drives its real render path. A per-entry pin
20+
* cannot see an outlet nobody listed, so the LIST is pinned too: the census
21+
* walks this package's source and finds every file that touches HTML at all —
22+
* an identifier or a string naming `html`, or a string carrying an HTML tag —
23+
* and requires each one to be on {@link OUTLETS} or on {@link NOT_OUTLETS} with
24+
* the reason it renders nothing. A new file that touches HTML fails here until
25+
* its author says which it is. A positive control proves the census actually
26+
* finds the known outlet.
27+
*
28+
* ⚠️ LIMIT, stated so it is not read as more: the census reads source text of
29+
* THIS package. It proves no unlisted file here touches HTML; it does not see
30+
* another package (each package holding an outlet carries its own list), and it
31+
* does not prove a listed outlet escapes — that is what the per-entry pin is
32+
* for.
33+
*/
34+
35+
import { readFileSync, readdirSync, statSync } from 'node:fs';
36+
import { dirname, join, relative, sep } from 'node:path';
37+
import { fileURLToPath } from 'node:url';
38+
39+
import ts from 'typescript';
40+
import { describe, it, expect, vi } from 'vitest';
41+
import { RestServer } from './rest-server.js';
42+
43+
/** …/packages/rest/src — this package's whole source tree. */
44+
const SRC = dirname(fileURLToPath(import.meta.url));
45+
46+
/** A file that renders a value into HTML, and the pin that holds it to the rule. */
47+
interface OutletEntry {
48+
/** What it renders. */
49+
readonly renders: string;
50+
/** Registers the pin that drives its real render path — an outlet cannot be listed without one. */
51+
readonly pin: () => void;
52+
}
53+
54+
/** A file that touches HTML without rendering a value into it. */
55+
interface NotOutletEntry {
56+
readonly why: string;
57+
}
58+
59+
const OUTLETS: Readonly<Record<string, OutletEntry>> = {
60+
'discovery-routes.ts': {
61+
renders: 'the GET {base}/docs Scalar viewer page, which places the request-derived openapi URL into a data-url attribute',
62+
pin: pinDocsViewer,
63+
},
64+
};
65+
66+
const NOT_OUTLETS: Readonly<Record<string, NotOutletEntry>> = {
67+
'html-escape.ts': { why: 'the escaper the outlet calls; renders nothing itself' },
68+
'import-template.ts': { why: 'names "html" as a sample field-type string in SAMPLE_TEXT_TYPES; renders nothing' },
69+
'rest-route-ledger.ts': { why: 'a route-ledger note describing the docs page in prose; renders nothing' },
70+
};
71+
72+
// ── The census ──────────────────────────────────────────────────────────────
73+
74+
const HTML_WORD = /html/i;
75+
/** An HTML element opening or closing tag inside a string. */
76+
const HTML_TAG =
77+
/<(?:!doctype|\/?(?:html|head|body|title|meta|style|script|link|p|a|b|i|u|em|strong|small|span|div|br|hr|img|table|thead|tbody|tfoot|tr|td|th|ul|ol|li|h[1-6]|form|input|button|textarea|select|option|label|pre|code|blockquote|section|article|header|footer|nav|main|center|font|iframe|svg|mj-[a-z-]+))(?=[\s>/])/i;
78+
79+
/** Does this source touch HTML in CODE — comments never count. */
80+
function touchesHtml(fileName: string, text: string): boolean {
81+
const source = ts.createSourceFile(fileName, text, ts.ScriptTarget.Latest, true);
82+
let hit = false;
83+
const visit = (node: ts.Node): void => {
84+
if (hit) return;
85+
if (ts.isIdentifier(node) || ts.isPrivateIdentifier(node)) {
86+
if (HTML_WORD.test(node.text)) hit = true;
87+
} else if (
88+
ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)
89+
|| ts.isTemplateHead(node) || ts.isTemplateMiddle(node) || ts.isTemplateTail(node)
90+
) {
91+
if (HTML_WORD.test(node.text) || HTML_TAG.test(node.text)) hit = true;
92+
}
93+
if (!hit) ts.forEachChild(node, visit);
94+
};
95+
visit(source);
96+
return hit;
97+
}
98+
99+
/** Every shipped source file, package-relative. Tests and test kits ship nothing. */
100+
function shippedSources(dir = SRC): string[] {
101+
const out: string[] = [];
102+
for (const entry of readdirSync(dir)) {
103+
const full = join(dir, entry);
104+
if (statSync(full).isDirectory()) {
105+
out.push(...shippedSources(full));
106+
continue;
107+
}
108+
if (!entry.endsWith('.ts') || entry.endsWith('.d.ts')) continue;
109+
if (entry.endsWith('.test.ts') || entry.endsWith('.testkit.ts')) continue;
110+
out.push(relative(SRC, full).split(sep).join('/'));
111+
}
112+
return out.sort();
113+
}
114+
115+
describe('the outlet list — every file here that touches HTML is classified', () => {
116+
it('the census reads code, not comments (control)', () => {
117+
expect(touchesHtml('a.ts', '// renders html\nexport const x = 1;')).toBe(false);
118+
expect(touchesHtml('a.ts', 'export const x = `<p>${1}</p>`;')).toBe(true);
119+
expect(touchesHtml('a.ts', 'export const bodyHtml = 1;')).toBe(true);
120+
expect(touchesHtml('a.ts', "export const t = 'text/html';")).toBe(true);
121+
});
122+
123+
it('the census finds the known outlet (positive control)', () => {
124+
const outlet = 'discovery-routes.ts';
125+
expect(Object.keys(OUTLETS)).toContain(outlet);
126+
expect(touchesHtml(outlet, readFileSync(join(SRC, outlet), 'utf8'))).toBe(true);
127+
});
128+
129+
it('the files that touch HTML are exactly the listed outlets and non-outlets', () => {
130+
const touching = shippedSources().filter((rel) => touchesHtml(rel, readFileSync(join(SRC, rel), 'utf8')));
131+
const listed = [...Object.keys(OUTLETS), ...Object.keys(NOT_OUTLETS)].sort();
132+
expect(touching).toEqual(listed);
133+
});
134+
135+
it('no file is both an outlet and a non-outlet', () => {
136+
expect(Object.keys(OUTLETS).filter((f) => f in NOT_OUTLETS)).toEqual([]);
137+
});
138+
});
139+
140+
// ── The per-entry pin ─────────────────────────────────────────────────────
141+
142+
const UNSCOPED = '/api/v1';
143+
const SCOPED = '/api/v1/environments/:environmentId';
144+
145+
/** Mount both bases, a signed-in context, and drive the GET {base}/docs handler. */
146+
function makeRest() {
147+
const httpServer = {
148+
get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(),
149+
use: vi.fn(), listen: vi.fn(), close: vi.fn(),
150+
} as any;
151+
const protocol: any = { getMetaItems: vi.fn(async () => ({ items: [] })) };
152+
const rest = new RestServer(
153+
httpServer, protocol,
154+
{ api: { version: 'v1', enableProjectScoping: true, projectResolution: 'auto' } } as any,
155+
);
156+
(rest as any).resolveExecCtx = vi.fn(async () => ({ userId: 'u_docs_reader' }));
157+
rest.registerRoutes();
158+
return rest;
159+
}
160+
161+
/** The viewer's `data-url` attribute value, as served for a request whose path is `reqPath`. */
162+
async function dataUrlFor(rest: RestServer, mountPath: string, reqPath: string, environmentId?: string): Promise<string> {
163+
const entry = (rest as any).routeManager.get('GET', mountPath);
164+
expect(entry, `GET ${mountPath} must be mounted`).toBeDefined();
165+
let html = '';
166+
const res: any = {
167+
status: () => res,
168+
json: () => res,
169+
send: (body: string) => { html = body; return res; },
170+
setHeader: () => res,
171+
header: () => res,
172+
};
173+
await entry.handler({
174+
method: 'GET', path: reqPath, url: reqPath,
175+
params: environmentId ? { environmentId } : {}, query: {},
176+
headers: { host: 'example.test' },
177+
}, res);
178+
const m = html.match(/data-url="([^\n]*)"><\/script>/);
179+
expect(m, 'the viewer page carries a data-url attribute').not.toBeNull();
180+
return m![1];
181+
}
182+
183+
function pinDocsViewer(): void {
184+
/**
185+
* A path segment carrying the characters a quoted HTML attribute interprets.
186+
* Kept to the minimum the context reads — a quote, an angle bracket, an
187+
* ampersand, an apostrophe — not an attack string.
188+
*/
189+
const SPECIAL_ENV = 'a"<&\'b';
190+
const SPECIAL_ENV_ESCAPED = 'a&quot;&lt;&amp;&#39;b';
191+
192+
it('a value carrying attribute-interpreted characters is escaped for the attribute context', async () => {
193+
const rest = makeRest();
194+
const reqPath = `/api/v1/environments/${SPECIAL_ENV}/docs`;
195+
const dataUrl = await dataUrlFor(rest, `${SCOPED}/docs`, reqPath, SPECIAL_ENV);
196+
expect(dataUrl).toBe(`/api/v1/environments/${SPECIAL_ENV_ESCAPED}/openapi.json`);
197+
// The literal characters do not survive into the served attribute value.
198+
expect(dataUrl).not.toContain('"');
199+
expect(dataUrl).not.toContain('<');
200+
});
201+
202+
it('an ordinary URL is served byte-identical', async () => {
203+
const rest = makeRest();
204+
const unscoped = await dataUrlFor(rest, `${UNSCOPED}/docs`, '/api/v1/docs');
205+
expect(unscoped).toBe('/api/v1/openapi.json');
206+
const scoped = await dataUrlFor(rest, `${SCOPED}/docs`, '/api/v1/environments/env_1/docs', 'env_1');
207+
expect(scoped).toBe('/api/v1/environments/env_1/openapi.json');
208+
});
209+
}
210+
211+
// Registered last, so every helper above is initialised before the pin reads it.
212+
for (const [file, entry] of Object.entries(OUTLETS)) {
213+
describe(`${file} — ${entry.renders}`, entry.pin);
214+
}

0 commit comments

Comments
 (0)