diff options
author | ArthurHoaro <arthur@hoa.ro> | 2017-02-27 19:45:55 +0100 |
---|---|---|
committer | ArthurHoaro <arthur@hoa.ro> | 2017-02-28 19:16:54 +0100 |
commit | e03761011521929a375ebb56f21adacb226a3a8d (patch) | |
tree | 6cc318939e74a35d74a037f18bca912b73e5c81e | |
parent | 5978588578ca103152598ccfbe41019b12e00a4f (diff) | |
download | Shaarli-e03761011521929a375ebb56f21adacb226a3a8d.tar.gz Shaarli-e03761011521929a375ebb56f21adacb226a3a8d.tar.zst Shaarli-e03761011521929a375ebb56f21adacb226a3a8d.zip |
Add markdown_escape setting
This setting allows to escape HTML in markdown rendering or not.
The goal behind it is to avoid XSS issue in shared instances.
More info:
* the setting is set to true by default
* it is set to false for anyone who already have the plugin enabled
(avoid breaking existing entries)
* improve the HTML sanitization when the setting is set to false - but don't consider it XSS proof
* mention the setting in the plugin README
-rw-r--r-- | application/Updater.php | 23 | ||||
-rw-r--r-- | plugins/markdown/README.md | 27 | ||||
-rw-r--r-- | plugins/markdown/markdown.php | 29 | ||||
-rw-r--r-- | tests/Updater/UpdaterTest.php | 66 | ||||
-rw-r--r-- | tests/plugins/PluginMarkdownTest.php | 57 | ||||
-rw-r--r-- | tests/plugins/resources/markdown.html | 6 |
6 files changed, 181 insertions, 27 deletions
diff --git a/application/Updater.php b/application/Updater.php index 3f5d325b..f5ebf31a 100644 --- a/application/Updater.php +++ b/application/Updater.php | |||
@@ -336,6 +336,29 @@ class Updater | |||
336 | } | 336 | } |
337 | $this->conf->set('resource.theme', 'vintage'); | 337 | $this->conf->set('resource.theme', 'vintage'); |
338 | $this->conf->write($this->isLoggedIn); | 338 | $this->conf->write($this->isLoggedIn); |
339 | |||
340 | return true; | ||
341 | } | ||
342 | |||
343 | /** | ||
344 | * * `markdown_escape` is a new setting, set to true as default. | ||
345 | * | ||
346 | * If the markdown plugin was already enabled, escaping is disabled to avoid | ||
347 | * breaking existing entries. | ||
348 | */ | ||
349 | public function updateMethodEscapeMarkdown() | ||
350 | { | ||
351 | if ($this->conf->exists('security.markdown_escape')) { | ||
352 | return true; | ||
353 | } | ||
354 | |||
355 | if (in_array('markdown', $this->conf->get('general.enabled_plugins'))) { | ||
356 | $this->conf->set('security.markdown_escape', false); | ||
357 | } else { | ||
358 | $this->conf->set('security.markdown_escape', true); | ||
359 | } | ||
360 | $this->conf->write($this->isLoggedIn); | ||
361 | |||
339 | return true; | 362 | return true; |
340 | } | 363 | } |
341 | } | 364 | } |
diff --git a/plugins/markdown/README.md b/plugins/markdown/README.md index aafcf066..bc9427e2 100644 --- a/plugins/markdown/README.md +++ b/plugins/markdown/README.md | |||
@@ -50,9 +50,20 @@ If the tag `nomarkdown` is set for a shaare, it won't be converted to Markdown s | |||
50 | 50 | ||
51 | > Note: this is a special tag, so it won't be displayed in link list. | 51 | > Note: this is a special tag, so it won't be displayed in link list. |
52 | 52 | ||
53 | ### HTML rendering | 53 | ### HTML escape |
54 | 54 | ||
55 | Markdown support HTML tags. For example: | 55 | By default, HTML tags are escaped. You can enable HTML tags rendering |
56 | by setting `security.markdwon_escape` to `false` in `data/config.json.php`: | ||
57 | |||
58 | ```json | ||
59 | { | ||
60 | "security": { | ||
61 | "markdown_escape": false | ||
62 | } | ||
63 | } | ||
64 | ``` | ||
65 | |||
66 | With this setting, Markdown support HTML tags. For example: | ||
56 | 67 | ||
57 | > <strong>strong</strong><strike>strike</strike> | 68 | > <strong>strong</strong><strike>strike</strike> |
58 | 69 | ||
@@ -60,12 +71,14 @@ Will render as: | |||
60 | 71 | ||
61 | > <strong>strong</strong><strike>strike</strike> | 72 | > <strong>strong</strong><strike>strike</strike> |
62 | 73 | ||
63 | If you want to shaare HTML code, it is necessary to use inline code or code blocks. | ||
64 | |||
65 | **If your shaared descriptions containing HTML tags before enabling the markdown plugin, | ||
66 | enabling it might break your page.** | ||
67 | 74 | ||
68 | > Note: HTML tags such as script, iframe, etc. are disabled for security reasons. | 75 | **Warning:** |
76 | |||
77 | * This setting might present **security risks** (XSS) on shared instances, even though tags | ||
78 | such as script, iframe, etc should be disabled. | ||
79 | * If you want to shaare HTML code, it is necessary to use inline code or code blocks. | ||
80 | * If your shaared descriptions contained HTML tags before enabling the markdown plugin, | ||
81 | enabling it might break your page. | ||
69 | 82 | ||
70 | ### Known issue | 83 | ### Known issue |
71 | 84 | ||
diff --git a/plugins/markdown/markdown.php b/plugins/markdown/markdown.php index 0cf6e6e2..de7c823d 100644 --- a/plugins/markdown/markdown.php +++ b/plugins/markdown/markdown.php | |||
@@ -14,18 +14,19 @@ define('NO_MD_TAG', 'nomarkdown'); | |||
14 | /** | 14 | /** |
15 | * Parse linklist descriptions. | 15 | * Parse linklist descriptions. |
16 | * | 16 | * |
17 | * @param array $data linklist data. | 17 | * @param array $data linklist data. |
18 | * @param ConfigManager $conf instance. | ||
18 | * | 19 | * |
19 | * @return mixed linklist data parsed in markdown (and converted to HTML). | 20 | * @return mixed linklist data parsed in markdown (and converted to HTML). |
20 | */ | 21 | */ |
21 | function hook_markdown_render_linklist($data) | 22 | function hook_markdown_render_linklist($data, $conf) |
22 | { | 23 | { |
23 | foreach ($data['links'] as &$value) { | 24 | foreach ($data['links'] as &$value) { |
24 | if (!empty($value['tags']) && noMarkdownTag($value['tags'])) { | 25 | if (!empty($value['tags']) && noMarkdownTag($value['tags'])) { |
25 | $value = stripNoMarkdownTag($value); | 26 | $value = stripNoMarkdownTag($value); |
26 | continue; | 27 | continue; |
27 | } | 28 | } |
28 | $value['description'] = process_markdown($value['description']); | 29 | $value['description'] = process_markdown($value['description'], $conf->get('security.markdown_escape', true)); |
29 | } | 30 | } |
30 | return $data; | 31 | return $data; |
31 | } | 32 | } |
@@ -34,17 +35,18 @@ function hook_markdown_render_linklist($data) | |||
34 | * Parse feed linklist descriptions. | 35 | * Parse feed linklist descriptions. |
35 | * | 36 | * |
36 | * @param array $data linklist data. | 37 | * @param array $data linklist data. |
38 | * @param ConfigManager $conf instance. | ||
37 | * | 39 | * |
38 | * @return mixed linklist data parsed in markdown (and converted to HTML). | 40 | * @return mixed linklist data parsed in markdown (and converted to HTML). |
39 | */ | 41 | */ |
40 | function hook_markdown_render_feed($data) | 42 | function hook_markdown_render_feed($data, $conf) |
41 | { | 43 | { |
42 | foreach ($data['links'] as &$value) { | 44 | foreach ($data['links'] as &$value) { |
43 | if (!empty($value['tags']) && noMarkdownTag($value['tags'])) { | 45 | if (!empty($value['tags']) && noMarkdownTag($value['tags'])) { |
44 | $value = stripNoMarkdownTag($value); | 46 | $value = stripNoMarkdownTag($value); |
45 | continue; | 47 | continue; |
46 | } | 48 | } |
47 | $value['description'] = process_markdown($value['description']); | 49 | $value['description'] = process_markdown($value['description'], $conf->get('security.markdown_escape', true)); |
48 | } | 50 | } |
49 | 51 | ||
50 | return $data; | 52 | return $data; |
@@ -53,11 +55,12 @@ function hook_markdown_render_feed($data) | |||
53 | /** | 55 | /** |
54 | * Parse daily descriptions. | 56 | * Parse daily descriptions. |
55 | * | 57 | * |
56 | * @param array $data daily data. | 58 | * @param array $data daily data. |
59 | * @param ConfigManager $conf instance. | ||
57 | * | 60 | * |
58 | * @return mixed daily data parsed in markdown (and converted to HTML). | 61 | * @return mixed daily data parsed in markdown (and converted to HTML). |
59 | */ | 62 | */ |
60 | function hook_markdown_render_daily($data) | 63 | function hook_markdown_render_daily($data, $conf) |
61 | { | 64 | { |
62 | // Manipulate columns data | 65 | // Manipulate columns data |
63 | foreach ($data['cols'] as &$value) { | 66 | foreach ($data['cols'] as &$value) { |
@@ -66,7 +69,10 @@ function hook_markdown_render_daily($data) | |||
66 | $value2 = stripNoMarkdownTag($value2); | 69 | $value2 = stripNoMarkdownTag($value2); |
67 | continue; | 70 | continue; |
68 | } | 71 | } |
69 | $value2['formatedDescription'] = process_markdown($value2['formatedDescription']); | 72 | $value2['formatedDescription'] = process_markdown( |
73 | $value2['formatedDescription'], | ||
74 | $conf->get('security.markdown_escape', true) | ||
75 | ); | ||
70 | } | 76 | } |
71 | } | 77 | } |
72 | 78 | ||
@@ -250,7 +256,7 @@ function sanitize_html($description) | |||
250 | $description); | 256 | $description); |
251 | } | 257 | } |
252 | $description = preg_replace( | 258 | $description = preg_replace( |
253 | '#(<[^>]+)on[a-z]*="[^"]*"#is', | 259 | '#(<[^>]+)on[a-z]*="?[^ "]*"?#is', |
254 | '$1', | 260 | '$1', |
255 | $description); | 261 | $description); |
256 | return $description; | 262 | return $description; |
@@ -265,10 +271,11 @@ function sanitize_html($description) | |||
265 | * 5. Wrap description in 'markdown' CSS class. | 271 | * 5. Wrap description in 'markdown' CSS class. |
266 | * | 272 | * |
267 | * @param string $description input description text. | 273 | * @param string $description input description text. |
274 | * @param bool $escape escape HTML entities | ||
268 | * | 275 | * |
269 | * @return string HTML processed $description. | 276 | * @return string HTML processed $description. |
270 | */ | 277 | */ |
271 | function process_markdown($description) | 278 | function process_markdown($description, $escape = true) |
272 | { | 279 | { |
273 | $parsedown = new Parsedown(); | 280 | $parsedown = new Parsedown(); |
274 | 281 | ||
@@ -278,7 +285,7 @@ function process_markdown($description) | |||
278 | $processedDescription = reverse_text2clickable($processedDescription); | 285 | $processedDescription = reverse_text2clickable($processedDescription); |
279 | $processedDescription = unescape($processedDescription); | 286 | $processedDescription = unescape($processedDescription); |
280 | $processedDescription = $parsedown | 287 | $processedDescription = $parsedown |
281 | ->setMarkupEscaped(false) | 288 | ->setMarkupEscaped($escape) |
282 | ->setBreaksEnabled(true) | 289 | ->setBreaksEnabled(true) |
283 | ->text($processedDescription); | 290 | ->text($processedDescription); |
284 | $processedDescription = sanitize_html($processedDescription); | 291 | $processedDescription = sanitize_html($processedDescription); |
diff --git a/tests/Updater/UpdaterTest.php b/tests/Updater/UpdaterTest.php index de330ae2..39be88f9 100644 --- a/tests/Updater/UpdaterTest.php +++ b/tests/Updater/UpdaterTest.php | |||
@@ -506,4 +506,70 @@ $GLOBALS[\'privateLinkByDefault\'] = true;'; | |||
506 | $this->conf = new ConfigManager($sandboxConf); | 506 | $this->conf = new ConfigManager($sandboxConf); |
507 | $this->assertEquals($theme, $this->conf->get('resource.theme')); | 507 | $this->assertEquals($theme, $this->conf->get('resource.theme')); |
508 | } | 508 | } |
509 | |||
510 | /** | ||
511 | * Test updateMethodEscapeMarkdown with markdown plugin enabled | ||
512 | * => setting markdown_escape set to false. | ||
513 | */ | ||
514 | public function testEscapeMarkdownSettingToFalse() | ||
515 | { | ||
516 | $sandboxConf = 'sandbox/config'; | ||
517 | copy(self::$configFile . '.json.php', $sandboxConf . '.json.php'); | ||
518 | $this->conf = new ConfigManager($sandboxConf); | ||
519 | |||
520 | $this->conf->set('general.enabled_plugins', ['markdown']); | ||
521 | $updater = new Updater([], [], $this->conf, true); | ||
522 | $this->assertTrue($updater->updateMethodEscapeMarkdown()); | ||
523 | $this->assertFalse($this->conf->get('security.markdown_escape')); | ||
524 | |||
525 | // reload from file | ||
526 | $this->conf = new ConfigManager($sandboxConf); | ||
527 | $this->assertFalse($this->conf->get('security.markdown_escape')); | ||
528 | } | ||
529 | |||
530 | |||
531 | /** | ||
532 | * Test updateMethodEscapeMarkdown with markdown plugin disabled | ||
533 | * => setting markdown_escape set to true. | ||
534 | */ | ||
535 | public function testEscapeMarkdownSettingToTrue() | ||
536 | { | ||
537 | $sandboxConf = 'sandbox/config'; | ||
538 | copy(self::$configFile . '.json.php', $sandboxConf . '.json.php'); | ||
539 | $this->conf = new ConfigManager($sandboxConf); | ||
540 | |||
541 | $this->conf->set('general.enabled_plugins', []); | ||
542 | $updater = new Updater([], [], $this->conf, true); | ||
543 | $this->assertTrue($updater->updateMethodEscapeMarkdown()); | ||
544 | $this->assertTrue($this->conf->get('security.markdown_escape')); | ||
545 | |||
546 | // reload from file | ||
547 | $this->conf = new ConfigManager($sandboxConf); | ||
548 | $this->assertTrue($this->conf->get('security.markdown_escape')); | ||
549 | } | ||
550 | |||
551 | /** | ||
552 | * Test updateMethodEscapeMarkdown with nothing to do (setting already enabled) | ||
553 | */ | ||
554 | public function testEscapeMarkdownSettingNothingToDoEnabled() | ||
555 | { | ||
556 | $sandboxConf = 'sandbox/config'; | ||
557 | copy(self::$configFile . '.json.php', $sandboxConf . '.json.php'); | ||
558 | $this->conf = new ConfigManager($sandboxConf); | ||
559 | $this->conf->set('security.markdown_escape', true); | ||
560 | $updater = new Updater([], [], $this->conf, true); | ||
561 | $this->assertTrue($updater->updateMethodEscapeMarkdown()); | ||
562 | $this->assertTrue($this->conf->get('security.markdown_escape')); | ||
563 | } | ||
564 | |||
565 | /** | ||
566 | * Test updateMethodEscapeMarkdown with nothing to do (setting already disabled) | ||
567 | */ | ||
568 | public function testEscapeMarkdownSettingNothingToDoDisabled() | ||
569 | { | ||
570 | $this->conf->set('security.markdown_escape', false); | ||
571 | $updater = new Updater([], [], $this->conf, true); | ||
572 | $this->assertTrue($updater->updateMethodEscapeMarkdown()); | ||
573 | $this->assertFalse($this->conf->get('security.markdown_escape')); | ||
574 | } | ||
509 | } | 575 | } |
diff --git a/tests/plugins/PluginMarkdownTest.php b/tests/plugins/PluginMarkdownTest.php index d359b2a1..d4cd1b97 100644 --- a/tests/plugins/PluginMarkdownTest.php +++ b/tests/plugins/PluginMarkdownTest.php | |||
@@ -14,11 +14,17 @@ require_once 'plugins/markdown/markdown.php'; | |||
14 | class PluginMarkdownTest extends PHPUnit_Framework_TestCase | 14 | class PluginMarkdownTest extends PHPUnit_Framework_TestCase |
15 | { | 15 | { |
16 | /** | 16 | /** |
17 | * @var ConfigManager instance. | ||
18 | */ | ||
19 | protected $conf; | ||
20 | |||
21 | /** | ||
17 | * Reset plugin path | 22 | * Reset plugin path |
18 | */ | 23 | */ |
19 | public function setUp() | 24 | public function setUp() |
20 | { | 25 | { |
21 | PluginManager::$PLUGINS_PATH = 'plugins'; | 26 | PluginManager::$PLUGINS_PATH = 'plugins'; |
27 | $this->conf = new ConfigManager('tests/utils/config/configJson'); | ||
22 | } | 28 | } |
23 | 29 | ||
24 | /** | 30 | /** |
@@ -36,7 +42,7 @@ class PluginMarkdownTest extends PHPUnit_Framework_TestCase | |||
36 | ), | 42 | ), |
37 | ); | 43 | ); |
38 | 44 | ||
39 | $data = hook_markdown_render_linklist($data); | 45 | $data = hook_markdown_render_linklist($data, $this->conf); |
40 | $this->assertNotFalse(strpos($data['links'][0]['description'], '<h1>')); | 46 | $this->assertNotFalse(strpos($data['links'][0]['description'], '<h1>')); |
41 | $this->assertNotFalse(strpos($data['links'][0]['description'], '<p>')); | 47 | $this->assertNotFalse(strpos($data['links'][0]['description'], '<p>')); |
42 | } | 48 | } |
@@ -61,7 +67,7 @@ class PluginMarkdownTest extends PHPUnit_Framework_TestCase | |||
61 | ), | 67 | ), |
62 | ); | 68 | ); |
63 | 69 | ||
64 | $data = hook_markdown_render_daily($data); | 70 | $data = hook_markdown_render_daily($data, $this->conf); |
65 | $this->assertNotFalse(strpos($data['cols'][0][0]['formatedDescription'], '<h1>')); | 71 | $this->assertNotFalse(strpos($data['cols'][0][0]['formatedDescription'], '<h1>')); |
66 | $this->assertNotFalse(strpos($data['cols'][0][0]['formatedDescription'], '<p>')); | 72 | $this->assertNotFalse(strpos($data['cols'][0][0]['formatedDescription'], '<p>')); |
67 | } | 73 | } |
@@ -110,6 +116,8 @@ class PluginMarkdownTest extends PHPUnit_Framework_TestCase | |||
110 | $output = escape($input); | 116 | $output = escape($input); |
111 | $input .= '<a href="#" onmouseHover="alert(\'xss\');" attr="tt">link</a>'; | 117 | $input .= '<a href="#" onmouseHover="alert(\'xss\');" attr="tt">link</a>'; |
112 | $output .= '<a href="#" attr="tt">link</a>'; | 118 | $output .= '<a href="#" attr="tt">link</a>'; |
119 | $input .= '<a href="#" onmouseHover=alert(\'xss\'); attr="tt">link</a>'; | ||
120 | $output .= '<a href="#" attr="tt">link</a>'; | ||
113 | $this->assertEquals($output, sanitize_html($input)); | 121 | $this->assertEquals($output, sanitize_html($input)); |
114 | // Do not touch escaped HTML. | 122 | // Do not touch escaped HTML. |
115 | $input = escape($input); | 123 | $input = escape($input); |
@@ -130,10 +138,10 @@ class PluginMarkdownTest extends PHPUnit_Framework_TestCase | |||
130 | )) | 138 | )) |
131 | ); | 139 | ); |
132 | 140 | ||
133 | $processed = hook_markdown_render_linklist($data); | 141 | $processed = hook_markdown_render_linklist($data, $this->conf); |
134 | $this->assertEquals($str, $processed['links'][0]['description']); | 142 | $this->assertEquals($str, $processed['links'][0]['description']); |
135 | 143 | ||
136 | $processed = hook_markdown_render_feed($data); | 144 | $processed = hook_markdown_render_feed($data, $this->conf); |
137 | $this->assertEquals($str, $processed['links'][0]['description']); | 145 | $this->assertEquals($str, $processed['links'][0]['description']); |
138 | 146 | ||
139 | $data = array( | 147 | $data = array( |
@@ -151,7 +159,7 @@ class PluginMarkdownTest extends PHPUnit_Framework_TestCase | |||
151 | ), | 159 | ), |
152 | ); | 160 | ); |
153 | 161 | ||
154 | $data = hook_markdown_render_daily($data); | 162 | $data = hook_markdown_render_daily($data, $this->conf); |
155 | $this->assertEquals($str, $data['cols'][0][0]['formatedDescription']); | 163 | $this->assertEquals($str, $data['cols'][0][0]['formatedDescription']); |
156 | } | 164 | } |
157 | 165 | ||
@@ -169,7 +177,7 @@ class PluginMarkdownTest extends PHPUnit_Framework_TestCase | |||
169 | )) | 177 | )) |
170 | ); | 178 | ); |
171 | 179 | ||
172 | $data = hook_markdown_render_feed($data); | 180 | $data = hook_markdown_render_feed($data, $this->conf); |
173 | $this->assertContains('<em>', $data['links'][0]['description']); | 181 | $this->assertContains('<em>', $data['links'][0]['description']); |
174 | } | 182 | } |
175 | 183 | ||
@@ -185,4 +193,41 @@ class PluginMarkdownTest extends PHPUnit_Framework_TestCase | |||
185 | $data = process_markdown($md); | 193 | $data = process_markdown($md); |
186 | $this->assertEquals($html, $data); | 194 | $this->assertEquals($html, $data); |
187 | } | 195 | } |
196 | |||
197 | /** | ||
198 | * Make sure that the HTML tags are escaped. | ||
199 | */ | ||
200 | public function testMarkdownWithHtmlEscape() | ||
201 | { | ||
202 | $md = '**strong** <strong>strong</strong>'; | ||
203 | $html = '<div class="markdown"><p><strong>strong</strong> <strong>strong</strong></p></div>'; | ||
204 | $data = array( | ||
205 | 'links' => array( | ||
206 | 0 => array( | ||
207 | 'description' => $md, | ||
208 | ), | ||
209 | ), | ||
210 | ); | ||
211 | $data = hook_markdown_render_linklist($data, $this->conf); | ||
212 | $this->assertEquals($html, $data['links'][0]['description']); | ||
213 | } | ||
214 | |||
215 | /** | ||
216 | * Make sure that the HTML tags aren't escaped with the setting set to false. | ||
217 | */ | ||
218 | public function testMarkdownWithHtmlNoEscape() | ||
219 | { | ||
220 | $this->conf->set('security.markdown_escape', false); | ||
221 | $md = '**strong** <strong>strong</strong>'; | ||
222 | $html = '<div class="markdown"><p><strong>strong</strong> <strong>strong</strong></p></div>'; | ||
223 | $data = array( | ||
224 | 'links' => array( | ||
225 | 0 => array( | ||
226 | 'description' => $md, | ||
227 | ), | ||
228 | ), | ||
229 | ); | ||
230 | $data = hook_markdown_render_linklist($data, $this->conf); | ||
231 | $this->assertEquals($html, $data['links'][0]['description']); | ||
232 | } | ||
188 | } | 233 | } |
diff --git a/tests/plugins/resources/markdown.html b/tests/plugins/resources/markdown.html index c0fbe7f4..07a5a32e 100644 --- a/tests/plugins/resources/markdown.html +++ b/tests/plugins/resources/markdown.html | |||
@@ -12,11 +12,11 @@ | |||
12 | <li><a href="http://link.tld">two</a></li> | 12 | <li><a href="http://link.tld">two</a></li> |
13 | <li><a href="http://link.tld">three</a></li> | 13 | <li><a href="http://link.tld">three</a></li> |
14 | <li><a href="http://link.tld">four</a></li> | 14 | <li><a href="http://link.tld">four</a></li> |
15 | <li>foo <a href="?addtag=foobar" title="Hashtag foobar">#foobar</a></li> | 15 | <li>foo <a href="?addtag=foobar" title="Hashtag foobar">#foobar</a></li> |
16 | </ol></li> | 16 | </ol></li> |
17 | </ol> | 17 | </ol> |
18 | <p><a href="?addtag=foobar" title="Hashtag foobar">#foobar</a> foo <code>lol #foo</code> <a href="?addtag=bar" title="Hashtag bar">#bar</a></p> | 18 | <p><a href="?addtag=foobar" title="Hashtag foobar">#foobar</a> foo <code>lol #foo</code> <a href="?addtag=bar" title="Hashtag bar">#bar</a></p> |
19 | <p>fsdfs <a href="http://link.tld">http://link.tld</a> <a href="?addtag=foobar" title="Hashtag foobar">#foobar</a> <code>http://link.tld</code></p> | 19 | <p>fsdfs <a href="http://link.tld">http://link.tld</a> <a href="?addtag=foobar" title="Hashtag foobar">#foobar</a> <code>http://link.tld</code></p> |
20 | <pre><code>http://link.tld #foobar | 20 | <pre><code>http://link.tld #foobar |
21 | next #foo</code></pre> | 21 | next #foo</code></pre> |
22 | <p>Block:</p> | 22 | <p>Block:</p> |