Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/25077-rest-docs-viewer-attr-escape.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@objectstack/rest': patch
---

fix(rest): the API docs viewer escapes the request-derived openapi URL for the attribute it lands in

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.

Clause-②: no
8 changes: 7 additions & 1 deletion packages/rest/src/discovery-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@
// half is the class's `probeMcpServeable`, reached through the host.
import { isMcpServerEnabled } from '@objectstack/types';
import { handleRouteError, sendThrownError } from './error-response.js';
// [#25077] The docs viewer page escapes the value it places into its attribute.
import { escapeHtml } from './html-escape.js';
import { logError } from './log.js';
// [#5040 E6] The `api`-metadata endpoints the OpenAPI document folds in.
import { enrichOpenApiWithEndpoints } from './openapi-endpoints.js';
Expand Down Expand Up @@ -690,6 +692,10 @@ export function registerOpenApiEndpoints(this: OpenApiRouteHost, basePath: strin
// Strip the trailing /docs to get the API base.
const apiBase = reqPath.replace(/\/docs\/?$/, '');
const specUrl = `${apiBase}/openapi.json`;
// [#25077] Escaped for the attribute it lands in (ruling 3C on
// #25040: every outlet that renders a value into HTML escapes it
// by context). A path without HTML-special characters renders
// byte for byte as before. `html-outlets.pin.test.ts` pins both.
const html = `<!doctype html>
<html>
<head>
Expand All @@ -698,7 +704,7 @@ export function registerOpenApiEndpoints(this: OpenApiRouteHost, basePath: strin
<title>ObjectStack API Docs</title>
</head>
<body>
<script id="api-reference" data-url="${specUrl}"></script>
<script id="api-reference" data-url="${escapeHtml(specUrl)}"></script>
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
</body>
</html>`;
Expand Down
43 changes: 43 additions & 0 deletions packages/rest/src/html-escape.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#25077] The HTML escaper this package's HTML outlets call — ruling 3C on
* #25040: every outlet that renders a value into HTML escapes it by the context
* it lands in.
*
* It covers the two contexts an outlet here may place a value into: element
* text and a QUOTED attribute value (single or double quotes). The five
* characters those contexts interpret become their entities, and every other
* character passes through untouched, so a value carrying none of the five
* renders byte for byte as it did unescaped. A browser decodes each entity back
* to its character, so what the page's script reads from the DOM is the
* original value.
*
* ⛔ Not for an unquoted attribute, a `<script>` or `<style>` body, a URL scheme
* decision or an event-handler attribute: each of those contexts interprets
* other characters and needs its own encoder. Place a value only into one of
* the two contexts above.
*
* Why a local module rather than a shared one: no package this one depends on
* exports an HTML escaper, and the repo's other escaper is private to the mail
* template engine, which this package does not import. It is not re-exported
* from the package entry, so it adds nothing to the published surface.
*
* The census in `html-outlets.pin.test.ts` lists this file as a non-outlet: it
* renders nothing itself.
*/

const HTML_ENTITY: Readonly<Record<string, string>> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
};

