-
Notifications
You must be signed in to change notification settings - Fork 0
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add ability to use HTML in group descriptions and posts #4
Open
thekid
wants to merge
14
commits into
main
Choose a base branch
from
feature/markup
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
a5d9c5e
Add ability to use HTML in group descriptions and posts
thekid 4391aac
Check for libxml_get_external_entity_loader()
thekid b56380f
Fix implementation vagaries in DomXML
thekid f501c5e
Rewrite test for raw entities input
thekid 5c56e90
Rename components -> traits
thekid 0ed6fe3
If the enclosing form is being reset, reset the editable
thekid f0db435
Fix HTML replacement
thekid cf6d32b
Simplify test code, add local XXE test
thekid 0ecb98d
Verify CVE-2023-3823
thekid 0f2ccca
Use PHP 8.4 HTML5 parser
thekid 28a8693
Pass charset to HTMLDocument::createFromString()
thekid 1495f6d
Display group description as markup
thekid 5b297b5
MFH
thekid f3b9bf6
MFH: Library update
thekid File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
<div class="editor" id="editor-{{id}}"> | ||
<input type="hidden" name="{{field}}" value="{{value}}"> | ||
<div contenteditable="true">{{& value}}</div> | ||
<div class="buttons"> | ||
<button name="bold">B</button> | ||
<button name="italic">I</button> | ||
<button name="strikeThrough">S</button> | ||
<span> | </span> | ||
<button name="h2" data-command="formatBlock">Heading</button> | ||
<button name="h3" data-command="formatBlock">Subtitle</button> | ||
<button name="p" data-command="formatBlock">Paragraph</button> | ||
<button name="pre" data-command="formatBlock">Preformatted</button> | ||
</div> | ||
</div> | ||
<script type="module">new Editor(document.getElementById('editor-{{id}}'));</script> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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')); | ||
} | ||
}); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,97 @@ | ||
<?php namespace de\thekid\crews; | ||
|
||
use DOMDocument; | ||
use DOM\HTMLDocument; | ||
|
||
/** | ||
* Transforms HTML markup to a subset we can render in the pages | ||
* | ||
* @test de.thekid.crews.unittest.MarkupTest | ||
* @see https://wiki.php.net/rfc/domdocument_html5_parser | ||
* @see https://github.com/php/php-src/security/advisories/GHSA-3qrf-m4j2-pcrr | ||
* @see https://research.securitum.com/dompurify-bypass-using-mxss/ | ||
* @see https://research.securitum.com/mutation-xss-via-mathml-mutation-dompurify-2-0-17-bypass/ | ||
* @see https://knowledge-base.secureflag.com/vulnerabilities/xml_injection/xml_entity_expansion_php.html | ||
*/ | ||
class Markup { | ||
const REMOVE= false; | ||
const EXPAND= true; | ||
|
||
private $handle= [ | ||
'h1' => [], | ||
'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<string, mixed> $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("<!DOCTYPE html><html><body>{$input}</body></html>", 0, 'utf-8'); | ||
} else { | ||
$doc= new DOMDocument('1.0', 'utf-8'); | ||
$doc->loadHTML("<html><head><meta charset='utf-8'></head><body>{$input}</body></html>", LIBXML_NONET); | ||
} | ||
} finally { | ||
libxml_use_internal_errors($useInternal); | ||
$entityLoader && libxml_set_external_entity_loader($entityLoader); | ||
} | ||
|
||
return $this->process($doc->getElementsByTagName('body')->item(0)->childNodes); | ||
} | ||
} |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, CSS nesting works fine in all major browsers: https://caniuse.com/?search=CSS%20nesting 😄