Skip to content

feat(web-app-html-editor): add an HTML editor with live preview#13895

Open
dj4oC wants to merge 12 commits into
masterfrom
feat/web-app-html-editor
Open

feat(web-app-html-editor): add an HTML editor with live preview#13895
dj4oC wants to merge 12 commits into
masterfrom
feat/web-app-html-editor

Conversation

@dj4oC

@dj4oC dj4oC commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a new core app, web-app-html-editor, that opens .html / .htm / .xhtml
files in a CodeMirror 6 source editor with a live, sandboxed preview. No WOPI and no
extra server: the file is loaded and saved over WebDAV by the standard AppWrapper,
the source is edited in CodeMirror, and the rendered result is shown in a strictly
sandboxed <iframe srcdoc>.

Approach

The app is intentionally thin and idiomatic. It mirrors web-app-text-editor:
defineWebApplication registers it by file extension and routes through
AppWrapperRoute. By declaring a currentContent prop and emitting
update:currentContent, it inherits the framework's WebDAV load/save, dirty
tracking, Ctrl+S, autosave, the unsaved-changes guard and error notifications, so
none of that is reimplemented. The app itself only composes:

  • HtmlEditorPane.vue - a CodeMirror 6 wrapper (HTML mode, line numbers, theme
    follows the active ownCloud theme). CodeMirror 6 is already in the workspace via
    md-editor-v3, so the individual @codemirror/* packages are added at the
    versions already in the lockfile.
  • HtmlPreviewPane.vue - a sandboxed srcdoc iframe.
  • HtmlToolbar.vue - an Editor | Split | Preview view-mode toggle (CSS grid; the
    filename, Save and action menu come from AppTopBar).

The app is enabled by default via the apps array in config/config.json.dist and
config/config.json.sample-ocis.

Security

The preview renders attacker-influenceable HTML (a user may open a file shared to
them), so the preview is treated as untrusted:

  • The iframe uses sandbox="allow-scripts" with no allow-same-origin (opaque
    origin), so the preview cannot read the shell's cookies, storage or OIDC token,
    cannot call the oCIS API as the user, and cannot script the parent. allow-forms
    and allow-popups are deliberately omitted.
  • A strict CSP (default-src 'none'; form-action 'none'; base-uri 'none'; inline
    script/style and data:/blob: only) is injected into the srcdoc, so the
    preview is network-isolated and does not depend on the deployment proxy CSP.
  • The live preview is paused for very large files until the user opts in, so a huge
    or hostile document cannot freeze the tab on open.
  • A regression test pins the exact sandbox contract (token set + srcdoc-not-src).

Trade-off: because the preview is network-isolated, documents that reference
external stylesheets/scripts/images will not load them. This is intentional for
a first version. The package ships ARCHAEOLOGY.md, DECISIONS.md and
SECURITY-REVIEW.md documenting the design rationale and the threat model.

Testing

  • Unit tests (vitest): app wiring, the CodeMirror pane, the preview pane (sandbox
    contract, srcdoc), the toolbar, the CSP-injection helper, and the large-file
    preview pause. pnpm --filter html-editor test:unit is green (27 tests).
  • vue-tsc --noEmit, eslint and prettier are clean.
  • Verified manually against a running oCIS 8.0.4: opening a .html file (the app is
    picked up as the default opener), live edit updating the preview, and saving back
    over WebDAV.

Notes

  • Registration is by file extension (oCIS associates apps to files by extension, not
    MIME type), matching web-app-text-editor.
  • No drag-to-resize split handle in this version (kept simple).

dj4oC and others added 5 commits June 19, 2026 15:20
Document how an oCIS editor app actually works before writing code: apps
register by file extension (not MIME type), and AppWrapper already provides
the WebDAV load/save, dirty tracking, Ctrl+S, unsaved-changes guard and error
toasts. Record the editor-library, preview-sandboxing, layout and registration
decisions, each citing the Phase 1 file it relies on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: David Walter <david.walter@kiteworks.com>
Mirror the text-editor package layout (package.json, vitest config, l10n) and
add CodeMirror 6 (already resolved in the lockfile via web-pkg) as direct deps.
Enable the app by adding "html-editor" to the default and sample-ocis configs;
the build auto-discovers it by its web-app-* directory name.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: David Walter <david.walter@kiteworks.com>
…ew modes

Register for the html, htm and xhtml extensions and route through AppWrapper,
so WebDAV load/save, dirty state, Ctrl+S and the unsaved-changes guard are
inherited. App.vue is a thin shell that binds currentContent and emits
update:currentContent; it composes a CodeMirror 6 source editor (HTML mode,
themed via ODS tokens), a sandboxed srcdoc iframe preview (no allow-same-origin),
and a view-mode toolbar (Editor | Split | Preview) laid out with CSS grid.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: David Walter <david.walter@kiteworks.com>
…olbar

Cover the app wiring (re-emits edits, switches view mode, debounces the
preview), the CodeMirror pane (renders, emits on change, applies external
content), the preview pane (srcdoc, sandbox without allow-same-origin, no
referrer) and the toolbar (active mode, emits changeMode).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: David Walter <david.walter@kiteworks.com>
…view

An adversarial security review (SECURITY-REVIEW.md) confirmed the design is sound
against the primary threat (a victim opening an attacker-controlled HTML file): the
opaque-origin iframe plus bearer-token-in-JS auth prevent token theft, parent-origin
XSS and acting as the victim. This tightens the residual low/info findings:

- Reduce the iframe sandbox to "allow-scripts" only. allow-forms and allow-popups
  added no value for a preview but enabled zero-click phishing / external form-POST
  beaconing from the opaque-origin frame; dropping them removes the vector.
- Inject a strict, self-contained CSP into the preview srcdoc (default-src 'none';
  form-action 'none'; base-uri 'none'; inline script/style and data:/blob: only) so
  the preview is network-isolated and not reliant on the deployment proxy CSP.
- Pause the live preview for large files until the user opts in, so a huge or
  hostile document cannot freeze the tab on open.
- Pin the full sandbox contract (exact token set + srcdoc-not-src) in tests so a
  future change cannot silently loosen the one control everything depends on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: David Walter <david.walter@kiteworks.com>
@update-docs

update-docs Bot commented Jun 19, 2026

Copy link
Copy Markdown

Thanks for opening this pull request! The maintainers of this repository would appreciate it if you would create a changelog item based on your changes.

@kw-security

kw-security commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues
Code Security 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

#13895

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: David Walter <david.walter@kiteworks.com>
@dj4oC dj4oC requested a review from LukasHirt June 19, 2026 14:12
dj4oC and others added 2 commits June 19, 2026 20:52
SonarCloud flagged the appInfo.extensions map and the app-switcher menu-item
block in index.ts as duplicated against web-app-text-editor (new-code duplication
over the 3% gate). Assign the already-typed fileExtensions directly instead of
re-mapping them, and drop the app-switcher launcher entry: it was copied
boilerplate and is not needed for v1. Users can still create a new HTML file from
the Files "New" menu via the extension's newFileMenu.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: David Walter <david.walter@kiteworks.com>
… test

The shared unit-test run (vitest projects) went red on web-pkg's @vitest/web-worker
tests with timeouts only after this package was added, and the symptom (worker
callbacks never firing) matches fake timers being active during real-timer async.
Rewrite the debounce assertion to use real timers and a short wait so this test
project cannot influence timer state in the shared run. No change to behavior under
test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: David Walter <david.walter@kiteworks.com>

@LukasHirt LukasHirt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Is this the correct location or should it live rather in the web-extensions repo?

@dj4oC

dj4oC commented Jun 23, 2026

Copy link
Copy Markdown
Contributor Author

Is this the correct location or should it live rather in the web-extensions repo?

If we move this to web-extensions, we need to move md-editor as well. Same stuff is reused in here

Adding the html-editor package regenerated the lockfile, which let vitest
('^4.1.5') float to 4.1.6 while @vitest/web-worker and @vitest/coverage-v8
stay pinned at 4.1.5. That resolved a second vitest@4.1.5 copy into web-pkg,
so @vitest/web-worker and the test runner loaded different vitest module
instances and the webWorker specs (exportAsPdfWorker, pasteWorker,
restoreWorker) hung until the 5s timeout.

Pin vitest to 4.1.6 via pnpm overrides so the whole workspace resolves a
single vitest, matching master's (green) topology.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: David Walter <david.walter@kiteworks.com>
if (!html) {
return META
}
const headOpen = /<head\b[^>]*>/i

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Security — High] The regex /<head\b[^>]*>/i matches the first occurrence of <head anywhere in the document, including inside HTML comments. A document like:

<!-- <head> -->
<html><head><title>X</title></head>...

causes .replace() to inject the CSP <meta> into the comment (which the browser ignores), while the real <head> element receives no CSP injection. The preview iframe then renders the hostile document with default-src 'none' and form-action 'none' unenforced — defeating the defense-in-depth layer.

The same applies to the /<html\b[^>]*>/i branch at line 41.

Fix: strip HTML comments before testing, or use a parser instead of regex to locate the insertion point.

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.

Fixed in 0614496. Instead of detecting the head at all, wrapWithPreviewCsp now prepends the <meta> as the document's very first bytes unconditionally — so there is no regex to divert and no before-<head> gap (same root cause as @DeepDiver1975's blocking note). firstUncommentedMatch is removed, and new regression tests cover both the <!-- <head> --> decoy and a resource placed before <head>.

watch(
() => currentContent,
(value) => {
if (isPreviewTooLarge(value) && !renderLargeAnyway.value) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Security — High] renderLargeAnyway is set to true in showPreviewAnyway() but is never reset. After a user opts in once for a large file, any subsequent change to currentContent — including a conflict-reload that replaces the file with an attacker-controlled version — immediately calls schedulePreview() without going through the pause gate, because this watch condition evaluates to false.

A realistic path: user clicks 'Show preview anyway' on a 600 KB file → attacker updates the file via a concurrent write → AppWrapper detects a 412 conflict and reloads → the watch fires with the new (still large, now hostile) content → renderLargeAnyway.value === true → preview renders automatically, no prompt.

Fix: reset renderLargeAnyway.value = false at the top of the watch handler (before the early-return check) so each new content version starts paused.

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.

Fixed: the content watcher resets renderLargeAnyway to false on every content change, so the opt-in is scoped to the exact document the user approved — a later large document (e.g. a conflict reload) re-pauses and re-prompts rather than auto-rendering. Covered by the re-pauses after opt-in when the content changes unit test.

() => currentContent,
(value) => {
if (isPreviewTooLarge(value) && !renderLargeAnyway.value) {
previewContent.value = ''

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Bug — Medium] The early-return path for large content sets previewContent.value = '' but does not cancel the pending previewTimer. If a timer was queued from a previous small-content update, it fires up to 250 ms later and writes stale HTML back into previewContent, restoring an old preview snapshot even though the pause gate is active.

Sequence:

  1. User types; schedulePreview() queues a 250 ms timer with snapshot S.
  2. User pastes a large block; watch fires, sets previewContent = '', returns early — timer is still live.
  3. Timer fires → previewContent = wrapWithPreviewCsp(S) (stale snapshot re-appears).
  4. User clicks 'Show preview anyway' → sees stale content for up to 250 ms instead of the actual current document.

Fix: call clearTimeout(previewTimer) (and set previewTimer = undefined) before returning early.

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.

Fixed: the large-content early-return now clears the pending previewTimer before setting previewContent = '', so a previously-scheduled small-content render cannot fire after the content has grown past the limit.


const showPreviewAnyway = () => {
renderLargeAnyway.value = true
schedulePreview(currentContent)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Bug — Medium] showPreviewAnyway() sets renderLargeAnyway.value = true synchronously, which immediately flips the v-if/v-else in the template so <html-preview-pane> mounts. At that moment previewContent.value is still '' (it was cleared by the watch's early-return path), so the iframe receives srcdoc="" — an empty, CSP-less page — and the user sees a blank preview for up to 250 ms until the schedulePreview timer fires.

Fix: call schedulePreview synchronously and await nextTick() before flipping renderLargeAnyway, or set previewContent to the wrapped value synchronously inside this function (skipping the debounce for this one explicit opt-in action).

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.

Fixed: showPreviewAnyway() cancels any pending timer and sets previewContent synchronously, so the pane no longer mounts empty for 250 ms after opt-in. Covered by the rendered synchronously on opt-in assertion.

// reaches the (sandboxed, opaque-origin) preview. Both panes stay mounted across
// view-mode switches (CSS grid collapses the hidden column) so the editor keeps
// its cursor/undo.
const previewContent = ref(previewPaused.value ? '' : wrapWithPreviewCsp(currentContent ?? ''))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Bug — Minor] The initial previewContent is computed synchronously at setup time, bypassing the watcher and the 250 ms debounce. Subsequent external content changes (e.g. a conflict-reload updating the currentContent prop) go through schedulePreview and arrive 250 ms later. This creates a structural inconsistency: the initial render is immediate, but every prop update after that is delayed.

In practice this means a user who saves the file from another tab and triggers a conflict-reload will see the editor pane update instantly while the preview pane lags by 250 ms — a momentary split-brain state that could look like a failed save.

Consider routing the initial render through schedulePreview as well (or using schedulePreview with a 0 ms delay for the initial case) so the two paths are identical.

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.

Kept intentional. The synchronous initial previewContent is what makes the split view show a preview immediately on open — the wraps the preview content with a strict iframe CSP test asserts the pane has content at mount with no awaited debounce. Routing the initial value through the 250 ms debounce would blank the preview on every open. It is a single expression and it already honours the paused state. Happy to unify via an immediate watch that renders synchronously on first run if you would prefer, but it nets out the same behaviour.

}
)

watch(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Bug — Minor] The watch source is () => isDark(), but isDark() is a plain function that reads themeStore?.currentTheme via optional chaining. When themeStore is undefined (as it is under vi.mock('@ownclouders/web-pkg')), the getter has no reactive dependencies to track, so the watcher never fires in tests. Any regression that breaks dark-mode theme switching in the editor passes the full test suite silently — the watch path is structurally untested.

In production the watcher works because themeStore is always defined, but the tested and production code paths diverge structurally. This is related to the test-accommodation issue at line 39.

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.

Fixed in 0614496: dropped the optional chaining, so isDark() reads themeStore.currentTheme directly. Added a test (reconfigures the editor when the theme dark mode changes) that flips the store's currentTheme ref and asserts the watch dispatches a theme reconfigure — so the watch path is now exercised, not just present.

watch(
() => currentContent,
(value) => {
if (isPreviewTooLarge(value) && !renderLargeAnyway.value) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Cleanup] The condition isPreviewTooLarge(value) && !renderLargeAnyway.value here duplicates the previewPaused computed defined at line 73. If the pause logic ever gains a third flag, the watch body must be updated separately and the two can silently diverge.

Consider replacing with previewPaused.value to keep a single source of truth for what 'paused' means.

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.

The watcher cannot reuse the previewPaused computed directly: previewPaused reads currentContent (the committed prop), whereas the watcher must test the incoming value and, per the fix above, reset renderLargeAnyway first before deciding to pause. Both paths already share the single size predicate isPreviewTooLarge(). Reusing the computed would reintroduce the stale-renderLargeAnyway bug, so I kept them separate — glad to extract a tiny helper if you would still like the call sites visually unified.

// The theme store is optional here: in unit tests `@ownclouders/web-pkg` is mocked
// and `useThemeStore()` returns undefined, so we read it defensively.
const themeStore = useThemeStore()
const isDark = () => Boolean(unref(themeStore?.currentTheme)?.isDark)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Cleanup] The themeStore?.currentTheme optional-chaining guard was added to survive vi.mock('@ownclouders/web-pkg') returning undefined in tests — a test-accommodation that leaks into production code. Every other consumer of useThemeStore in the monorepo (e.g. web-pkg/src/components/TextEditor/TextEditor.vue, web-app-epub-reader/src/App.vue) destructures currentTheme directly without optional chaining, because the real store always initialises a theme.

The correct fix is in the test: use a partial mock that returns a real-shaped store object, as epub-reader does:

useThemeStore: vi.fn(() => ({ currentTheme: ref({ isDark: false }) }))

That removes the need for defensive optional-chaining in production code, and the watcher at line 132 can then track a real reactive dependency.

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.

Fixed in 0614496: removed the themeStore?. guard. The test mock now returns a real-shaped store ({ currentTheme: ref({ isDark: false }) }) instead of the bare vi.mock('@ownclouders/web-pkg') auto-mock, so the production code no longer carries a test-only accommodation and the dark-mode watch tracks a real reactive dependency.

extension: 'html',
label: () => $gettext('HTML file'),
newFileMenu: {
menuTitle() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Cleanup] menuTitle() duplicates the label of the same extension entry — both return $gettext('HTML file'). If the label is ever renamed, a developer who updates label but misses menuTitle will silently show the old string in the 'New >' create menu while all other surfaces show the new one.

The text-editor avoids this by deriving menuTitle from extensionItem.label directly. Consider the same pattern here, or at minimum extract a shared constant.

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.

Resolved: both label and newFileMenu.menuTitle reference the same htmlFileLabel factory now, so renaming the label updates the menu entry too.

color: '#e34c26',
defaultExtension: 'html',
meta: {
fileSizeLimit: 2000000

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Cleanup] fileSizeLimit (2 000 000 bytes, here) and PREVIEW_SIZE_LIMIT (500 000 bytes, in helpers/preview.ts) are two independent hardcoded thresholds that govern the same file but have no enforced relationship. Currently the wrapper warns at 2 MB while the preview pauses at 500 KB, which is the intended order — but there is nothing to prevent a future edit from reversing them (e.g. raising PREVIEW_SIZE_LIMIT above 2 MB), and no comment explaining the dependency.

Consider adding a runtime assertion PREVIEW_SIZE_LIMIT < fileSizeLimit, or exporting PREVIEW_SIZE_LIMIT from helpers/preview.ts and referencing it here so the relationship is explicit.

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.

Fixed in 0614496: index.ts imports PREVIEW_SIZE_LIMIT, defines FILE_SIZE_LIMIT, and asserts PREVIEW_SIZE_LIMIT < FILE_SIZE_LIMIT at module load — so the AppWrapper open limit and the preview-pause limit cannot silently drift apart.

… guard

Address review findings:

- Security: wrapWithPreviewCsp() matched <head>/<html> with a raw regex, so a
  crafted '<!-- <head> -->' could divert the CSP <meta> into a commented-out
  tag and leave the rendered preview without its self-protecting CSP. Matching
  now skips HTML comments and injects into the first real head/html.
- Security: renderLargeAnyway was never reset, so a single 'show preview
  anyway' opt-in permanently disabled the large-file pause; a later external
  content change could auto-render another large/hostile document. The guard
  now re-arms on every content change.
- Bug: the large-content watch path cleared previewContent but left a queued
  debounce timer that could still render the oversized content; it is now
  cancelled.
- Bug: opting in now renders synchronously instead of through the 250ms
  debounce, so the preview pane no longer mounts empty.
- Cleanup: de-duplicate the 'HTML file' label/menuTitle.

Adds regression tests for the comment-evasion and guard re-arm behaviour.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: David Walter <david.walter@kiteworks.com>
@LukasHirt

Copy link
Copy Markdown
Collaborator

Run pnpm format to fix the prettier error please.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: David Walter <david.walter@kiteworks.com>

@DeepDiver1975 DeepDiver1975 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.

Thanks for an unusually careful PR — the threat model, the opaque-origin reasoning, and the pinned sandbox-contract test are exactly the right shape for a feature that renders attacker-influenceable HTML. I did an adversarial security review (including loading hostile srcdoc into a real opaque-origin sandboxed iframe with this PR's exact CSP-injection helper) plus a code/test/i18n pass. Overall this is close, and the high-value attacks are structurally prevented — but there is one real gap between the stated "network-isolated preview" guarantee and the actual behavior that I'd like fixed before merge.

Security — high-value goals are sound (verified)

  • No allow-same-origin ⇒ opaque origin. Verified: a preview script calling window.parent.localStorage throws SecurityError, so the OIDC bearer token (held in parent-origin JS, not a cookie) is unreachable. Token theft, authenticated API calls as the victim, parent-DOM XSS, and iframe breakout are all structurally prevented.
  • default-src 'none' blocks script-driven egress: verified fetch/XHR/sendBeacon/WebSocket/EventSource/new Image()/dynamic prefetch and document.write-injected subresources are all denied.
  • Sandbox blocks window.open, window.top.location, <a target=_top>; form-action 'none' blocks form POST/GET. firstUncommentedMatch correctly resists the <\!-- <head> --> decoy.

Requested change (blocking): meta CSP misses subresources placed before the injection point

helpers/preview.ts injects the CSP <meta> after the first uncommented <head>/<html>. A <meta http-equiv> CSP only governs resources that appear after it in document order, and the attacker controls the whole document — so they can put an external reference first and escape the policy. Verified in headless Chromium against this helper:

<\!doctype html><img src="http://evil/a.png"><head></head>

produces ...<img src="http://evil/a.png"><head><meta CSP></head> and the img loads (same for <link rel=stylesheet>, <style>@import> and the <html><img><head> shape).

Impact is LOW — the channel only carries attacker-already-known/static data (a "victim opened the file" beacon, referrer-less), and cannot exfiltrate any computed or cross-origin secret (those paths are cut by the opaque origin + CSP). But it does break the README/DECISIONS/SECURITY-REVIEW claim that the preview is network-isolated. Cheap, robust fix: prepend the <meta> as the very first bytes of the document unconditionally (first meta CSP wins and covers everything after it) instead of inserting it after the discovered head — i.e. drop the "insert after the real head" branch, which is precisely what creates the gap. Please also add a regression test with an external resource before <head>.

Residual risk to document (non-blocking)

With allow-scripts, the preview can still navigate itself (<meta http-equiv=refresh> / location.href) to an external URL — verified. It stays confined to the sandboxed, opaque-origin pane (parent tab and popups remain blocked), so it's a beacon + low-grade in-pane visual phishing only, no secret access. SECURITY-REVIEW.md currently implies only forms/popups are closed; please note that frame-self navigation remains possible as accepted residual risk (or add frame-src 'none'/navigate-to 'none' where supported).

Code quality / tests / i18n — looks good

  • Thin app that correctly inherits AppWrapper load/save/dirty/Ctrl+S/guard via the currentContent prop + update:currentContent emit; not reimplementing any of it is the right call.
  • CodeMirror lifecycle is clean: compartments for theme/read-only, debounce timer cleared on unmount, large-file guard re-arms on every content change (good — prevents auto-rendering a different large doc after a conflict reload).
  • Tests are solid (27): sandbox-contract regression test, CSP helper, debounce, large-file pause/opt-in/re-arm. i18n via $gettext throughout; a11y role="group"/aria-pressed/aria-label on the toggle. Bundle impact is effectively zero (CodeMirror already resolved transitively via md-editor-v3).
  • Changelog fragment present (changelog/unreleased/enhancement-html-editor.md).

Minor nits (optional): font-src data: but img-src data: blob: — intentional asymmetry? And the three doc files (ARCHAEOLOGY.md/DECISIONS.md/SECURITY-REVIEW.md) are great rationale but unusual to ship in a core package; consider whether maintainers want them in-tree.

Net: a well-built feature with a sound isolation core. Please make the CSP injection authoritative regardless of attacker source order (prepend), tighten the SECURITY-REVIEW wording on frame-self navigation, then I'm happy. Not auto-approving while the CSP-ordering gap stands, since it contradicts a stated security guarantee.

- preview CSP: prepend the <meta> as the document's first bytes rather than
  inserting it into the discovered <head>. The first CSP meta wins and governs
  every resource after it, so prepending covers the whole document — including
  any element placed before a real or commented-out <head> that head-insertion
  left outside the policy and free to beacon. Removes the head-detection helper:
  a pure string prepend has no parsing logic for hostile markup to defeat.
- preview CSP: allow blob: for font-src symmetrically with img-src/media-src
  (a blob: URL is a local, non-network source minted by the sandboxed frame and
  cannot exfiltrate).
- editor: drop the optional-chaining accommodation for an undefined theme store;
  the store is always present, and the dark-mode watch now tracks a real reactive
  dependency. The test mock provides a real-shaped currentTheme ref and exercises
  the dark-mode reconfigure path.
- index: enforce FILE_SIZE_LIMIT > PREVIEW_SIZE_LIMIT with a load-time assertion
  so the AppWrapper open limit and the preview-pause limit cannot silently
  disagree.
- docs: record the prepend rationale and the frame self-navigation residual risk
  (F11) in SECURITY-REVIEW.md / DECISIONS.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: David Walter <david.walter@kiteworks.com>
@sonarqubecloud

Copy link
Copy Markdown

@dj4oC

dj4oC commented Jun 27, 2026

Copy link
Copy Markdown
Contributor Author

@DeepDiver1975 thanks for the adversarial pass — the headless-Chromium repro of the before-<head> gap was spot on. All three points are addressed in 0614496, and @LukasHirt's location question is below.

CSP ordering (blocking) — fixed. wrapWithPreviewCsp now prepends the <meta> as the document's very first bytes unconditionally; the head-detection branches and firstUncommentedMatch are gone. The first CSP meta wins, so the policy governs the whole document regardless of attacker source order — including your <!doctype html><img src="http://evil/a.png"><head></head> shape. Added a regression test asserting the meta precedes an <img> placed before <head>, plus one for the comment-decoy shape. The only cost (quirks-mode rendering for doctype-less documents) is acceptable for a sandboxed, network-isolated preview and is documented in the helper, DECISIONS.md, and SECURITY-REVIEW.md (F5/F8 updated).

Frame-self navigation (residual risk) — documented. Added finding F11 to SECURITY-REVIEW.md plus a note in DECISIONS.md: the sandboxed frame can navigate itself (<meta http-equiv="refresh"> / location) to an external URL; default-src 'none' does not cover frame self-navigation and the sandbox blocks only top-frame navigation. It is recorded as accepted low residual risk — such a navigation can carry only attacker-already-known data, never the victim's token/cookies/storage (parent origin, bearer-token-in-JS). I did not add navigate-to/frame-src because neither reliably constrains a frame navigating itself across current browsers (navigate-to was dropped from CSP3 in most engines); happy to add a best-effort navigate-to 'none' if you'd like the defense-in-depth signal anyway.

font-src/img-src asymmetry — fixed. font-src is now data: blob:, symmetric with img-src/media-src. blob: is a local, non-network source minted by the sandboxed frame's own script, so it cannot egress.

Package location & in-tree docs (@LukasHirt's question + D4). Fair to raise, and ultimately a maintainer call. It's built as a core app (alongside the existing in-tree editors) rather than a web-extensions package because it registers by file extension through the in-tree app pipeline and reuses CodeMirror that's already resolved transitively via md-editor-v3 — packaging it externally would duplicate that wiring for no gain. The three rationale docs (ARCHAEOLOGY.md/DECISIONS.md/SECURITY-REVIEW.md) are admittedly unusual to ship in-tree; I'm happy to move them into the PR description / wiki or drop them if you'd rather keep the package lean. Tell me your preference and I'll adjust in a follow-up.

Verification after the changes: pnpm check:types clean, eslint clean, prettier --check clean, and the package unit suite passes (29 tests, including the new prepend/regression and dark-mode-watch cases).

@dj4oC

dj4oC commented Jun 27, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up on the in-tree rationale docs (@DeepDiver1975's optional nit, @LukasHirt's location question): decision is to keep ARCHAEOLOGY.md, DECISIONS.md, and SECURITY-REVIEW.md in the package as three separate files.

For a feature that renders attacker-influenceable HTML, keeping the threat model and the design decisions versioned next to the code — reviewable in the same diff and pinned to the implementation they describe rather than drifting in a wiki or PR description — is worth the slight unusualness of shipping them in-tree. Keeping the three separate keeps each concern self-contained: archaeology = why the original spec was off, decisions = the design choices, security-review = the threat model and findings. Happy to revisit if a maintainer feels strongly; no code change for this.

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants