Security¶
The editor stores markup and your templates render it, so the boundary matters. The boundary is on the server: everything the package accepts is put through an allowlist before it is stored, and again when it is displayed.
Where sanitisation happens¶
- HTML storage.
TipTapFormFieldcleans the submitted markup withsanitize_html: anything outside the allowlist is unwrapped, unknown attributes are dropped,script/stylebodies are discarded, link and image URLs are protocol-allowlisted, and inline styles keep only allowed properties with safe values. - Display. The
tiptap_htmlfilter sanitises again at render time, which is what makes it safe on rows stored before this boundary existed. - JSON storage.
TipTapJSONFieldprotocol-allowlists thedocon every save and re-derives thehtmlmirror from it (render_doc);TipTapValuesanitises its mirror on construction, so no caller can hand a template markup the editor could not have produced.
The browser-side controls below are still there, and still worth having — they are what makes the editor behave well. They are not the security boundary, because they run in the browser:
- ProseMirror's schema is a normalizer. Content is parsed into a strict document
model on load; anything the schema doesn't represent —
<script>, event handlers, unknown tags/attributes — is dropped. The editor never holds markup it can't model. - Link protocols are allowlisted (
linkProtocols, defaulthttp/https/mailto/tel) and imagesrcis protocol-validated on insertion. - Source view re-parses through the schema, so what you see equals what the editor submits.
None of that survives a client that skips the editor: the widget is a plain
<textarea>, and a direct POST reaches the field with whatever the client chose to
send. That is the case the server-side allowlist exists for.
What survives¶
The allowlist is not a second vocabulary maintained beside the editor: it is built
from EXTENSION_HTML_VOCABULARY, one entry per built-in extension declaring the tags,
attributes and style properties that extension emits. The same table is where the
built-in extension names come from, so the editor's output and the sanitiser's
allowlist cannot drift apart.
| Kept | |
|---|---|
| Blocks | p, h1–h6 (with margin, margin-block-end, padding-left, text-align), blockquote, pre/code, hr, br, ul, ol (start, type), li |
| Inline | strong, em, u, s, code, sub, sup, span (with color, background-color, font-family, font-size) |
| Links | a with href (allowlisted protocols), class, target (_blank/_self only), rel (known tokens only) |
| Images | img with src (allowlisted protocols), alt, title, width, height, and layout style (float, display, vertical-align, margin/padding and their per-side forms, border, border-radius, width, height) |
| Tables | table, colgroup, col, tbody, tr, th/td with colspan, rowspan, colwidth, background-color |
Everything else is unwrapped: the tag goes, the text inside it stays. A sanitiser that
deleted what it did not recognise could quietly empty half a document, so it never
deletes visible text — the one exception is script and style, whose bodies are
code rather than prose and are dropped whole.
A link that opens a new browsing context always carries rel="noopener noreferrer",
whatever the stored document asked for: rel="opener" re-enables the window.opener
handle that target="_blank" otherwise implies away.
Content nested deeper than MAX_DOCUMENT_DEPTH (100 elements or nodes) is refused
with a ValidationError rather than recursed into.
Rendering¶
Render with the tiptap_html filter, for either storage format:
|safe on an HTML-stored value renders whatever the column happens to hold. For a row
written by this version of the package that is a sanitised value — but for a row
written before it, or by anything other than the form, it is not. |tiptap_html costs
one allowlist pass and does not depend on how the row got there.
Custom extensions¶
A custom extension emits markup this package has never seen, so declare what it emits.
TIPTAP_EXTRA_EXTENSIONS takes either a list of names or a mapping that carries the
vocabulary:
TIPTAP_EXTRA_EXTENSIONS = {
"callout": {"aside": {"attrs": ["class"], "styles": ["background-color"]}},
"wordCount": {}, # emits no markup of its own
}
A declared vocabulary joins the allowlist and the extension's markup survives intact.
A name given without one (the list form) raises a warning naming the extension, and
its tags are unwrapped — the wrapper is lost, the text is not. Two declarations are
refused outright: a tag that executes script or loads a document (script, style,
iframe, object, embed, base, meta, link, form, svg, template, …) and
any on* attribute.
JSON storage¶
JSON storage (TipTapJSONField) keeps the same boundary, with one extra
rule. Because protocol allowlisting happens on parse — which a stored-JSON document
never runs — rendering arbitrary JSON is not automatically safe. So:
- The field validates the stored
docon every save: the link/image protocol allowlist is enforced in pure Python (no extra dependency), and disallowedjavascript:/vbscript:/other schemes on linkhref/ imagesrcare stripped. The canonical value is always safe, whoever wrote it (form, API, import). - The
htmlmirror is re-derived from the sanitizeddocon every save (never trusted from the caller) by the built-inrender_doc, which re-applies the protocol allowlist, HTML-escapes text and attributes, reduces a link'starget/rel, and passes inlinestylevalues through a conservative CSS allowlist (no;/:injection, nourl(...:...), noexpression). An image's storedstyleis split into declarations and filtered against the same layout-property list the sanitiser uses. A write can set{doc, html}directly (API / import / hand-edit); the suppliedhtmlis discarded, so the rendered surface always reflects only the sanitized doc. TipTapValuesanitises itshtmlin__post_init__, so the mirror is safe on every instance however it was built — including the one aModelFormleaves onform.instanceafter validation fails, which is redisplayed straight back to the browser.
Caveats¶
- You still control the render context. The filter trusts the markup it produces; keep untrusted input out of attributes you interpolate around it.
- Content stored before this version was never sanitised. Rendering it with
|tiptap_htmlcleans it at display time; see the CHANGELOG for how to clean the column itself. - Custom extensions widen the surface. Anything you declare in
TIPTAP_EXTRA_EXTENSIONSis accepted from then on — validate what your extension itself accepts. - External asset mode loads TipTap you provide; the browser-side guarantees above hold for the pinned, bundled version. See Asset modes. The server-side allowlist is unaffected.
- Uploads are yours to police.
BaseImageUploadViewenforces the wire contract, not file-type/size/virus policy — add those insave.