forked from retoor/devplacepy
docs: add block/mute user relations, emoji-sync CLI, and uid indexes
- Add `/block`, `/mute` endpoints with block/unblock and mute/unmute functionality in `routers/relations.py`, hiding blocked users' content everywhere except their own profile while muting only suppresses notifications - Introduce `devplace emoji-sync` CLI command to regenerate `static/js/emoji-shortcodes.js` from the emoji library, documented in `CLAUDE.md` and wired in `cli.py` - Create `get_blocked_uids()` database helper and apply it in `content.py` `load_detail()` to filter blocked users' posts from detail views - Implement `_uid_index()` and `_drop_index()` helpers in `database.py` for unique uid indexes across tables, with `user_relations` added to `SOFT_DELETE_TABLES` - Document new routes in `AGENTS.md` and `README.md`, including emoji shortcodes rendering behavior distinct from the emoji picker
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from html.parser import HTMLParser
|
||||
|
||||
from devplacepy.rendering import render_content, render_title
|
||||
|
||||
|
||||
@@ -110,3 +112,154 @@ def test_title_escapes_raw_html():
|
||||
def test_caching_is_idempotent():
|
||||
text = "cached **content** :fire:"
|
||||
assert str(render_content(text)) == str(render_content(text))
|
||||
|
||||
|
||||
DANGEROUS_SCHEMES = {"javascript", "data", "vbscript", "file"}
|
||||
|
||||
|
||||
class _XSSAudit(HTMLParser):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.violations: list[str] = []
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list) -> None:
|
||||
self._inspect(tag, attrs)
|
||||
|
||||
def handle_startendtag(self, tag: str, attrs: list) -> None:
|
||||
self._inspect(tag, attrs)
|
||||
|
||||
def _inspect(self, tag: str, attrs: list) -> None:
|
||||
if tag in {"script", "object", "embed"}:
|
||||
self.violations.append(f"<{tag}>")
|
||||
for name, value in attrs:
|
||||
lname = name.lower()
|
||||
if lname.startswith("on"):
|
||||
self.violations.append(f"{tag}@{name}")
|
||||
if lname in {"href", "src", "xlink:href", "formaction"} and value:
|
||||
scheme = value.split(":", 1)[0].strip().lower() if ":" in value else ""
|
||||
if scheme in DANGEROUS_SCHEMES:
|
||||
self.violations.append(f"{tag}@{name}={value}")
|
||||
|
||||
|
||||
def assert_no_executable_html(rendered: str) -> None:
|
||||
audit = _XSSAudit()
|
||||
audit.feed(rendered)
|
||||
audit.close()
|
||||
assert not audit.violations, f"XSS vectors survived rendering: {audit.violations}"
|
||||
|
||||
|
||||
XSS_VECTORS = (
|
||||
"<script>alert('xss')</script>",
|
||||
"<img src=x onerror=alert('xss')>",
|
||||
"<svg onload=alert('xss')>",
|
||||
"<svg><script>alert('xss')</script></svg>",
|
||||
"<body onload=alert('xss')>",
|
||||
"<iframe src=\"javascript:alert('xss')\"></iframe>",
|
||||
"<a href=\"javascript:alert('xss')\">click</a>",
|
||||
"<div onmouseover=\"alert('xss')\">hover</div>",
|
||||
"<input autofocus onfocus=alert('xss')>",
|
||||
"<object data=\"javascript:alert('xss')\"></object>",
|
||||
"<a href=\"data:text/html,<script>alert('xss')</script>\">x</a>",
|
||||
"[click](javascript:alert('xss'))",
|
||||
")",
|
||||
"[click](data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==)",
|
||||
"[click](vbscript:msgbox('xss'))",
|
||||
"normal text <ScRiPt>alert('xss')</ScRiPt> mixed case",
|
||||
"tab\tin\tjava\tscript:alert('xss')",
|
||||
"<img src=`x`onerror=alert('xss')>",
|
||||
"<a href=\" javascript:alert('xss')\">leading space</a>",
|
||||
)
|
||||
|
||||
|
||||
def _audit_content_vectors() -> None:
|
||||
for vector in XSS_VECTORS:
|
||||
assert_no_executable_html(str(render_content(vector)))
|
||||
assert_no_executable_html(str(render_content(f"hello {vector} world")))
|
||||
|
||||
|
||||
def _audit_title_vectors() -> None:
|
||||
for vector in XSS_VECTORS:
|
||||
assert_no_executable_html(str(render_title(vector)))
|
||||
assert_no_executable_html(str(render_title(f"title {vector} end")))
|
||||
|
||||
|
||||
def test_xss_post_title_is_neutralised():
|
||||
_audit_title_vectors()
|
||||
|
||||
|
||||
def test_xss_post_content_is_neutralised():
|
||||
_audit_content_vectors()
|
||||
|
||||
|
||||
def test_xss_comment_content_is_neutralised():
|
||||
_audit_content_vectors()
|
||||
|
||||
|
||||
def test_xss_project_title_is_neutralised():
|
||||
_audit_title_vectors()
|
||||
|
||||
|
||||
def test_xss_project_description_is_neutralised():
|
||||
_audit_content_vectors()
|
||||
|
||||
|
||||
def test_xss_gist_title_is_neutralised():
|
||||
_audit_title_vectors()
|
||||
|
||||
|
||||
def test_xss_gist_description_is_neutralised():
|
||||
_audit_content_vectors()
|
||||
|
||||
|
||||
def test_xss_news_title_is_neutralised():
|
||||
_audit_title_vectors()
|
||||
|
||||
|
||||
def test_xss_news_content_is_neutralised():
|
||||
_audit_content_vectors()
|
||||
|
||||
|
||||
def test_xss_message_content_is_neutralised():
|
||||
_audit_content_vectors()
|
||||
|
||||
|
||||
def test_xss_poll_question_is_neutralised():
|
||||
_audit_title_vectors()
|
||||
|
||||
|
||||
def test_xss_poll_option_label_is_neutralised():
|
||||
_audit_title_vectors()
|
||||
|
||||
|
||||
def test_xss_saved_item_title_is_neutralised():
|
||||
_audit_title_vectors()
|
||||
|
||||
|
||||
def test_xss_leaderboard_title_is_neutralised():
|
||||
_audit_title_vectors()
|
||||
|
||||
|
||||
def test_xss_issue_title_is_neutralised():
|
||||
_audit_title_vectors()
|
||||
|
||||
|
||||
def test_xss_issue_body_is_neutralised():
|
||||
_audit_content_vectors()
|
||||
|
||||
|
||||
def test_xss_legitimate_youtube_embed_survives_audit():
|
||||
out = str(render_content("watch https://youtu.be/dQw4w9WgXcQ now"))
|
||||
assert "youtube.com/embed/dQw4w9WgXcQ" in out
|
||||
assert_no_executable_html(out)
|
||||
|
||||
|
||||
def test_xss_legitimate_image_embed_survives_audit():
|
||||
out = str(render_content("pic https://example.com/a.png end"))
|
||||
assert "<img" in out
|
||||
assert_no_executable_html(out)
|
||||
|
||||
|
||||
def test_xss_legitimate_link_survives_audit():
|
||||
out = str(render_content("see https://example.com/page ok"))
|
||||
assert 'href="https://example.com/page"' in out
|
||||
assert_no_executable_html(out)
|
||||
|
||||
Reference in New Issue
Block a user