Skip to content

fix: skip AI moderation for posts that only share trusted links - #67

Merged
hiteshkava merged 1 commit into
release-ulmofrom
LP-1310-trusted-link-risk-check
Oct 15, 2026
Merged

hiteshkava merged 1 commit into
release-ulmofrom
LP-1310-trusted-link-risk-check

Conversation

@hiteshkava

@hiteshkava hiteshkava commented Oct 15, 2026 •

Copy link
Copy Markdown

Description

Follow-up to #66 (LP-1310). #66 removes links to trusted domains before a post is sent to the XPert spam classifier. That is not enough for short posts. This comment:

<p>Use this like fo registation : <a href="https://us02web.zoom.us/meeting/register/…#/registration" target="_blank" rel="noopener">URL</a></p>

reaches the classifier as Use this like fo registation : URL, which it reads as a phishing lure. Against production XPert it was flagged in 2 of 2 runs after stripping, and in 2 of 4 runs even with the Zoom link left in. Changing how the link is replaced cannot make it pass reliably.

This PR stops classifying a post whose links are all trusted, unless the rest of the post gives readers another way to be contacted or redirected:

  • a link to any other domain: with http(s):// or www., as a bare domain followed by a path under any TLD (codepen.oi/editor/…, discord.gg/…, forms.gle/…), as a bare domain under a common TLD (bestessays.com), as an anchor, or as embedded media (<img>, <iframe>, …)
  • an email address, including a spelled-out "tutor at gmail dot com"
  • a phone number (8 or more digits)
  • a WhatsApp, Telegram, WeChat, Viber, Discord, Skype or Snapchat mention

HTML entities, full-width letters and invisible characters are undone before this check, so evil&#46;com, What\u200bsApp (a zero-width space inside) and Whats-App are caught.

A post with any of these is classified exactly as before, without its trusted links, so a scam that adds a trusted link is still caught. Posts without trusted links are unaffected, and links to untrusted domains, including lookalikes such as codepen.oi, are still judged by XPert. A false match only means a post is classified as it is today. Every post that skips the classifier is logged (AI moderation skipped, only trusted links: <id>), because it gets no audit log entry.

JIRA: https://2u-internal.atlassian.net/browse/LP-1310

