render the HTML an author writes, and narrow the gate to one call site

Dropping raw HTML was silently destructive. H<sub>2</sub>O rendered as "H2O",
10<sup>6</sup> as "106", <kbd>Ctrl</kbd> as "Ctrl", and khosra check reported
nothing — an author lost meaning with no signal anywhere. Measured on the real
binary before and after.

Invariant 2 already says content from the site root is trusted, so the old gate
was defending the half of the boundary that was never in question while the
untrusted half has no code to defend yet. Chemistry, units, exponents and
keystrokes are what a hard-science site needs and what no Markdown dialect
expresses, so html.WithUnsafe() goes on in internal/render/render.go.

The gate does not disappear; it narrows. verify.sh used to fail on WithUnsafe
appearing anywhere and now fails unless it appears in exactly that one file —
watched doing both, accepting one call site and naming both files when a second
appears. A second pipeline trusting its input is the failure ADR-0003 exists to
prevent, and when comments arrive they get their own goldmark without it. The
gate is the reminder that the split has to be built rather than assumed.

The security test that asserted "raw HTML must still be dropped" now asserts the
property that actually holds and matters more: a shortcode argument stays data
whatever the page around it is allowed to do. ::figure{alt=<b>bold</b>} still
arrives as &lt;b&gt; while the <span> beside it renders.

