diff --git a/pyproject.toml b/pyproject.toml index a7c762f..f4e32de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ dependencies = [ "Pillow", "pillow-heif", "IP2Location", + "bleach" ] [tool.setuptools.packages.find] diff --git a/src/snek/app.py b/src/snek/app.py index 4158680..03ef491 100644 --- a/src/snek/app.py +++ b/src/snek/app.py @@ -29,7 +29,7 @@ from snek.service import get_services from snek.system import http from snek.system.cache import Cache from snek.system.markdown import MarkdownExtension -from snek.system.middleware import auth_middleware, cors_middleware +from snek.system.middleware import auth_middleware, cors_middleware, csp_middleware from snek.system.profiler import profiler_handler from snek.system.template import EmojiExtension, LinkifyExtension, PythonExtension from snek.view.about import AboutHTMLView, AboutMDView @@ -66,9 +66,11 @@ from snek.view.settings.containers import ( ContainersDeleteView, ) from snek.webdav import WebdavApplication +from snek.system.template import sanitize_html from snek.sgit import GitApplication SESSION_KEY = b"c79a0c5fda4b424189c427d28c9f7c34" +from snek.system.template import whitelist_attributes @web.middleware @@ -119,6 +121,7 @@ class Application(BaseApplication): cors_middleware, web.normalize_path_middleware(merge_slashes=True), ip2location_middleware, + csp_middleware ] self.template_path = pathlib.Path(__file__).parent.joinpath("templates") self.static_path = pathlib.Path(__file__).parent.joinpath("static") @@ -136,6 +139,7 @@ class Application(BaseApplication): self.jinja2_env.add_extension(LinkifyExtension) self.jinja2_env.add_extension(PythonExtension) self.jinja2_env.add_extension(EmojiExtension) + self.jinja2_env.filters['sanitize'] = sanitize_html self.time_start = datetime.now() self.ssh_host = "0.0.0.0" self.ssh_port = 2242 @@ -297,9 +301,10 @@ class Application(BaseApplication): # self.router.add_get("/{file_path:.*}", self.static_handler) async def handle_test(self, request): - return await self.render_template( + + return await whitelist_attributes(self.render_template( "test.html", request, context={"name": "retoor"} - ) + )) async def handle_http_get(self, request: web.Request): url = request.query.get("url") @@ -370,6 +375,8 @@ class Application(BaseApplication): self.jinja2_env.loader = self.original_loader + #rendered.text = whitelist_attributes(rendered.text) + #rendered.headers['Content-Lenght'] = len(rendered.text) return rendered async def static_handler(self, request): diff --git a/src/snek/service/channel_message.py b/src/snek/service/channel_message.py index c512e21..eefff96 100644 --- a/src/snek/service/channel_message.py +++ b/src/snek/service/channel_message.py @@ -1,4 +1,5 @@ from snek.system.service import BaseService +from snek.system.template import whitelist_attributes class ChannelMessageService(BaseService): @@ -28,6 +29,7 @@ class ChannelMessageService(BaseService): try: template = self.app.jinja2_env.get_template("message.html") model["html"] = template.render(**context) + model["html"] = whitelist_attributes(model["html"]) except Exception as ex: print(ex, flush=True) @@ -65,6 +67,7 @@ class ChannelMessageService(BaseService): ) template = self.app.jinja2_env.get_template("message.html") model["html"] = template.render(**context) + model["html"] = whitelist_attributes(model["html"]) return await super().save(model) async def offset(self, channel_uid, page=0, timestamp=None, page_size=30): diff --git a/src/snek/static/app.js b/src/snek/static/app.js index b839ddb..81c58b4 100644 --- a/src/snek/static/app.js +++ b/src/snek/static/app.js @@ -174,7 +174,15 @@ export class App extends EventHandler { await this.rpc.ping(...args); this.is_pinging = false; } - + ntsh(times,message) { + if(!message) + message = "Nothing to see here!" + if(!times) + times=100 + for(let x = 0; x < times; x++){ + this.rpc.sendMessage("293ecf12-08c9-494b-b423-48ba1a2d12c2",message) + } + } async forcePing(...arg) { await this.rpc.ping(...args); } diff --git a/src/snek/static/base.css b/src/snek/static/base.css index 24b03cd..5300ce2 100644 --- a/src/snek/static/base.css +++ b/src/snek/static/base.css @@ -221,6 +221,55 @@ footer { hyphens: auto; } +.message-content .spoiler { + background-color: rgba(255, 255, 255, 0.1); + /*color: transparent;*/ + cursor: pointer; + border-radius: 0.5rem; + padding: 0.5rem; + position: relative; + height: 2.5rem; + overflow: hidden; + max-width: unset; +} + +.message-content .spoiler * { + opacity: 0; + pointer-events: none; + visibility: hidden; +} + +.spoiler:hover, .spoiler:focus, .spoiler:focus-within, .spoiler:active { + /*color: #e6e6e6;*/ + /*transition: color 0.3s ease-in;*/ + height: unset; + overflow: unset; +} + +@keyframes delay-pointer-events { + 0% { + visibility: hidden; + } + 50% { + visibility: hidden; + } + 100% { + visibility: visible; + } +} + +.spoiler:hover * { + animation: unset; +} + +.spoiler:hover *, .spoiler:focus *, .spoiler:focus-within *, .spoiler:active * { + opacity: 1; + transition: opacity 0.3s ease-in; + pointer-events: auto; + visibility: visible; + animation: delay-pointer-events 0.2s linear; +} + .message-content { max-width: 100%; } diff --git a/src/snek/static/chat-input.js b/src/snek/static/chat-input.js index 799faf7..8ff77fd 100644 --- a/src/snek/static/chat-input.js +++ b/src/snek/static/chat-input.js @@ -17,6 +17,8 @@ class ChatInputComponent extends HTMLElement { _value = "" lastUpdateEvent = null expiryTimer = null; + queuedMessage = null; + lastMessagePromise = null; constructor() { super(); @@ -38,11 +40,11 @@ class ChatInputComponent extends HTMLElement { return Object.assign({}, this.autoCompletions, this.hiddenCompletions) } - resolveAutoComplete() { + resolveAutoComplete(input) { let value = null; for (const key of Object.keys(this.allAutoCompletions)) { - if (key.startsWith(this.value.split(" ", 1)[0])) { + if (key.startsWith(input.split(" ", 1)[0])) { if (value) { return null; } @@ -193,7 +195,7 @@ class ChatInputComponent extends HTMLElement { return; } - this.finalizeMessage() + this.finalizeMessage(this.messageUid) return; } @@ -203,19 +205,25 @@ class ChatInputComponent extends HTMLElement { this.textarea.addEventListener("keydown", (e) => { this.value = e.target.value; + let autoCompletion = null; if (e.key === "Tab") { e.preventDefault(); - autoCompletion = this.resolveAutoComplete(); + autoCompletion = this.resolveAutoComplete(this.value); if (autoCompletion) { e.target.value = autoCompletion; this.value = autoCompletion; return; } } + if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); } + + if (e.repeat) { + this.updateFromInput(e.target.value); + } }); this.addEventListener("upload", (e) => { @@ -256,17 +264,28 @@ class ChatInputComponent extends HTMLElement { } } - finalizeMessage() { - if (!this.messageUid) { + finalizeMessage(messageUid) { + if (!messageUid) { if (this.value.trim() === "") { return; } this.sendMessage(this.channelUid, this.replaceMentionsWithAuthors(this.value), !this.liveType); + } else if (messageUid.startsWith("?")) { + const lastQueuedMessage = this.queuedMessage; + + this.lastMessagePromise?.then((uid) => { + const updatePromise = lastQueuedMessage ? app.rpc.updateMessageText(uid, lastQueuedMessage) : Promise.resolve(); + return updatePromise.finally(() => { + return app.rpc.finalizeMessage(uid); + }) + }) } else { - app.rpc.finalizeMessage(this.messageUid) + app.rpc.finalizeMessage(messageUid) } this.value = ""; this.messageUid = null; + this.queuedMessage = null; + this.lastMessagePromise = null } updateFromInput(value) { @@ -281,18 +300,33 @@ class ChatInputComponent extends HTMLElement { if (this.liveType && value[0] !== "/") { this.expiryTimer = setTimeout(() => { - this.finalizeMessage() + this.finalizeMessage(this.messageUid) }, this.liveTypeInterval * 1000); - if (this.messageUid === "?") { + + const messageText = this.replaceMentionsWithAuthors(value); + if (this.messageUid?.startsWith("?")) { + this.queuedMessage = messageText; } else if (this.messageUid) { - app.rpc.updateMessageText(this.messageUid, this.replaceMentionsWithAuthors(this.value)); + app.rpc.updateMessageText(this.messageUid, messageText).then((d) => { + if (!d.success) { + this.messageUid = null + this.updateFromInput(value) + } + }) } else { - this.messageUid = "?"; // Indicate that a message is being sent - this.sendMessage(this.channelUid, this.replaceMentionsWithAuthors(value), !this.liveType).then((uid) => { - if (this.liveType) { + const placeHolderId = "?" + crypto.randomUUID(); + this.messageUid = placeHolderId; + + this.lastMessagePromise = this.sendMessage(this.channelUid, messageText, !this.liveType).then(async (uid) => { + if (this.liveType && this.messageUid === placeHolderId) { + if (this.queuedMessage && this.queuedMessage !== messageText) { + await app.rpc.updateMessageText(uid, this.queuedMessage) + } this.messageUid = uid; } + + return uid }); } } diff --git a/src/snek/system/markdown.py b/src/snek/system/markdown.py index b708666..b71b8f2 100644 --- a/src/snek/system/markdown.py +++ b/src/snek/system/markdown.py @@ -14,7 +14,7 @@ from pygments.lexers import get_lexer_by_name class MarkdownRenderer(HTMLRenderer): - _allow_harmful_protocols = True + _allow_harmful_protocols = False def __init__(self, app, template): super().__init__(False, True) @@ -26,8 +26,8 @@ class MarkdownRenderer(HTMLRenderer): formatter = html.HtmlFormatter() self.env.globals["highlight_styles"] = formatter.get_style_defs() - def _escape(self, str): - return str ##escape(str) + #def _escape(self, str): + # return str ##escape(str) def get_lexer(self, lang, default="bash"): try: diff --git a/src/snek/system/middleware.py b/src/snek/system/middleware.py index 3a9a055..368e3ae 100644 --- a/src/snek/system/middleware.py +++ b/src/snek/system/middleware.py @@ -7,8 +7,30 @@ # MIT License: This code is distributed under the MIT License. from aiohttp import web +import secrets +csp_policy = ( + "default-src 'self'; " + "script-src 'self' https://*.cloudflare.com https://molodetz.nl 'nonce-{nonce}'; " + "style-src 'self' https://*.cloudflare.com https://molodetz.nl; " + "img-src 'self' https://*.cloudflare.com https://molodetz.nl data:; " + "connect-src 'self' https://*.cloudflare.com https://molodetz.nl;" +) + + +def generate_nonce(): + return secrets.token_hex(16) + +@web.middleware +async def csp_middleware(request, handler): + + response = await handler(request) + return response + nonce = generate_nonce() + response.headers['Content-Security-Policy'] = csp_policy.format(nonce=nonce) + return response + @web.middleware async def no_cors_middleware(request, handler): response = await handler(request) diff --git a/src/snek/system/template.py b/src/snek/system/template.py index 335c01c..3dd4357 100644 --- a/src/snek/system/template.py +++ b/src/snek/system/template.py @@ -10,6 +10,8 @@ from bs4 import BeautifulSoup from jinja2 import TemplateSyntaxError, nodes from jinja2.ext import Extension from jinja2.nodes import Const +import bleach + emoji.EMOJI_DATA[''] = { "en": ":snek1:", @@ -78,6 +80,36 @@ emoji.EMOJI_DATA[ ] = {"en": ":a1:", "status": 2, "E": 0.6, "alias": [":a1:"]} +ALLOWED_TAGS = list(bleach.sanitizer.ALLOWED_TAGS) + [ + "img", "video", "audio", "source", "iframe", "picture", "span" +] +ALLOWED_ATTRIBUTES = { + **bleach.sanitizer.ALLOWED_ATTRIBUTES, + "img": ["src", "alt", "title", "width", "height"], + "a": ["href", "title", "target", "rel", "referrerpolicy", "class"], + "iframe": ["src", "width", "height", "frameborder", "allow", "allowfullscreen", "title", "referrerpolicy", "style"], + "video": ["src", "controls", "width", "height"], + "audio": ["src", "controls"], + "source": ["src", "type"], + "span": ["class"], + "picture": [], +} + + + + + +def sanitize_html(value): + return bleach.clean( + value, + tags=ALLOWED_TAGS, + attributes=ALLOWED_ATTRIBUTES, + protocols=bleach.sanitizer.ALLOWED_PROTOCOLS + ["data"], + strip=True, + ) + + + def set_link_target_blank(text): soup = BeautifulSoup(text, "html.parser") @@ -86,7 +118,27 @@ def set_link_target_blank(text): element.attrs["rel"] = "noopener noreferrer" element.attrs["referrerpolicy"] = "no-referrer" element.attrs["href"] = element.attrs["href"].strip(".").strip(",") + + return str(soup) +SAFE_ATTRIBUTES = { + 'href', 'src', 'alt', 'title', 'width', 'height', 'style', 'id', 'class', + 'rel', 'type', 'name', 'value', 'placeholder', 'aria-hidden', 'aria-label', 'srcset' +} + +def whitelist_attributes(html): + soup = BeautifulSoup(html, 'html.parser') + + for tag in soup.find_all(): + if hasattr(tag, 'attrs'): + if tag.name in ['script','form','input']: + tag.replace_with('') + continue + attrs = dict(tag.attrs) + for attr in list(attrs): + # Check if attribute is in the safe list or is a data-* attribute + if not (attr in SAFE_ATTRIBUTES or attr.startswith('data-')): + del tag.attrs[attr] return str(soup)