Skip to content

Keep width and height on SafeHTML images - #15383

Merged
rtibbles merged 2 commits into
learningequality:developfrom
rtibblesbot:issue-15378-4dd7bd
Sep 26, 2026
Merged

rtibbles merged 2 commits into
learningequality:developfrom
rtibblesbot:issue-15378-4dd7bd

Conversation

@rtibblesbot

@rtibblesbot rtibblesbot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • <img> keeps width/height; other elements still lose them.
  • Height-only images derive width from the natural ratio, as Studio's editor does.
  • Percentage heights are dropped.
  • Match/associate chips keep their fixed box.
  • Carried inline styles on images and tables apply through $attrs; the image border and table width move to CSS, and parseStyleString goes.

References

Fixes #15378. First seen: learningequality/studio#6189.

Reviewer guidance

  • Unit tests: pnpm test-jest packages/kolibri-common/components/SafeHTML.
  • Open question: an authored size lifts the 80vh cap — should it hold for tall images?
  • Open question: with both attributes, height follows the natural ratio, not a stretched W×H — is distortion ever intended?

QA steps

Setup: pnpm devserver, a learner, a channel imported via Device > Channels with Safe HTML5 articles holding each step's HTML; QTI steps use /en/learn/#/qti_sandbox/. Images over 400×300.

  1. Learn > Library, open an article with images sized W+H, width only, height only, height="50%": authored size, natural ratio, 1px border.
  2. Same article at 360px: images shrink undistorted; Expand image shows only on downscaled ones, opening a closable lightbox.
  3. Open an article with allowlisted (align, float, colour) and disallowed image styles: only allowlisted ones apply.
  4. Open an article with 2-, 3- and 5+-column tables, some sized and styled: 640px up to 3 columns, then 200px each; styles apply; sizes ignored; scrolls at 360px.
  5. In the QTI sandbox, load a choice item with sized images in prompt, choices and hint: authored size; selecting, Submit and the hint work.
  6. In the QTI sandbox, load three items with an image in the same place — styled, unstyled, restyled: each shows only its own style.

Evidence

Sized images

Step Screenshot
W+H, width only W+H, width only
Height only; 50% dropped Height only; 50% dropped

Phone viewport and lightbox

Step Screenshot
360px 360px
Chip on downscaled image Chip on downscaled image
No chip at natural size No chip at natural size
Lightbox open Lightbox open
Closed Closed
s2-phone-lightbox-open-close.webm

Styled images

Step Screenshot
Allowlisted apply, disallowed stripped Allowlisted apply, disallowed stripped
Content swapped, none stale Content swapped, none stale

Tables

Step Screenshot
2, 3 columns 2, 3 columns
5, 7 columns 5, 7 columns
360px scroll 360px scroll

QTI sized images

Step Screenshot
Initial Initial
Answered, hint open Answered, hint open
s5-qti-select-submit-hint.webm

QTI consecutive items

Step Screenshot
Item 1: styled Item 1: styled
Item 2: unstyled Item 2: unstyled
Item 3: styled Item 3: styled
s6-qti-step-styled-unstyled-styled.webm

QTI then Safe HTML5

Step Screenshot
QTI QTI
Article Article
QTI again, unchanged QTI again, unchanged

Interaction chips

Step Screenshot
Match, 1280px Match at 1280px
Match, 360px Match at 360px
Associate, 1280px Associate at 1280px
Associate, 360px Associate at 360px
Associate selected Associate selected

AI usage

Used Claude Code to plan and implement the sanitizer, layout and chip changes with their tests. Verified with the SafeHTML, QTI viewer and Safe HTML5 viewer Jest suites and prek.

Deviations from the issue spec

Criterion Why not
width/height on <img> kept "including when only one is set" Percentage height dropped: it collapses the image.
width/height applied by SafeHTML Match/associate chips keep their fixed box.

@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly

How was this generated?
  • Ran pre-flight CI checks (lint, format, tests) and verified all pass
  • Rebased onto the target branch and resolved any conflicts
  • Reorganized commit history into clean, logical commits
  • Audited the diff to ensure only issue-relevant files are changed
  • Built PR body from the repository's PR template with evidence blocks
@rtibblesbot

🟡 Waiting for feedback

Last updated: 2026-09-26 05:04 UTC

@github-actions github-actions Bot added DEV: renderers HTML5 apps, videos, exercises, etc. DEV: frontend SIZE: medium labels Sep 25, 2026
@github-actions

github-actions Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

@rtibblesbot
rtibblesbot marked this pull request as ready for review September 25, 2026 21:19
@rtibblesbot
rtibblesbot requested review from a team and marcellamaki September 25, 2026 21:19

