feat(web-app-html-editor): add an HTML editor with live preview#13895
feat(web-app-html-editor): add an HTML editor with live preview#13895dj4oC wants to merge 12 commits into
Conversation
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>
|
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. |
✅ Snyk checks have passed. No issues have been found so far.
💻 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>
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
left a comment
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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 = '' |
There was a problem hiding this comment.
[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:
- User types;
schedulePreview()queues a 250 ms timer with snapshot S. - User pastes a large block; watch fires, sets
previewContent = '', returns early — timer is still live. - Timer fires →
previewContent = wrapWithPreviewCsp(S)(stale snapshot re-appears). - 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.
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
[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).
There was a problem hiding this comment.
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 ?? '')) |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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() { |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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>
|
Run |
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: David Walter <david.walter@kiteworks.com>
DeepDiver1975
left a comment
There was a problem hiding this comment.
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 callingwindow.parent.localStoragethrowsSecurityError, 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: verifiedfetch/XHR/sendBeacon/WebSocket/EventSource/new Image()/dynamic prefetch anddocument.write-injected subresources are all denied.- Sandbox blocks
window.open,window.top.location,<a target=_top>;form-action 'none'blocks form POST/GET.firstUncommentedMatchcorrectly 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
AppWrapperload/save/dirty/Ctrl+S/guard via thecurrentContentprop +update:currentContentemit; 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
$gettextthroughout; a11yrole="group"/aria-pressed/aria-labelon the toggle. Bundle impact is effectively zero (CodeMirror already resolved transitively viamd-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>
|
|
@DeepDiver1975 thanks for the adversarial pass — the headless-Chromium repro of the before- CSP ordering (blocking) — fixed. Frame-self navigation (residual risk) — documented. Added finding F11 to font-src/img-src asymmetry — fixed. 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 Verification after the changes: |
|
Follow-up on the in-tree rationale docs (@DeepDiver1975's optional nit, @LukasHirt's location question): decision is to keep 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 |



Summary
Adds a new core app,
web-app-html-editor, that opens.html/.htm/.xhtmlfiles 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:defineWebApplicationregisters it by file extension and routes throughAppWrapperRoute. By declaring acurrentContentprop and emittingupdate:currentContent, it inherits the framework's WebDAV load/save, dirtytracking,
Ctrl+S, autosave, the unsaved-changes guard and error notifications, sonone of that is reimplemented. The app itself only composes:
HtmlEditorPane.vue- a CodeMirror 6 wrapper (HTML mode, line numbers, themefollows the active ownCloud theme). CodeMirror 6 is already in the workspace via
md-editor-v3, so the individual@codemirror/*packages are added at theversions already in the lockfile.
HtmlPreviewPane.vue- a sandboxedsrcdociframe.HtmlToolbar.vue- an Editor | Split | Preview view-mode toggle (CSS grid; thefilename, Save and action menu come from
AppTopBar).The app is enabled by default via the
appsarray inconfig/config.json.distandconfig/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:
sandbox="allow-scripts"with noallow-same-origin(opaqueorigin), 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-formsand
allow-popupsare deliberately omitted.default-src 'none'; form-action 'none'; base-uri 'none'; inlinescript/style and
data:/blob:only) is injected into thesrcdoc, so thepreview is network-isolated and does not depend on the deployment proxy CSP.
or hostile document cannot freeze the tab on open.
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.mdandSECURITY-REVIEW.mddocumenting the design rationale and the threat model.Testing
contract,
srcdoc), the toolbar, the CSP-injection helper, and the large-filepreview pause.
pnpm --filter html-editor test:unitis green (27 tests).vue-tsc --noEmit,eslintandprettierare clean..htmlfile (the app ispicked up as the default opener), live edit updating the preview, and saving back
over WebDAV.
Notes
MIME type), matching
web-app-text-editor.