const HTML_SPECIAL = /[&<>"']/g;

/** Escape `value` for HTML element text or a quoted HTML attribute value. */
export function escapeHtml(value: string): string {
return value.replace(HTML_SPECIAL, (ch) => HTML_ENTITY[ch]);
}
214 changes: 214 additions & 0 deletions packages/rest/src/html-outlets.pin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#25077] Every outlet in this package that renders a value into HTML escapes
* it by the context it lands in — ruling 3C on #25040.
*
* ## The rule
*
* The burden sits on the OUTLET: the code that places a value into markup. A
* value reaching an HTML outlet may carry any character, and nothing upstream
* strips or refuses it, so the outlet escapes it for the context it lands in —
* element text or a quoted attribute value. This package has exactly one such
* outlet today: the `GET {base}/docs` viewer in `discovery-routes.ts`, which
* places the request-derived openapi URL into a `data-url="…"` attribute.
*
* ## The list, and the pin on the list itself
*
* {@link OUTLETS} names every file of this package that renders a value into
* HTML, each with a pin that drives its real render path. A per-entry pin
* cannot see an outlet nobody listed, so the LIST is pinned too: the census
* walks this package's source and finds every file that touches HTML at all —
* an identifier or a string naming `html`, or a string carrying an HTML tag —
* and requires each one to be on {@link OUTLETS} or on {@link NOT_OUTLETS} with
* the reason it renders nothing. A new file that touches HTML fails here until
* its author says which it is. A positive control proves the census actually
* finds the known outlet.
*
* ⚠️ LIMIT, stated so it is not read as more: the census reads source text of
* THIS package. It proves no unlisted file here touches HTML; it does not see
* another package (each package holding an outlet carries its own list), and it
* does not prove a listed outlet escapes — that is what the per-entry pin is
* for.
*/

import { readFileSync, readdirSync, statSync } from 'node:fs';
import { dirname, join, relative, sep } from 'node:path';
import { fileURLToPath } from 'node:url';

import ts from 'typescript';
import { describe, it, expect, vi } from 'vitest';
import { RestServer } from './rest-server.js';

/** …/packages/rest/src — this package's whole source tree. */
const SRC = dirname(fileURLToPath(import.meta.url));

/** A file that renders a value into HTML, and the pin that holds it to the rule. */
interface OutletEntry {
/** What it renders. */
readonly renders: string;
/** Registers the pin that drives its real render path — an outlet cannot be listed without one. */
readonly pin: () => void;
}

/** A file that touches HTML without rendering a value into it. */
interface NotOutletEntry {
readonly why: string;
}

const OUTLETS: Readonly<Record<string, OutletEntry>> = {
'discovery-routes.ts': {
renders: 'the GET {base}/docs Scalar viewer page, which places the request-derived openapi URL into a data-url attribute',
pin: pinDocsViewer,
},
};

const NOT_OUTLETS: Readonly<Record<string, NotOutletEntry>> = {
'html-escape.ts': { why: 'the escaper the outlet calls; renders nothing itself' },
'import-template.ts': { why: 'names "html" as a sample field-type string in SAMPLE_TEXT_TYPES; renders nothing' },
'rest-route-ledger.ts': { why: 'a route-ledger note describing the docs page in prose; renders nothing' },
};

// ── The census ──────────────────────────────────────────────────────────────

const HTML_WORD = /html/i;
/** An HTML element opening or closing tag inside a string. */
const HTML_TAG =
/<(?:!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;

/** Does this source touch HTML in CODE — comments never count. */
function touchesHtml(fileName: string, text: string): boolean {
const source = ts.createSourceFile(fileName, text, ts.ScriptTarget.Latest, true);
let hit = false;
const visit = (node: ts.Node): void => {
if (hit) return;
if (ts.isIdentifier(node) || ts.isPrivateIdentifier(node)) {
if (HTML_WORD.test(node.text)) hit = true;
} else if (
ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)
|| ts.isTemplateHead(node) || ts.isTemplateMiddle(node) || ts.isTemplateTail(node)
) {
if (HTML_WORD.test(node.text) || HTML_TAG.test(node.text)) hit = true;
}
if (!hit) ts.forEachChild(node, visit);
};
visit(source);
return hit;
}

/** Every shipped source file, package-relative. Tests and test kits ship nothing. */
function shippedSources(dir = SRC): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
out.push(...shippedSources(full));
continue;
}
if (!entry.endsWith('.ts') || entry.endsWith('.d.ts')) continue;
if (entry.endsWith('.test.ts') || entry.endsWith('.testkit.ts')) continue;
out.push(relative(SRC, full).split(sep).join('/'));
}
return out.sort();
}

