A document comes from the browser, so Kotoba treats every document as input from the person who wrote it. This guide tells what Kotoba does, and what your app must do.
No raw HTML
Kotoba never passes raw HTML through. There is no HTML node, and a document is a tree of known nodes with typed attributes.
Kotoba.Document.parse/2checks the document envelope and each node. A node of a type that the registry does not know is kept as aKotoba.Nodes.Unknownnode. It renders as an empty, inertspan.kotoba-unknown.Kotoba.Rendererrenders throughPhoenix.HTML, so it escapes every string. A node module that returns a plain string fromrender_html/2gets it escaped too.Kotoba.Sanitizerchecks each node before it renders: its attributes (their types, no control characters, length limits), its place in the tree (a list holds only list items, a link holds no other link, and so on) and its URLs. A node that fails a check renders as an unknown node. The renderer does not raise.
Links
A link renders as an anchor only when its URL is relative or has an
allowed scheme. The default schemes are http, https and mailto:
config :kotoba, allowed_link_schemes: ~w(http https mailto tel)A link with a URL that is not safe (javascript:, data:, …) renders as
its text. Anchors have rel="noopener nofollow". The editor gets the same
list of schemes, so it refuses the same URLs.
Policies
Render content from people that the app does not trust with the
:untrusted policy:
<.kotoba_content content={@comment.body} policy={:untrusted} />With :untrusted, links render as their text, with no anchor, and
attachments render as their caption (else their file name), escaped, with
no image and no link. See
Kotoba.Sanitizer.
The cached HTML
Kotoba.Content keeps the rendered HTML of the document (with the
:default policy) in the row. Kotoba.Content.cast/1 always renders it
again from the document: it never takes HTML from its input, not even
from a Kotoba.Content struct. So the HTML
in a row that Kotoba wrote is safe.
Kotoba.Components.kotoba_content/1 trusts the html of a stored row,
and shows it with no new rendering. So:
- A row that code outside Kotoba writes (a SQL script, an import, another
service) must go through
Kotoba.Content.cast/1orKotoba.Content.rerender/2first. - Or render it with
policy={:untrusted}, or withnodes: then the component renders the document again and does not use the cache.
Uploads
Kotoba reads the first bytes of each uploaded file on the server and
keeps a content type only when the bytes prove it: PNG, JPEG, GIF, WebP,
PDF, MP4 and WebM video, UTF-8 plain text, ZIP and Office files. Every other file is
application/octet-stream. The type that the browser sends is not used.
The storage key takes its extension from the checked type, so a key never
ends in .html or .svg. The file name is cleaned of control and
bidirectional format characters, and is only shown as text.
An uploaded file is content from a person. Serve it so that it cannot run a script on your origin:
X-Content-Type-Options: nosniff.- The checked content type, never the type that the browser sent.
Content-Disposition: attachmentfor every type that is not an image, a PDF or a video (Kotoba.Attachments.inline?/1).Content-Security-Policy: default-src 'none'; sandbox.
Kotoba.Storage.Local.Plug sends all of these headers, and
Cache-Control: private. Do not serve the upload directory with
Plug.Static: it takes the type from the extension and sends none of
these headers.
An adapter for S3 or another store must set the content type from
meta.content_type and Content-Disposition: attachment for each type
that Kotoba.Attachments.inline?/1 refuses. Without the disposition, the
store serves an uploaded HTML file inline, as a page on the store's
origin. The Kotoba.Storage docs have an example.
Serve private files through a path in your app that checks access (for example a controller that redirects to a short-lived signed URL). The URL of a file is in the stored document, and every reader of the document sees it.
What your app must do
- Authorize every event. The editor's events (
kotoba:change,kotoba:prompt, the upload events) and the form events come from the browser. Check in eachhandle_event/3that the person can edit the record. - Check the features. An editor's
featureslimit what the person can make in the browser, not what a request can post. Check them in the changeset withKotoba.Content.validate_features/3(see the Editing features guide). - Scope the prompts. A prompt callback runs with the query from the
browser. Search only the things that the person can see, for example
with an arity-2 callback that reads the scope from the socket. The
itemsof a local prompt are in the page, so give only the ones that the person can see. - Check mentions and app nodes. The
idof a mention and the attributes of an app node (a prompt'sattrstoo) come from the browser. Check them before you act on them (a notification, a link to a record). - Read-only is not access control.
readonlychanges the editor in the browser only. Check on the server that the person can save. - Limit the size. A document has no size limit in Kotoba. Limit the
size of the form params in your endpoint (
Plug.Parsers:length) and of the WebSocket messages, in the endpoint:socket "/live", Phoenix.LiveView.Socket, websocket: [max_frame_size: 1_000_000, connect_info: [session: @session_options]].