core 2793/2800, ext 1077/2000, 34 gates green, 0 warnings.
This commit is contained in:
Claude Opus 5
2026-08-01 21:08:06 +06:00
committed by bdeshi
parent a893ab1821
commit 1f168d973b
11 changed files with 127 additions and 61 deletions
+32 -32
View File
@@ -122,7 +122,7 @@ doc.go 8 · scaffold.go 94
- scaffold.go:76 func titleFrom(key string) string
- scaffold.go:85 func mkdirAll(root *os.Root, dir string) error
## internal/ext/shortcodes — 611 lines + 427 test
## internal/ext/shortcodes — 611 lines + 435 test
doc.go 7 · images.go 250 · shortcodes.go 354
@@ -178,9 +178,9 @@ doc.go 8 · watch.go 125
- watch.go:90 func record(sum hash.Hash, p string, d fs.DirEntry, err error) error
- watch.go:113 func dropping(name string) bool
## internal/render — 710 lines + 461 test
## internal/render — 713 lines + 489 test
chrome.go 110 · render.go 470 · view.go 130
chrome.go 110 · render.go 473 · view.go 130
- chrome.go:19 var chrome = map[string]map[string]string{
- chrome.go:33 var months = map[string][]string{
@@ -190,34 +190,34 @@ chrome.go 110 · render.go 470 · view.go 130
- chrome.go:73 func numerals(lang string, n int) string
- chrome.go:82 func day(lang string, t time.Time) string
- chrome.go:98 func localiseDigits(lang, s string) string
- render.go:25 var themeFS embed.FS
- render.go:29 type Renderer struct
- render.go:48 type parsedTheme struct
- render.go:62 type Partial func(name string, data Fragment) ([]byte, error)
- render.go:65 type Fragment struct
- render.go:75 type Picture struct
- render.go:92 type Origin struct
- render.go:101 var originKey = parser.NewContextKey()
- render.go:104 func OriginFrom(pc parser.Context) (Origin, bool)
- render.go:111 func WithOrigin(pc parser.Context, origin Origin)
- render.go:124 func New(siteFS fs.FS, settings content.Settings, extend func(Partial) []goldmark.Extender) (*Renderer, error)
- render.go:153 func parseTheme(siteFS fs.FS) (*parsedTheme, error)
- render.go:181 func (r *Renderer) head(title, lang, canonical string) head
- render.go:198 func (r *Renderer) absolute(path string) string
- render.go:206 func (r *Renderer) Navigation(sections func() []string) { r.sections = sections }
- render.go:218 func (r *Renderer) Refresh() error
- render.go:229 func (r *Renderer) Partial(name string, data Fragment) ([]byte, error)
- render.go:248 func parseSet(siteFS fs.FS, names ...string) (*template.Template, error)
- render.go:270 func readStyle(siteFS fs.FS) (template.CSS, error)
- render.go:288 func (r *Renderer) Extras(b content.Bundle, served string, entries []content.Entry, selected *Selected) ([]byte, error)
- render.go:309 func (r *Renderer) RenderText(kind string, data []byte) (template.HTML, error)
- render.go:327 func (r *Renderer) Bundle(b content.Bundle, served string, variants []string, seq *content.Sequence) ([]byte, error)
- render.go:364 func (r *Renderer) Listing(section, lang string, all []content.Bundle, page int) ([]byte, error)
- render.go:383 func (r *Renderer) Tag(section, slug, lang string, all []content.Bundle, page int) ([]byte, error)
- render.go:410 func (r *Renderer) sequence(seq *content.Sequence, lang string) *Sequence
- render.go:437 func (r *Renderer) item(b content.Bundle, lang string) Item
- render.go:442 func (r *Renderer) paginate(title, lang, canonical string, all []content.Bundle, page int, url func(int) string) (List, []content.Bundle)
- render.go:464 func (r *Renderer) execute(set *template.Template, data any, what string) ([]byte, error)
- render.go:26 var themeFS embed.FS
- render.go:30 type Renderer struct
- render.go:49 type parsedTheme struct
- render.go:63 type Partial func(name string, data Fragment) ([]byte, error)
- render.go:66 type Fragment struct
- render.go:76 type Picture struct
- render.go:93 type Origin struct
- render.go:102 var originKey = parser.NewContextKey()
- render.go:105 func OriginFrom(pc parser.Context) (Origin, bool)
- render.go:112 func WithOrigin(pc parser.Context, origin Origin)
- render.go:125 func New(siteFS fs.FS, settings content.Settings, extend func(Partial) []goldmark.Extender) (*Renderer, error)
- render.go:156 func parseTheme(siteFS fs.FS) (*parsedTheme, error)
- render.go:184 func (r *Renderer) head(title, lang, canonical string) head
- render.go:201 func (r *Renderer) absolute(path string) string
- render.go:209 func (r *Renderer) Navigation(sections func() []string) { r.sections = sections }
- render.go:221 func (r *Renderer) Refresh() error
- render.go:232 func (r *Renderer) Partial(name string, data Fragment) ([]byte, error)
- render.go:251 func parseSet(siteFS fs.FS, names ...string) (*template.Template, error)
- render.go:273 func readStyle(siteFS fs.FS) (template.CSS, error)
- render.go:291 func (r *Renderer) Extras(b content.Bundle, served string, entries []content.Entry, selected *Selected) ([]byte, error)
- render.go:312 func (r *Renderer) RenderText(kind string, data []byte) (template.HTML, error)
- render.go:330 func (r *Renderer) Bundle(b content.Bundle, served string, variants []string, seq *content.Sequence) ([]byte, error)
- render.go:367 func (r *Renderer) Listing(section, lang string, all []content.Bundle, page int) ([]byte, error)
- render.go:386 func (r *Renderer) Tag(section, slug, lang string, all []content.Bundle, page int) ([]byte, error)
- render.go:413 func (r *Renderer) sequence(seq *content.Sequence, lang string) *Sequence
- render.go:440 func (r *Renderer) item(b content.Bundle, lang string) Item
- render.go:445 func (r *Renderer) paginate(title, lang, canonical string, all []content.Bundle, page int, url func(int) string) (List, []content.Bundle)
- render.go:467 func (r *Renderer) execute(set *template.Template, data any, what string) ([]byte, error)
- view.go:16 type head struct
- view.go:36 type Page struct
- view.go:55 type Sequence struct
@@ -228,7 +228,7 @@ chrome.go 110 · render.go 470 · view.go 130
- view.go:111 type Item struct
- view.go:122 type Alternate struct
## internal/web — 734 lines + 1599 test
## internal/web — 734 lines + 1601 test
asset.go 58 · discover.go 71 · extras.go 93 · feed.go 125 · resolve.go 170 · web.go 217