Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
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
19 changes: 19 additions & 0 deletions .changeset/13449-retire-html-renderer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@object-ui/components': minor
'@object-ui/types': minor
'@object-ui/core': minor
'@object-ui/cli': minor
---

The raw-HTML `html` node type is retired (objectui#13449). Its renderer wrote the authored `html` string into the page through `dangerouslySetInnerHTML` with no sanitiser, so anyone who could write metadata could put script-bearing markup in front of every viewer — a `kind: 'html'` page included, which compiled an `html` tag carrying an `html` attribute with no diagnostic. `@objectstack/spec` declares no `html` component, so it is removed rather than sanitised, at once, with no deprecation alias. This is breaking for a page that still names it; it is marked `minor` under this repository's version policy.

- **SDUI node `html`**, also registered as `ui:html`. FROM: `@object-ui/components` registered both keys. TO: neither is registered. A JSON node that still names it draws the unknown-component panel, a `kind: 'html'` page that writes the tag fails to compile with `forbidden-tag` and `unknown-component`, and `objectui check` no longer counts either key among the known component types.
- **Public block `html`** in `PUBLIC_BLOCKS` (`@object-ui/core`). FROM: listed as the curated tier's escape hatch, so `getPublicConfigs()` and the published `sdui.manifest.json` carried it. TO: not listed, so neither names it.
- **Schema `HtmlSchema`** in `@object-ui/types`: the TypeScript interface from the package entry, with its `DataDisplaySchema` member and its `SchemaRegistry` row (so `ComponentType` no longer offers `'html'`), and the zod arm from `@object-ui/types/zod`. FROM: exported, and a `type: "html"` node validated against the arm. TO: there is no such export, so importing it fails to compile. `DataDisplaySchema` keeps a refusal arm for the literal, which is not exported, so `objectui validate` refuses a `type: "html"` node BY NAME at `type`, and the message names the remedy.

The `html` FIELD type (`HtmlFieldMetadata`, drawn by `field:html`) is a different layer and stays: its value displays through the sanitised fields path.

Fix for a page that still names it: in place of `{ "type": "html", "html": "…" }`, declare rich text a user edits as an `html` or `richtext` field on the object, whose value renders sanitised, and write authored page copy as a `kind: 'html'` page.

Clause-②: no (narrowing)
Metadata-protocol: narrowed
140 changes: 140 additions & 0 deletions apps/console/src/__tests__/html-node-retired-13449.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* objectui#13449 — the raw-HTML `html` node type is retired, and neither
* authoring surface can put unsanitised markup into the page any more.
*
* ## The defect
*
* `@object-ui/components` registered `html` (`ui:html` plus the bare key),
* whose renderer wrote the authored `html` string into a `div` through
* `dangerouslySetInnerHTML` with no sanitiser. `@objectstack/spec` declares no
* `html` component. Two doors reached it:
*
* 1. a JSON node `{ "type": "html", "html": "…" }`;
* 2. a `kind:'html'` page — the tier documented as parsed, never executed,
* and safe for untrusted authors. Its compile whitelists every registered
* type, so `<html html="…" />` compiled with NO diagnostic and the
* renderer injected the string. Measured on the base before the fix: an
* `img` carrying an `onerror` attribute reached the DOM through both doors.
*
* ## What each suite holds
*
* 1. the `kind:'html'` page refuses the tag at compile time and renders no
* authored markup (red on the base: zero diagnostics, the `img` with its
* `onerror` attribute in the DOM);
* 2. the JSON node draws the unknown-component panel and no authored markup
* (red on the base: the markup was injected);
* 3. CONTROL — the same page without the retired tag renders its authored
* content, so suite 1's absence is not a page that renders nothing.
*
* It lives in `apps/console` because the claim is about the whole registration
* graph: `@object-ui/fields` registers a `field:html` editor that must not take
* over the bare key once the display renderer is gone.
*/
import { describe, it, expect } from 'vitest';
import { render, waitFor } from '@testing-library/react';
import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react';
import { ComponentRegistry } from '@object-ui/core';
import { compile, manifestFromConfigs } from '@object-ui/sdui-parser';

// The full registration graph — the pair `dev/manifest-dump.tsx` builds the
// published artifacts from, and the pair every live-path suite here reads.
import '@object-ui/components';
import '../register-plugins';

const dataSource = {
find: async () => ({ data: [], total: 0, hasMore: false }),
findOne: async () => null,
create: async () => ({}),
update: async () => ({}),
delete: async () => ({}),
count: async () => 0,
getObjectSchema: async (name: string) => ({ name, label: name, fields: {} }),
getObjects: async () => [],
onMutation: () => () => {},
} as any;

/**
* The manifest an html-kind page validates against, built the way the renderer
* builds it — `ComponentRegistry.getKnownTypes()` + each type's declared
* `inputs`, mirroring `page.tsx`'s `getJsxManifest()`.
*/
const livePageManifest = () =>
manifestFromConfigs(
ComponentRegistry.getKnownTypes().map((t) => {
const meta = ComponentRegistry.getMeta(t);
return { type: t, namespace: meta?.namespace, isContainer: meta?.isContainer, inputs: meta?.inputs };
}) as unknown as Parameters<typeof manifestFromConfigs>[0],
);

function renderNode(schema: Record<string, unknown>) {
return render(
<SchemaRendererProvider dataSource={dataSource}>
<SchemaRenderer schema={schema as any} />
</SchemaRendererProvider>,
);
}

/** Script-bearing markup, as an author would write it. Never executed here: happy-dom loads no image. */
const PAYLOAD = '<img src="x" data-probe="img13449" onerror="window.__xss13449=1"><b id="p13449">raw</b>';

/** Did any of the authored markup reach the DOM as elements? */
function injected(container: HTMLElement) {
return {
img: container.querySelector('img[data-probe="img13449"]') !== null,
onerror: container.querySelector('[onerror]') !== null,
bold: container.querySelector('#p13449') !== null,
};
}

const NOTHING_INJECTED = { img: false, onerror: false, bold: false };

describe("suite 1 — a `kind:'html'` page cannot write the retired tag (objectui#13449)", () => {
const SOURCE = `<main><html html='${PAYLOAD}' /></main>`;

it('the live compile refuses `<html>` by name', () => {
const diagnostics = compile(SOURCE, livePageManifest()).diagnostics;
const onTag = diagnostics.filter((d) => (d as { tag?: string }).tag === 'html');
expect(onTag.length).toBeGreaterThan(0);
expect(onTag.every((d) => d.severity === 'error')).toBe(true);
});

it('the rendered page carries none of the authored markup', async () => {
const { container } = renderNode({ type: 'page', kind: 'html', name: 'retired_html_page', source: SOURCE });
// The page settles on its compile report; wait until it has drawn anything.
await waitFor(() => expect(container.textContent ?? '').not.toEqual(''));
expect(injected(container)).toEqual(NOTHING_INJECTED);
});
});

describe('suite 2 — a JSON `html` node draws the unknown-component panel (objectui#13449)', () => {
it('neither registry key survives, and the field editor did not take the bare one', () => {
expect(ComponentRegistry.get('html')).toBeFalsy();
expect(ComponentRegistry.get('ui:html')).toBeFalsy();
// Lit control: the FIELD widget of the same name is still registered.
expect(ComponentRegistry.get('field:html')).toBeTruthy();
});

it('the node renders no authored markup', async () => {
const { container } = renderNode({ type: 'html', html: PAYLOAD });
await waitFor(() => expect(container.querySelector('[role="alert"]')).not.toBeNull());
expect(injected(container)).toEqual(NOTHING_INJECTED);
});
});

describe('suite 3 — CONTROL: the same page without the retired tag renders its content', () => {
it('an html-tier `p` reaches the DOM with its text', async () => {
const { container } = renderNode({
type: 'page',
kind: 'html',
name: 'control_page',
source: '<main><p className="ctl13449">control text</p></main>',
});
await waitFor(() => expect(container.querySelector('p.ctl13449')?.textContent).toBe('control text'));
});
});
7 changes: 6 additions & 1 deletion apps/console/src/__tests__/html-tier-manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,12 @@ describe('the curated tier is unmoved — the JSON-surface vocabulary did not gr
expect(curated).toEqual(PUBLIC_BLOCKS.filter((tag) => tag in published.components));
// Anti-vacuity: the curated tier resolved, and to the vocabulary it is.
expect(curated.length).toBeGreaterThan(50);
expect(curated).toEqual(expect.arrayContaining(['object-grid', 'box', 'html']));
expect(curated).toEqual(expect.arrayContaining(['object-grid', 'box', 'text']));
// The raw-HTML `html` block left the curated tier with its node type
// (objectui#13449). Read off the manifest the published artifact is built
// from, not off the roster, so a stray registration would show up here.
expect(curated).not.toContain('html');
expect('html' in published.components).toBe(false);
});