describe('the outlet list — every file here that touches HTML is classified', () => {
it('the census reads code, not comments (control)', () => {
expect(touchesHtml('a.ts', '// renders html\nexport const x = 1;')).toBe(false);
expect(touchesHtml('a.ts', 'export const x = `<p>${1}</p>`;')).toBe(true);
expect(touchesHtml('a.ts', 'export const bodyHtml = 1;')).toBe(true);
expect(touchesHtml('a.ts', "export const t = 'text/html';")).toBe(true);
});

it('the census finds the known outlet (positive control)', () => {
const outlet = 'discovery-routes.ts';
expect(Object.keys(OUTLETS)).toContain(outlet);
expect(touchesHtml(outlet, readFileSync(join(SRC, outlet), 'utf8'))).toBe(true);
});

it('the files that touch HTML are exactly the listed outlets and non-outlets', () => {
const touching = shippedSources().filter((rel) => touchesHtml(rel, readFileSync(join(SRC, rel), 'utf8')));
const listed = [...Object.keys(OUTLETS), ...Object.keys(NOT_OUTLETS)].sort();
expect(touching).toEqual(listed);
});

it('no file is both an outlet and a non-outlet', () => {
expect(Object.keys(OUTLETS).filter((f) => f in NOT_OUTLETS)).toEqual([]);
});
});

// ── The per-entry pin ─────────────────────────────────────────────────────

const UNSCOPED = '/api/v1';
const SCOPED = '/api/v1/environments/:environmentId';

/** Mount both bases, a signed-in context, and drive the GET {base}/docs handler. */
function makeRest() {
const httpServer = {
get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(),
use: vi.fn(), listen: vi.fn(), close: vi.fn(),
} as any;
const protocol: any = { getMetaItems: vi.fn(async () => ({ items: [] })) };
const rest = new RestServer(
httpServer, protocol,
{ api: { version: 'v1', enableProjectScoping: true, projectResolution: 'auto' } } as any,
);
(rest as any).resolveExecCtx = vi.fn(async () => ({ userId: 'u_docs_reader' }));
rest.registerRoutes();
return rest;
}

/** The viewer's `data-url` attribute value, as served for a request whose path is `reqPath`. */
async function dataUrlFor(rest: RestServer, mountPath: string, reqPath: string, environmentId?: string): Promise<string> {
const entry = (rest as any).routeManager.get('GET', mountPath);
expect(entry, `GET ${mountPath} must be mounted`).toBeDefined();
let html = '';
const res: any = {
status: () => res,
json: () => res,
send: (body: string) => { html = body; return res; },
setHeader: () => res,
header: () => res,
};
await entry.handler({
method: 'GET', path: reqPath, url: reqPath,
params: environmentId ? { environmentId } : {}, query: {},
headers: { host: 'example.test' },
}, res);
const m = html.match(/data-url="([^\n]*)"><\/script>/);
expect(m, 'the viewer page carries a data-url attribute').not.toBeNull();
return m![1];
}

function pinDocsViewer(): void {
/**
* A path segment carrying the characters a quoted HTML attribute interprets.
* Kept to the minimum the context reads — a quote, an angle bracket, an
* ampersand, an apostrophe — not an attack string.
*/
const SPECIAL_ENV = 'a"<&\'b';
const SPECIAL_ENV_ESCAPED = 'a&quot;&lt;&amp;&#39;b';

it('a value carrying attribute-interpreted characters is escaped for the attribute context', async () => {
const rest = makeRest();
const reqPath = `/api/v1/environments/${SPECIAL_ENV}/docs`;
const dataUrl = await dataUrlFor(rest, `${SCOPED}/docs`, reqPath, SPECIAL_ENV);
expect(dataUrl).toBe(`/api/v1/environments/${SPECIAL_ENV_ESCAPED}/openapi.json`);
// The literal characters do not survive into the served attribute value.
expect(dataUrl).not.toContain('"');
expect(dataUrl).not.toContain('<');
});

it('an ordinary URL is served byte-identical', async () => {
const rest = makeRest();
const unscoped = await dataUrlFor(rest, `${UNSCOPED}/docs`, '/api/v1/docs');
expect(unscoped).toBe('/api/v1/openapi.json');
const scoped = await dataUrlFor(rest, `${SCOPED}/docs`, '/api/v1/environments/env_1/docs', 'env_1');
expect(scoped).toBe('/api/v1/environments/env_1/openapi.json');
});
}

// Registered last, so every helper above is initialised before the pin reads it.
for (const [file, entry] of Object.entries(OUTLETS)) {
describe(`${file} — ${entry.renders}`, entry.pin);
}
Loading