@rtibbles rtibbles left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This broadly makes sense, and you've properly compensated for why the width and height were originally stripped - but why wouldn't we just add them as props on the image component (and then shouldn't that allow the attributes to stay even if they're forbidden?)

Props alone won't keep them: DOMPurify strips FORBID_ATTR before mapNode reads props, and attributeNameCheck only covers custom elements.

  • Have SafeHTML keep any attribute declared as a prop by the component rendering that tag (HTMLComponents and customComponents), then declare width/height on SafeHtmlImage and drop the img-only hook.
  • Enforce the forbid list in an uponSanitizeAttribute hook, not with forceKeepAttr, which skips _isValidAttribute and would let the src prop bypass ALLOWED_URI_REGEXP.

The style handling can be simpler too:

  • The carried style already reaches the img via v-bind="$attrs"; parseStyleString and the instance.proxy.$attrs workaround only exist to merge it into the image's own :style a second time.
  • Move the border into the scoped style as border: 1px solid var(--tokens-fineLine) and drop :style. Same for SafeHtmlTable, after which parseStyleString.js can go.

// A percentage height resolves against the flex-stretched wrapper, whose
// height is the image's own, so it would shrink the image; drop it.
const height = computed(() => {
const { height } = instance.proxy.$attrs;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you not just add width and height to props?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 0859fd2: width/height are now props, read in place of $attrs. Searched SafeHTML/*.vue for other $attrs reads: 4 matches; the 2 size reads changed, the 2 style reads (image, table) stay since Vue reserves style as a prop name.

Props alone would not keep them:

  • FORBID_ATTR is absolute in DOMPurify: _isValidAttribute returns false before any custom-element check.
  • validProps only feeds attributeNameCheck for customComponents tags; img goes through HTMLComponents, which it never reads.
  • Sanitising runs before mapNode builds props, so a removed attribute never reaches the component.

So index.js still drops FORBID_ATTR and strips the size attributes from non-img elements in a hook.

@rtibblesbot
rtibblesbot force-pushed the issue-15378-4dd7bd branch 2 times, most recently from 0859fd2 to 6b471a4 Compare September 26, 2026 03:53
@rtibbles

Copy link
Copy Markdown
Member

Only the inline props thread was addressed; the review body still stands:

  • Keep any attribute declared as a prop by the tag's component (HTMLComponents and customComponents) via an uponSanitizeAttribute hook, not forceKeepAttr, and drop the img-only hook.
  • Drop the $attrs style read, contentStyle and getCurrentInstance from SafeHtmlImage; v-bind="$attrs" already carries the style.
  • Move the border into the scoped style as border: 1px solid var(--tokens-fineLine) and drop :style, in both SafeHtmlImage and SafeHtmlTable.
  • Delete parseStyleString.js.

@rtibblesbot

Copy link
Copy Markdown
Contributor Author

Done in f92ed0c and 282f127.

Prop allowlist:

  • Each createSafeHTML builds its own purifier; a forbidden attribute survives only when the component for its tag (HTMLComponents or customComponents) declares it as a prop.
  • Enforced in an uponSanitizeAttribute hook; no forceKeepAttr, so src still goes through ALLOWED_URI_REGEXP.
  • The img-only hook is gone. New test: a custom element keeps a declared width and loses an undeclared height.

Styles:

  • Searched SafeHTML/ for :style merges and parseStyleString imports: 2 each (image, table). Changed all.
  • Image border is now 1px solid var(--tokens-fineLine) in the scoped style; :style, contentStyle and the $attrs workaround are gone.
  • The table width is still computed from its column count, so it moves to --table-width on the container, where the other table variables already live.
  • parseStyleString.js and its spec are deleted.

In the capture, the carried style, the border and the 800px table width (4 columns) all come through without :style:

QTI sandbox: sized image, unsized image, styled table

rtibblesbot and others added 2 commits September 25, 2026 21:37
A height-only image derives its width from the natural ratio, as
Studio's editor does. Match/associate chips keep their fixed box.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The image border and table width move to CSS, so nothing merges into
the carried style and parseStyleString goes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@rtibbles rtibbles left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Manually QA'd in the QTI sandbox: all six QA steps pass, including associate chips with sized images. No console errors.

@rtibbles
rtibbles merged commit e6146e6 into learningequality:develop Sep 26, 2026
124 checks passed
@rtibblesbot
rtibblesbot deleted the issue-15378-4dd7bd branch September 26, 2026 05:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

DEV: frontend DEV: renderers HTML5 apps, videos, exercises, etc. SIZE: medium

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SafeHTML drops width and height attributes on images

2 participants