it('the stamped entries are exactly the roster, in roster order, and nothing else', () => {
Expand Down
2 changes: 1 addition & 1 deletion apps/console/src/__tests__/public-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ const EXPECTED_COVERED = [
'badge',
'alert',
'button',
'html',
// `html` left with the raw-HTML node type it named (objectui#13449).
];

/**
Expand Down
17 changes: 0 additions & 17 deletions content/docs/components/basic/html.mdx

This file was deleted.

1 change: 0 additions & 1 deletion content/docs/components/basic/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
"icon",
"image",
"separator",
"html",
"button-group",
"pagination",
"navigation-menu",
Expand Down
1 change: 0 additions & 1 deletion content/docs/components/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ The foundation of your UI. These are simple, single-purpose components:
- **[Icon](/docs/components/basic/icon)** - Render icons from Lucide React
- **[Image](/docs/components/basic/image)** - Display images with lazy loading
- **[Separator](/docs/components/basic/separator)** - Visual divider between content
- **[HTML](/docs/components/basic/html)** - Render raw HTML content

### Form Components

Expand Down
6 changes: 6 additions & 0 deletions content/docs/guide/react-pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,12 @@ look uniform — `<record:related-list>` is not registered, only
The plain wrapper on this tier is `<box>`. `<div>` is deprecated here as it is in
JSON, and the page is refused at compile time with an error that names `box`.

There is no raw-HTML block on this tier, or on any other. The `html` node type,
which wrote an authored HTML string into the page unsanitised, is retired, so an
`<html>` tag is refused at compile time as an unknown component. Rich text a user
edits belongs in an `html` or `richtext` field, whose value renders through the
sanitised fields path.

Use it for anything author- or AI-generated. Expressions are limited to what the
schema supports (`${data.x}`), and there is no local state or event handling
beyond the action system.
Expand Down
10 changes: 0 additions & 10 deletions examples/schema-catalog/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ import components_basic_div_grid_layout from './schemas/components-basic-div/gri
import components_basic_div_nested_divs from './schemas/components-basic-div/nested-divs.json' with { type: 'json' };
import components_basic_div_use_box_instead from './schemas/components-basic-div/use-box-instead.json' with { type: 'json' };
import components_basic_div_use_card_instead from './schemas/components-basic-div/use-card-instead.json' with { type: 'json' };
import components_basic_html_basic_html from './schemas/components-basic-html/basic-html.json' with { type: 'json' };
import components_basic_icon_basic_icon from './schemas/components-basic-icon/basic-icon.json' with { type: 'json' };
import components_basic_icon_colored_icons from './schemas/components-basic-icon/colored-icons.json' with { type: 'json' };
import components_basic_icon_icon_sizes from './schemas/components-basic-icon/icon-sizes.json' with { type: 'json' };
Expand Down Expand Up @@ -704,15 +703,6 @@ const REGISTRY: Record<string, Example> = {
},
schema: components_basic_div_use_card_instead,
},
'components-basic-html/basic-html': {
id: 'components-basic-html/basic-html',
meta: {
title: "Basic Html",
description: "",
category: 'components-basic-html',
},
schema: components_basic_html_basic_html,
},
'components-basic-icon/basic-icon': {
id: 'components-basic-icon/basic-icon',
meta: {
Expand Down

This file was deleted.

Loading
Loading