Text.scala's four opaque types validate identically, so their names promise a safety distinction they do not make #52

Open
opened 2026-09-07 09:40:21 +00:00 by mph · 0 comments
Owner

Problem

The Text.scala type family reads as a safety hierarchy but is not one. All four opaque types
validate identically — Text.validateNonEmpty — so the names carry a guarantee the
constructors never make.

core/shared/src/main/scala/works/iterative/core/Text.scala:

Type apply validation Implied by the name
PlainMultiLine Text.validateNonEmpty plain text, safe to display
PlainOneLine validateNonEmpty + no newline plain text, one line
Markdown Text.validateNonEmpty markdown
HtmlText Text.validateNonEmpty markup, safe to emit unescaped

HtmlText and PlainMultiLine are indistinguishable in what they guarantee. Neither performs HTML
escaping, character allow-listing, markup validation or a length cap. Each also has an unsafe
constructor or a direct Option path that skips even the non-emptiness check.

Consequence

A reader reasons from the name: "it is PlainMultiLine, so it is plain"; "it is HtmlText, so
raw() is fine". Both inferences are unsupported. In practice PlainMultiLine carries
staff-authored free text that is later rendered on an end-user-facing page, and the only thing
standing between it and stored XSS is the caller remembering to use an auto-escaping renderer.

Observed in medeca-modul-poptavky (MEDECA-478): a security review had to trace PlainMultiLine into
iw-support to establish that it guarantees nothing but non-emptiness. The rendering happened to be
safe, but nothing in the type said so.

Proposed fix

At minimum, Scaladoc on each of the four types stating exactly what is guaranteed (non-emptiness,
plus no-newline for PlainOneLine) and what is not (no escaping, no markup validation, no length
cap) — so the name is no longer the only documentation.

Better, if it is worth the change: make the safety distinction real rather than nominal.
HtmlText becomes the only type claiming "safe to emit unescaped" and gets a constructor that
earns it (a sanitiser, or an explicitly-trusted unsafe path that is named as such); the plain
types get an escaping path (asHtml / escaped) so converting one into the other is a visible
step rather than an assumption.

Provenance: MEDECA-478 lessons triage, L31.

## Problem The `Text.scala` type family reads as a safety hierarchy but is not one. All four opaque types validate **identically** — `Text.validateNonEmpty` — so the names carry a guarantee the constructors never make. `core/shared/src/main/scala/works/iterative/core/Text.scala`: | Type | `apply` validation | Implied by the name | |---|---|---| | `PlainMultiLine` | `Text.validateNonEmpty` | plain text, safe to display | | `PlainOneLine` | `validateNonEmpty` + no newline | plain text, one line | | `Markdown` | `Text.validateNonEmpty` | markdown | | `HtmlText` | `Text.validateNonEmpty` | markup, safe to emit unescaped | `HtmlText` and `PlainMultiLine` are indistinguishable in what they guarantee. Neither performs HTML escaping, character allow-listing, markup validation or a length cap. Each also has an `unsafe` constructor or a direct `Option` path that skips even the non-emptiness check. ## Consequence A reader reasons from the name: "it is `PlainMultiLine`, so it is plain"; "it is `HtmlText`, so `raw()` is fine". Both inferences are unsupported. In practice `PlainMultiLine` carries staff-authored free text that is later rendered on an end-user-facing page, and the only thing standing between it and stored XSS is the caller remembering to use an auto-escaping renderer. Observed in medeca-modul-poptavky (MEDECA-478): a security review had to trace `PlainMultiLine` into iw-support to establish that it guarantees nothing but non-emptiness. The rendering happened to be safe, but nothing in the type said so. ## Proposed fix At minimum, Scaladoc on each of the four types stating exactly what is guaranteed (non-emptiness, plus no-newline for `PlainOneLine`) and what is not (no escaping, no markup validation, no length cap) — so the name is no longer the only documentation. Better, if it is worth the change: make the safety distinction real rather than nominal. `HtmlText` becomes the only type claiming "safe to emit unescaped" and gets a constructor that earns it (a sanitiser, or an explicitly-trusted `unsafe` path that is named as such); the plain types get an escaping path (`asHtml` / `escaped`) so converting one into the other is a visible step rather than an assumption. Provenance: MEDECA-478 lessons triage, L31.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
iterative-works/support#52
No description provided.