diff --git a/package.json b/package.json index a4ed5f1..e188ddf 100755 --- a/package.json +++ b/package.json @@ -3,6 +3,10 @@ "htmx.org": "^1.9" }, "bundles": { - "vendor": {"htmx.org": ["dist/htmx.min.js", "dist/ext/ws.js"], "fonts://display=swap": "Rubik:wght@300"} + "vendor": { + "htmx.org": ["dist/htmx.min.js", "dist/ext/ws.js"], + "fonts://display=swap": ["Rubik:wght@300", "JetBrains+Mono:wght@400"], + ".": ["src/main/js/editor.js"] + } } } \ No newline at end of file diff --git a/src/main/handlebars/group.handlebars b/src/main/handlebars/group.handlebars index 151521d..a7620f5 100755 --- a/src/main/handlebars/group.handlebars +++ b/src/main/handlebars/group.handlebars @@ -8,17 +8,17 @@ {{/if}} -

+

{{#with group}} - {{#*fragment "description"}}{{description}}{{/fragment}} + {{#*fragment "description"}}{{& description}}{{/fragment}} {{/with}} -

+

- + {{> traits/editor id="post" field="body"}}
@@ -28,7 +28,7 @@ {{#each posts}} {{#*fragment "post"}}
-

{{body}}

+

{{& body}}

Posted by {{editor.name}} on {{date created}}{{#with updated}}, updated {{date .}}{{/with}} @@ -48,7 +48,7 @@ {{#*inline "edit"}}
- + {{> traits/editor id=_id field="body" value=body}}
@@ -58,9 +58,7 @@ {{/inline}} {{#*inline "update"}} -
- -
+ {{> traits/editor id=_id field="description" value=description}}
diff --git a/src/main/handlebars/index.handlebars b/src/main/handlebars/index.handlebars index 4bcb1f1..af9fcad 100755 --- a/src/main/handlebars/index.handlebars +++ b/src/main/handlebars/index.handlebars @@ -15,7 +15,7 @@ {{name}}

{{name}}

-

{{description}}

+
{{& description}}
Created by {{owner.name}} on {{date created format="d.m.Y"}} @@ -39,7 +39,7 @@
- + {{> traits/editor id="group" field="description"}}
diff --git a/src/main/handlebars/layout.handlebars b/src/main/handlebars/layout.handlebars index 7883d13..a8a2fc3 100755 --- a/src/main/handlebars/layout.handlebars +++ b/src/main/handlebars/layout.handlebars @@ -11,6 +11,10 @@ font-family: 'Rubik', sans-serif; } + pre, code { + font-family: 'JetBrains Mono', monospace; + } + body { background-color: #ede6e1; margin: 0; @@ -34,7 +38,7 @@ form { display: flex; flex-direction: column; - width: max-content; + width: 100%; align-items: flex-start; gap: .5rem; } @@ -48,6 +52,25 @@ width: 100%; } + div.editor { + width: 100%; + } + + div[contenteditable] { + background-color: white; + padding: .25rem; + width: 100%; + outline: 1px solid black; + } + + div[contenteditable] p { + margin: 0; + } + + div[contenteditable] p + p { + margin-block-start: 1rem; + } + button[type='submit'] { padding: .25rem .5rem; } @@ -85,12 +108,26 @@ } } - .group h2, .group p { + .group h2 { padding-inline: 1rem; + margin: 0; } - .group h2 { - margin: 0; + .group .description { + padding-inline: 1rem; + display: -webkit-box; + -webkit-line-clamp: 5; + -webkit-box-orient: vertical; + overflow: hidden; + + & * { + font-size: 1rem; + padding-inline: 0; + } + + & *:not(:first-child) { + display: none; + } } .posts { @@ -117,8 +154,8 @@ padding: .5rem; } - .post p { - white-space: pre-wrap; + .post p:empty { + display: none; } .post p.emoji { diff --git a/src/main/handlebars/traits/editor.handlebars b/src/main/handlebars/traits/editor.handlebars new file mode 100755 index 0000000..a2384b0 --- /dev/null +++ b/src/main/handlebars/traits/editor.handlebars @@ -0,0 +1,15 @@ +
+ +
{{& value}}
+
+ + + + | + + + + +
+
+ \ No newline at end of file diff --git a/src/main/js/editor.js b/src/main/js/editor.js new file mode 100755 index 0000000..326358c --- /dev/null +++ b/src/main/js/editor.js @@ -0,0 +1,106 @@ +// Replacement for Sanitizer API, see https://web.dev/articles/sanitizer +// but also includes the possibility to rewrite tags to others. +class Markup { + EXPAND = true; + REMOVE = false; + #handle = { + 'H1' : {}, + 'H2' : {}, + 'H3' : {}, + 'B' : {}, + 'I' : {}, + 'U' : {}, + 'P' : {}, + 'CODE' : {}, + 'PRE' : {}, + 'STRIKE' : {}, + 'STRONG' : {emit: 'b'}, + 'EM' : {emit: 'i'}, + 'SECTION' : {emit: 'p'}, + 'ARTICLE' : {emit: 'p'}, + 'A' : {attributes: ['href']}, + 'DIV' : this.EXPAND, + 'SCRIPT' : this.REMOVE, + 'EMBED' : this.REMOVE, + 'OBJECT' : this.REMOVE, + 'IFRAME' : this.REMOVE, + '#comment' : this.REMOVE, + '#text' : (n) => n.wholeText, + }; + + html(text) { + return text.replace(/[<>&'"]/g, m => `&#${m.charCodeAt(0)};`); + } + + transform(nodes) { + let transformed = ''; + for (const node of nodes) { + // console.log(node.nodeName, node); + + const handle = this.#handle[node.nodeName] ?? null; + if (null === handle) { + transformed += this.html(node.innerText); + } else if (this.REMOVE === handle) { + // NOOP + } else if (this.EXPAND === handle) { + transformed += this.transform(node.childNodes); + } else if ('function' === typeof handle) { + transformed += handle(node); + } else { + const tag = handle.emit ?? node.nodeName; + transformed += `<${tag}`; + for (const attribute of handle.attributes ?? []) { + if (node.hasAttribute(attribute)) { + transformed += ` ${attribute}="${this.html(node.getAttribute(attribute))}"`; + } + } + transformed += `>${this.transform(node.childNodes)}`; + } + } + return transformed; + } +} + +class Editor { + static markup = new Markup(); + + constructor($node) { + const $field = $node.querySelector('input[type="hidden"]'); + const $editable = $node.querySelector('div[contenteditable="true"]'); + + for (const $button of $node.querySelectorAll('button')) { + $button.addEventListener('click', e => { + e.preventDefault(); + if ($button.dataset.command) { + document.execCommand($button.dataset.command, false, $button.name); + } else { + document.execCommand($button.name, false, null); + } + }); + } + + // Synchronize hidden field + $editable.addEventListener('input', e => { + $field.value = $editable.innerHTML; + }); + + // If the enclosing form is being reset, reset the editable + $editable.closest('form').addEventListener('reset', e => { + $editable.innerText= ''; + }); + + // Transform HTML, inserting text as-is. Todo: images, see + // https://web.dev/patterns/clipboard/paste-images/ + $editable.addEventListener('paste', e => { + console.log(e); + e.preventDefault(); + if (-1 !== e.clipboardData.types.indexOf('text/html')) { + const doc = document.implementation.createHTMLDocument('Pasted text'); + doc.write(e.clipboardData.getData('text/html')); + document.execCommand('insertHTML', false, Editor.markup.transform(doc.body.childNodes)); + } else { + document.execCommand('insertText', false, e.clipboardData.getData('text/plain')); + } + }); + } +} diff --git a/src/main/php/de/thekid/crews/Markup.php b/src/main/php/de/thekid/crews/Markup.php new file mode 100755 index 0000000..ecf4d0e --- /dev/null +++ b/src/main/php/de/thekid/crews/Markup.php @@ -0,0 +1,97 @@ + [], + 'h2' => [], + 'h3' => [], + 'p' => [], + 'b' => [], + 'i' => [], + 'u' => [], + 's' => [], + 'pre' => [], + 'code' => [], + 'strike' => [], + 'hr' => [], + 'br' => [], + 'a' => ['attributes' => ['href']], + 'img' => ['attributes' => ['src']], + 'strong' => ['emit' => 'b'], + 'em' => ['emit' => 'i'], + 'div' => self::EXPAND, + '#comment' => self::REMOVE, + 'script' => self::REMOVE, + 'embed' => self::REMOVE, + 'object' => self::REMOVE, + 'iframe' => self::REMOVE, + ]; + + public function __construct(array $handle= []) { + $this->handle+= $handle; + } + + private function process($nodes) { + $text= ''; + foreach ($nodes as $node) { + $handle= $this->handle[$node->nodeName] ?? null; + if (null === $handle) { + $text.= htmlspecialchars($node->textContent); + } else if (self::REMOVE === $handle) { + // NOOP + } else if (self::EXPAND === $handle) { + $text.= $this->process($node->childNodes); + } else { + $tag= $handle['emit'] ?? $node->nodeName; + $text.= "<{$tag}"; + foreach ($handle['attributes'] ?? [] as $attribute) { + if ($node->hasAttribute($attribute)) { + $text.= ' '.$attribute.'="'.htmlspecialchars($node->getAttribute($attribute)).'"'; + } + } + $text.= ">{$this->process($node->childNodes)}"; + } + } + return $text; + } + + public function transform(string $input): string { + if (strlen($input) === strspn($input, "\r\n\t ")) return $input; + + libxml_clear_errors(); + $useInternal= libxml_use_internal_errors(true); + $entityLoader= PHP_VERSION_ID >= 80200 ? libxml_get_external_entity_loader() : null; + libxml_set_external_entity_loader(fn() => null); + + // Use https://wiki.php.net/rfc/domdocument_html5_parser for PHP 8.4+ + try { + if (PHP_VERSION_ID >= 80400) { + $doc= HTMLDocument::createFromString("{$input}", 0, 'utf-8'); + } else { + $doc= new DOMDocument('1.0', 'utf-8'); + $doc->loadHTML("{$input}", LIBXML_NONET); + } + } finally { + libxml_use_internal_errors($useInternal); + $entityLoader && libxml_set_external_entity_loader($entityLoader); + } + + return $this->process($doc->getElementsByTagName('body')->item(0)->childNodes); + } +} \ No newline at end of file diff --git a/src/main/php/de/thekid/crews/web/Group.php b/src/main/php/de/thekid/crews/web/Group.php index 89fab38..38a730a 100755 --- a/src/main/php/de/thekid/crews/web/Group.php +++ b/src/main/php/de/thekid/crews/web/Group.php @@ -1,13 +1,14 @@ groups= $db->collection('groups'); @@ -36,7 +37,9 @@ public function group(ObjectId $group, string $view) { #[Put] public function describe(#[Value] User $user, ObjectId $group, #[Param] string $description) { - $result= $this->groups->modify($user->where($group, 'owner'), ['$set' => ['description' => $description]]); + $result= $this->groups->modify($user->where($group, 'owner'), ['$set' => [ + 'description' => $this->markup->transform($description), + ]]); return View::named('group#description')->with($result->document()->properties()); } @@ -44,7 +47,7 @@ public function describe(#[Value] User $user, ObjectId $group, #[Param] string $ public function create(#[Value] User $user, ObjectId $group, #[Param] string $body) { $insert= $this->posts->insert(new Document([ 'group' => $group, - 'body' => $body, + 'body' => $this->markup->transform($body), 'editor' => $user->reference(), 'created' => Date::now(), ])); @@ -71,7 +74,7 @@ public function delete(#[Value] User $user, ObjectId $group, ObjectId $id) { #[Put('/posts/{id}')] public function update(#[Value] User $user, ObjectId $group, ObjectId $id, #[Param] string $body) { $this->posts->update($user->where($id, 'editor'), ['$set' => [ - 'body' => $body, + 'body' => $this->markup->transform($body), 'updated' => Date::now(), ]]); $this->events->publish($user, $group, ['update' => $id]); diff --git a/src/main/php/de/thekid/crews/web/Index.php b/src/main/php/de/thekid/crews/web/Index.php index 7d59c9c..1fe99ed 100755 --- a/src/main/php/de/thekid/crews/web/Index.php +++ b/src/main/php/de/thekid/crews/web/Index.php @@ -1,13 +1,14 @@ groups= $db->collection('groups'); @@ -42,7 +43,7 @@ public function create(#[Value] User $user, #[Param] $name, #[Param] $descriptio // Create, then trigger redirect $insert= $this->groups->insert(new Document([ 'name' => $name, - 'description' => $description, + 'description' => $this->markup->transform($description), 'owner' => $user->reference(), 'created' => Date::now(), ])); diff --git a/src/test/php/de/thekid/crews/unittest/MarkupTest.php b/src/test/php/de/thekid/crews/unittest/MarkupTest.php new file mode 100755 index 0000000..9412500 --- /dev/null +++ b/src/test/php/de/thekid/crews/unittest/MarkupTest.php @@ -0,0 +1,153 @@ +transform('')); + } + + #[Test] + public function whitespace_only() { + Assert::equals("\r\n\t ", new Markup()->transform("\r\n\t ")); + } + + #[Test] + public function text() { + Assert::equals('This is a test', new Markup()->transform('This is a test')); + } + + #[Test] + public function markup() { + Assert::equals('This is a test', new Markup()->transform('This is a test')); + } + + #[Test, Values([ + ['test', 'test'], + ['test', 'test'], + ])] + public function rewrites($input, $expected) { + Assert::equals($expected, new Markup()->transform($input)); + } + + #[Test, Values([ + ['1 < 2'], + ['1 & 2'], + ['Test <&">€&unknown; Works'], + ])] + public function does_not_contain_unescaped_entities($input) { + $transformed= new Markup()->transform($input); + + foreach (['//', '/"/', '/&(?![a-z]+;)/'] as $pattern) { + Assert::equals(0, preg_match($pattern, new Markup()->transform($input))); + } + } + + #[Test] + public function html_entities_are_escaped() { + Assert::equals( + 'Test <&">€&unknown; Works', + new Markup()->transform('Test <&">€&unknown; Works') + ); + } + + #[Test, Values([ + ['Ãœbercoder'], + ['👉🙂'], + ['中国'], // "China" + ])] + public function loads_utf8($input) { + Assert::equals($input, new Markup()->transform($input)); + } + + #[Test, Values([ + [''], + [''], + [''], + [''], + ])] + public function removes_script_tags($input) { + Assert::equals('', new Markup()->transform($input)); + } + + #[Test] + public function removes_unsupported_attributes() { + Assert::equals( + 'test', + new Markup()->transform('test') + ); + } + + #[Test] + public function removes_divs() { + Assert::equals( + 'The container is gone', + new Markup()->transform('
The container is gone
') + ); + } + + #[Test] + public function removes_comments() { + Assert::equals( + 'The is gone', + new Markup()->transform('The is gone') + ); + } + + #[Test, Values([ + [''], + [''], + [''], + ])] + public function does_not_parse_doctype_with($entity) { + Assert::equals( + "]>\n

&xxe;

", + new Markup()->transform("\n

&xxe;

"), + ); + } + + #[Test] + public function CVE_2023_3823() { + $entity= ' %remote; %intern; n%trick;'; + Assert::equals( + ' %remote; %intern; n%trick;]>', + new Markup()->transform(""), + ); + } + + #[Test, Runtime(php: '<8.4-dev')] + public function svg_mutation_xss_html4() { + $exploit= '

'; + Assert::equals('<img src onerror=alert(1)>', new Markup()->transform($exploit)); + } + + #[Test, Runtime(php: '>=8.4-dev')] + public function namespace_confusion_xss_html5() { + $exploit= '