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 @@
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)}${tag}>`;
+ }
+ }
+ 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)}{$tag}>";
+ }
+ }
+ 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= '