Linear-time link handling (also fixes code from #66)

Moderation runs inside the request that creates a post, and the LMS does not limit post size, so the link handling has to stay linear on hostile input. It did not:

Hostile post Before (c621d75 / first version of this PR) After
One long word (email pattern) 8.0 s at 40,000 characters 0.37 s at 2,000,000
Unclosed trusted anchors, <a href="https://codepen.io/x">y repeated (anchor pattern from #66) 3.9 s at 10,000 characters, over 20 s at 20,000 0.26 s at 2,000,000
<a href="x" repeated, no > (anchor pattern from #66) over 20 s at 10,000 characters 0.23 s at 2,000,000

The anchor pattern shipped in #66, which is already on release-ulmo and pinned in edx-internal, and it runs on every post in a course with AI moderation enabled once trusted domains are configured. The post does not need to contain a trusted link. Anchors are now found by matching opening and closing tags separately; an opening tag cannot run past the next <. Every pattern alternative that can match a long run of characters only starts at the beginning of a word.

Changes

  • forum/ai_moderation/links.py:
    • new has_risk_signals();
    • anchors are unwrapped in linear time;
    • the email pattern only starts at the beginning of a word.
  • forum/ai_moderation/service.py: skips classification when trusted links were removed and has_risk_signals() finds nothing, and logs the skip.
  • docs/how-tos/configure_ai_moderation.rst: describes the rule under AI_MODERATION_TRUSTED_LINK_DOMAINS, and warns that a lure can sit on a trusted page itself.
  • Version bumped to 0.7.4.

Things to note

  • Known gap (accepted trade-off): a post that only links to a trusted domain is not classified at all. Pages on a trusted domain, such as a CodePen pen or a Zoom registration page, are written by whoever posts them, so a lure can sit on the trusted page itself. The same applies to a text-only scam that carries a trusted link and no other contact detail, such as "guaranteed A grades, register: ".
  • Zoom invites: a pasted full invite with a Meeting ID or dial-in numbers counts as containing a phone number, so it is still classified as before.
  • Deployment: https://github.com/edx/edx-internal/pull/15015 bumps the forum pin to this PR's commit. After this PR is merged, that pin has to move to the resulting release-ulmo commit before the edx-internal PR is merged. The trusted domains (codepen.io, zoom.us) are already configured in edx-internal. Because of the anchor slowdown above, this is worth deploying soon.

Testing

  • 60 new tests:

    • has_risk_signals() cases: bare links on any TLD, spelled-out and entity-encoded contacts, zero-width and full-width characters, and everyday CSS, JavaScript and file paths that must not count as a risk.
    • Anchor tests: single-quoted and uppercase tags, several anchors in one post, and an unclosed anchor left in place.
    • Timing tests on hostile input for the whole link handling, including the reviewer's 50,000-character test.
    • Service tests: the reported Zoom comment, the skip log line, and trusted links combined with a phone number, an email address or another link.
  • The full suite passes (522 tests).

  • black, pycodestyle, pydocstyle, pylint, mypy and doc8 are clean.

  • On devstack (LMS, Python 3.11) against production XPert, with codepen.io and zoom.us trusted:

    Post Sent to XPert Result
    Use this like fo registation : <a href="https://us02web.zoom.us/meeting/register/…">URL</a> no (skip logged) not spam
    My solution: https://codepen.io/… why is the footer not sticky? no (skip logged) not spam
    Guaranteed A grades! WhatsApp +1-555-0199 https://us02web.zoom.us/j/… yes, without the Zoom link spam
    Register: https://us02web.zoom.us/j/… or email tutor@example.com yes, without the Zoom link spam
    Zoom link with discord.gg/…, tutor at gmail dot com, evil&#46;com, a zero-width WhatsApp or Whats-App yes, without the Zoom link spam
    https://chatgpt.com/, codepen.oi/… (not trusted) yes, unchanged XPert decides
    A 124,000-character hostile post — link handling took 0.013 s

Manual testing instructions

  1. Configure AI moderation in the LMS (AI_MODERATION_BACKEND, AI_MODERATION_API_URL, AI_MODERATION_CLIENT_ID, AI_MODERATION_USER_ID) with AI_MODERATION_TRUSTED_LINK_DOMAINS = ["codepen.io", "zoom.us"], and enable discussions.enable_ai_moderation and discussions.enable_ai_auto_delete_spam for a test course.
  2. Post Use this like fo registation : <a href="https://us02web.zoom.us/meeting/register/<id>">URL</a>. It stays visible, is not flagged, gets no ModerationAuditLog entry, and the LMS log shows AI moderation skipped, only trusted links.
  3. Post Guaranteed A grades! WhatsApp +1-555-0199 https://us02web.zoom.us/j/<id>. It is flagged and soft-deleted, with a ModerationAuditLog entry.

Merge checklist:
Check off if complete or not applicable:

  • Version bumped
  • Changelog record added (not applicable: CHANGELOG.rst is not maintained on this branch)
  • Documentation updated (not only docstrings)
  • Fixup commits are squashed away
  • Unit tests added/updated
  • Manual testing instructions provided
  • Noted any: Concerns, dependencies, migration issues, deadlines, tickets

@hiteshkava
hiteshkava force-pushed the LP-1310-trusted-link-risk-check branch from 1c14678 to 18100e2 Compare October 15, 2026 09:12
Comment thread forum/ai_moderation/links.py Outdated
# unwrapped by then), an email address, or a messaging app.
_RISK_SIGNAL_RE = re.compile(
r"https?://|www\.|<(?:a|img|iframe|video|embed|object)\b"
r"|(?<![\w@./-])(?:[a-z0-9-]+\.)+[a-z][a-z0-9-]*/\S"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocker (CPU exhaustion). This alternative has no start anchor, so on a long run of word characters without an @, [\w.+-]+ is retried from every position and the search goes quadratic. has_risk_signals() on a post with a trusted link plus one long word takes 7 s at 40,000 characters, 23 s at 80,000 and 62 s at 160,000. It runs inside the create request, and the LMS does not cap the post size.

A lookbehind lets it start only at the beginning of a token. It finds the same addresses and stays linear (2,000,000 characters in 0.05 s):

Suggested change
r"|(?<![\w@./-])(?:[a-z0-9-]+\.)+[a-z][a-z0-9-]*/\S"
r"|(?<![\w.+-])[\w.+-]+@[\w-]+(?:\.[\w-]+)+"

A regression test for tests/test_ai_moderation_links.py (plus import time). It fails on the current head and passes with the fix:

    def test_long_post_is_checked_quickly(self) -> None:
        """A long post without a risk signal does not stall the request."""
        start = time.perf_counter()
        assert has_risk_signals("a" * 50_000) is False
        assert time.perf_counter() - start < 1

# or under a common TLD, an anchor or embedded media (trusted anchors have been
# unwrapped by then), an email address, or a messaging app.
_RISK_SIGNAL_RE = re.compile(
r"https?://|www\.|<(?:a|img|iframe|video|embed|object)\b"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A bare domain is only caught under the TLDs in _COMMON_TLDS, so the usual redirectors get through: discord.gg/…, linktr.ee/…, forms.gle/…, shorturl.at/…, rb.gy/…, is.gd/…. Since a false match only means the post is classified as today, I'd also count any domain followed by a path, whatever its TLD. The ordinary-post test cases still pass (style.css, node.js 18 and the like have no / after them):

Suggested change
r"https?://|www\.|<(?:a|img|iframe|video|embed|object)\b"
rf"|(?<![\w@.-])(?:[a-z0-9-]+\.)+(?:(?:{_COMMON_TLDS})(?![\w-])|[a-z]{{2,63}}/)"

The comment above _COMMON_TLDS would then say a bare domain under any other TLD goes unnoticed unless a path follows it.

Comment thread forum/ai_moderation/links.py Outdated
_RISK_SIGNAL_RE = re.compile(
r"https?://|www\.|<(?:a|img|iframe|video|embed|object)\b"
r"|(?<![\w@./-])(?:[a-z0-9-]+\.)+[a-z][a-z0-9-]*/\S"
rf"|(?<![\w@.-])(?:[a-z0-9-]+\.)+(?:{_COMMON_TLDS})(?![\w-])"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whats-App me and email tutor at gmail dot com both get through today. This widens the separator, adds a few more apps scams send people to, and catches a spelled-out "dot com":

Suggested change
rf"|(?<![\w@.-])(?:[a-z0-9-]+\.)+(?:{_COMMON_TLDS})(?![\w-])"
r"|\b(?:whats[\W_]*app|telegram|wechat|viber|discord|skype|snapchat)\b"
r"|\bdot\W{0,3}(?:com|net|org)\b",

Comment thread forum/ai_moderation/links.py Outdated
Comment on lines +129 to +131
is classified as usual.
"""
if not content:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The patterns run on the raw text, so the usual tricks for hiding a contact detail from a filter get through: evil&#46;com (HTML entity), What\u200bsApp (zero-width space) and full-width letters. Normalizing first closes those with the standard library (needs import html and import unicodedata next to import re at line 18):

Suggested change
is classified as usual.
"""
if not content:
if not content:
return False
# Undo the usual ways of hiding a link or contact from a pattern: HTML
# entities, full-width letters and invisible characters.
content = unicodedata.normalize("NFKC", html.unescape(content))
content = re.sub(
r"[\u00ad\u200b-\u200f\u202a-\u202e\u2060-\u2064\ufeff]", "", content
)
if _RISK_SIGNAL_RE.search(content):

Comment on lines +305 to +307
if stripped != content and not has_risk_signals(stripped):
result["reasoning"] = "Only trusted links, and no other contact or link"
return result

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: skipped posts leave no audit log entry, so a log line is the only way to see how often this exemption is used, or abused:

Suggested change
if stripped != content and not has_risk_signals(stripped):
result["reasoning"] = "Only trusted links, and no other contact or link"
return result
if stripped != content and not has_risk_signals(stripped):
log.info(
f"AI moderation skipped, only trusted links: {content_instance.get('_id')}"
)
result["reasoning"] = "Only trusted links, and no other contact or link"
return result

"Call +1-555-0199 today",
"Reach me on +91 98765 43210",
"Contact (555) 123-4567",
"Add me on WhatsApp",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cases for the gaps above. They fail on the current head and pass with the suggestions on lines 48, 50 and 129-131 of links.py:

Suggested change
"Add me on WhatsApp",
"I am on WeChat and Viber",
"Join discord.gg/abc123",
"Everything is on linktr.ee/helper",
"Sign up at forms.gle/abc",
"Email tutor at gmail dot com",
"Visit evil&#46;com",
"What\u200bsApp me",
"Whats-App me",

@abhalsod-sonata abhalsod-sonata left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the clear write-up. Skipping classification only when nothing else in the post can reach or redirect readers is a reasonable rule, and "a false match only means the post is classified as today" is a good property to build on. One blocker and some hardening, all inline:

  1. Blocker: has_risk_signals() is quadratic on long input (forum/ai_moderation/links.py line 49). The email alternative has no start anchor, so on a long word without an @ the regex retries from every character. On this branch a post with a trusted link plus one long word takes 7 s at 40,000 characters and 62 s at 160,000. Nothing in the LMS caps the post size (DATA_UPLOAD_MAX_MEMORY_SIZE = None in openedx/envs/common.py, and the discussion API's raw_body has no max_length), and moderation runs inside the create request. So any learner in a course with AI moderation enabled can tie up an LMS worker with a single post. A lookbehind makes it linear.
  2. Misses are the risky direction. A false match costs nothing, but a miss means the post is never classified. On this branch all of these pass as "only trusted links" when posted with a Zoom link: discord.gg/abc, linktr.ee/helper, forms.gle/abc, shorturl.at/abc, tutor at gmail dot com, evil&#46;com, What\u200bsApp (zero-width space) and Whats-App. Three small suggestions inline (lines 48, 50 and 129-131) catch all of them using only re, html and unicodedata, so there is no new dependency and no longer TLD list to maintain.
  3. Optional: log when a post is skipped (forum/ai_moderation/service.py line 305). Skipped posts leave no audit log entry, so today there is no way to see how often the trusted-link exemption is used, or abused.
  4. Docs: the accepted gap is a bit wider than text-only lures. codepen.io pens and Zoom registration pages are written by whoever posts them, so the lure can sit on the trusted page itself. That is the same reason the docs give for avoiding github.io. It's worth saying next to that warning that, with this change, a short post linking to a trusted domain is not classified at all.

Checked locally (Python 3.11, Django 4.2) with all the suggestions applied: the full suite passes (503, including 8 new tests that fail on the current head), and black, pycodestyle, pydocstyle, pylint and mypy are clean.

A post whose links all point at trusted domains could still be deleted.
Once its link was removed, short text such as "Use this like fo
registation : URL" read as a phishing lure to the classifier, which
flagged it about half the time even with the link left in (LP-1310).

Such a post is now not classified at all, unless the rest of it offers
another way to be contacted or redirected: a link to any other domain,
embedded media, an email address, a phone number, or a messaging app
mention. Those posts are classified as before, without their trusted
links, so a scam that adds a trusted link is still caught. The check
undoes HTML entities, full-width letters and invisible characters
first, and every skipped post is logged.

Link handling now runs in linear time. Moderation runs inside the
request that creates a post, posts have no size limit, and the anchor
and email patterns took from seconds to minutes on a few tens of
thousands of characters of hostile input.
@hiteshkava
hiteshkava force-pushed the LP-1310-trusted-link-risk-check branch from 18100e2 to 539eaab Compare October 15, 2026 09:37
@hiteshkava

Copy link
Copy Markdown
Author

Thanks for the thorough review. All four points are addressed in 539eaab.

  1. Blocker (CPU exhaustion): the email pattern now only starts at the beginning of a word (your lookbehind), and your 50,000-character test is in. While checking the rest of the module, I found the anchor pattern from fix: keep AI moderation from deleting posts with trusted links #66 had the same problem, only worse. <a href="x" repeated without a > took over 20 s at 10,000 characters, and it runs on every post in a moderated course once trusted domains are configured, with or without a trusted link in the post. Anchors are now found by matching opening and closing tags separately, and an opening tag cannot run past the next <. The whole link handling now processes 2,000,000 characters of each hostile pattern in under 1.3 s. TestHostilePosts covers those inputs.

  2. Misses: applied all three suggestions:

    • Any domain followed by a path counts, whatever its TLD. I had added a similar rule in 18100e2 for codepen.oi/… and switched to your single alternative.
    • whats[\W_]*app, plus Discord, Skype and Snapchat, and a spelled-out "dot com".
    • Entity, NFKC and invisible-character normalization. The invisible characters are a precompiled _INVISIBLE_RE, written with \u escapes so the source stays ASCII.

    Your cases are in the tests, plus shorturl.at/abc and a full-width Whatsapp.

  3. Logging: added as suggested.

  4. Docs: added the note about lures on trusted pages next to the github.io warning.

The full suite passes (522 tests), and black, pycodestyle, pydocstyle, pylint, mypy and doc8 are clean. The PR description has the timing numbers and a devstack run against production XPert.

1 similar comment
@hiteshkava

Copy link
Copy Markdown
Author

Thanks for the thorough review. All four points are addressed in 539eaab.

  1. Blocker (CPU exhaustion): the email pattern now only starts at the beginning of a word (your lookbehind), and your 50,000-character test is in. While checking the rest of the module, I found the anchor pattern from fix: keep AI moderation from deleting posts with trusted links #66 had the same problem, only worse. <a href="x" repeated without a > took over 20 s at 10,000 characters, and it runs on every post in a moderated course once trusted domains are configured, with or without a trusted link in the post. Anchors are now found by matching opening and closing tags separately, and an opening tag cannot run past the next <. The whole link handling now processes 2,000,000 characters of each hostile pattern in under 1.3 s. TestHostilePosts covers those inputs.

  2. Misses: applied all three suggestions:

    • Any domain followed by a path counts, whatever its TLD. I had added a similar rule in 18100e2 for codepen.oi/… and switched to your single alternative.
    • whats[\W_]*app, plus Discord, Skype and Snapchat, and a spelled-out "dot com".
    • Entity, NFKC and invisible-character normalization. The invisible characters are a precompiled _INVISIBLE_RE, written with \u escapes so the source stays ASCII.

    Your cases are in the tests, plus shorturl.at/abc and a full-width Whatsapp.

  3. Logging: added as suggested.

  4. Docs: added the note about lures on trusted pages next to the github.io warning.

The full suite passes (522 tests), and black, pycodestyle, pydocstyle, pylint, mypy and doc8 are clean. The PR description has the timing numbers and a devstack run against production XPert.

@abhalsod-sonata abhalsod-sonata left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. Re-checked at 539eaab: all four points are addressed, and rewriting the anchor handling was a good catch.

  • Performance: checked locally. The old _ANCHOR_RE took 40 s on 5,000 characters of <a href="x" with no >; the new _unwrap_trusted_anchors() handles 200,000 characters of it in 0.02 s. Every hostile pattern I tried (long words, runs of <a , </a> and a.) stays under 0.1 s for strip_trusted_links() plus has_risk_signals() at 200,000 characters. Unwrapping behaves as before: trusted anchors become their text, while untrusted and unclosed anchors stay in place and get classified.
  • Misses: every case from my review is caught now.
  • Logging and docs: look good.

The full suite passes locally (522 tests, Python 3.11, Django 4.2), lint is clean and CI is green.

@hiteshkava
hiteshkava merged commit 8310b7c into release-ulmo Oct 15, 2026
13 checks passed
@hiteshkava
hiteshkava deleted the LP-1310-trusted-link-risk-check branch October 15, 2026 09:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants