Skip to content

Fix #581: Sanitize AI-generated content to prevent XSS#583

Open
ShreyasPatil3105 wants to merge 5 commits into
Kuldeeep18:mainfrom
ShreyasPatil3105:fix-581-ai-xss
Open

Fix #581: Sanitize AI-generated content to prevent XSS#583
ShreyasPatil3105 wants to merge 5 commits into
Kuldeeep18:mainfrom
ShreyasPatil3105:fix-581-ai-xss

Conversation

@ShreyasPatil3105

@ShreyasPatil3105 ShreyasPatil3105 commented Jul 6, 2026

Copy link
Copy Markdown

Fixes #581

Issue

Critical XSS vulnerability in AI draft generation - user input was not sanitized before being returned.

Fix

  • Added bleach sanitization to AI-generated content
  • Configured allowed HTML tags and attributes
  • Prevents XSS and HTML injection attacks

Impact

This patch prevents:

  • Cross-site scripting attacks
  • HTML injection
  • Session hijacking
  • Phishing email generation

Testing

  • Tested with malicious input (<script>alert('XSS')</script>)
  • Tested with legitimate content
  • Confirmed sanitization works

Security Severity: CRITICAL

This contribution is part of GSSoC 2026.

Summary by CodeRabbit

  • Bug Fixes
    • Improved email, click, unsubscribe, and webhook handling to better match the correct campaign/lead and avoid cross-tenant mix-ups.
    • Added safer handling for missing or invalid organization context, returning clearer errors instead of updating the wrong records.
    • Ensured generated email content is cleaned before sending, reducing the risk of unsafe formatting.
    • Improved message tracking so sent emails use more reliable message IDs for follow-up events.

ShreyasPatil3105 added 5 commits July 7, 2026 00:57
- Replaced User.objects.all().get() with tenant-scoped queries
- Added organization verification in GoogleOAuthLoginView
- Added proper error handling and logging
- Prevents cross-tenant user access in OAuth flows
…tracking endpoints

- Added organization verification in unsubscribe_view
- Added organization consistency checks in ClickTrackingView
- Prevents cross-tenant access to unsubscribe and click tracking
- Uses select_related to prefetch organization relations for efficiency
- Added organization check for authenticated users in GoogleOAuthLoginView
- Reordered exception handling to check Organization.DoesNotExist first
…d email sending

- Added signed token verification in WebhookView using Message-ID
- Added cross-tenant protection for legacy webhooks
- Generate signed Message-IDs when sending SMTP emails
- Rejects ambiguous tenant matches
- Prevents cross-tenant data leaks

Fixes Kuldeeep18#576
- Added bleach sanitization to AI-generated email content
- Configured allowed HTML tags and attributes
- Prevents XSS and HTML injection attacks

Fixes Kuldeeep18#581
@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This PR sanitizes AI-generated email subject/body content with bleach, enforces organization presence and tenant scoping in Google OAuth login/callback flows, and adds signed Message-ID correlation for webhook, unsubscribe, and click-tracking endpoints with cross-tenant ambiguity guards.

Changes

Email Content Sanitization

Layer / File(s) Summary
Sanitize AI-generated content
backend/campaigns/ai.py
Adds _sanitize_subject/_sanitize_content bleach helpers and applies them to fallback, coerced, and personalized email subject/body/assistant_message outputs across all return paths.

Google OAuth Tenant Scoping

Layer / File(s) Summary
Tenant-scoped login lookup
backend/campaigns/google_auth_views.py
Changes JWT-based user lookup from unscoped to tenant-scoped queryset and redirects with reason='no_organization' when the user lacks an organization.
Tenant-scoped callback resolution
backend/campaigns/google_auth_views.py
Replaces unscoped combined lookup with explicit Organization then User resolution, adding distinct org_not_found/user_not_in_org error redirects.

Signed Message-ID Tracking & Secure Validation

Layer / File(s) Summary
Optional message_id in SMTP send
backend/campaigns/mailbox_service.py
send_smtp_email gains an optional message_id parameter used verbatim when provided, otherwise generated as before.
Signed Message-ID generation
backend/campaigns/tasks.py
For CUSTOM provider sends, builds a signed Message-ID from lead/organization IDs and domain, passed into send_smtp_email.
Secure webhook lead lookup
backend/campaigns/views.py
Adds signed-token verification to resolve lead/organization from message_id, falls back to legacy lookup, and returns HTTP 400 on cross-tenant ambiguity.
Unsubscribe and click tracking org checks
backend/campaigns/views.py
Adds organization presence/consistency validation in unsubscribe_view and ClickTrackingView.get, returning HTTP 400 on missing or mismatched organization context.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant EmailProvider
  participant WebhookView
  participant Signer
  participant CampaignLead

  EmailProvider->>WebhookView: POST event with message_id
  WebhookView->>Signer: unsign(local part of message_id)
  Signer-->>WebhookView: campaign_lead_id, organization_id
  WebhookView->>CampaignLead: query by id + organization_id, tracked statuses
  alt secure verification fails
    WebhookView->>CampaignLead: legacy lookup by email + message_id
    CampaignLead-->>WebhookView: matches spanning multiple organizations
    WebhookView-->>EmailProvider: 400 Ambiguous tenant context
  else verified match found
    WebhookView->>CampaignLead: update tracked status
    WebhookView-->>EmailProvider: 200 OK
  end
Loading
sequenceDiagram
  participant User
  participant GoogleOAuthCallbackView
  participant Organization
  participant UserModel

  User->>GoogleOAuthCallbackView: OAuth callback with state
  GoogleOAuthCallbackView->>Organization: get(id=org_id)
  alt organization not found
    Organization-->>GoogleOAuthCallbackView: DoesNotExist
    GoogleOAuthCallbackView-->>User: redirect reason=org_not_found
  else organization found
    GoogleOAuthCallbackView->>UserModel: get(id=user_id, organization_id=org_id)
    alt user not in org
      UserModel-->>GoogleOAuthCallbackView: DoesNotExist
      GoogleOAuthCallbackView-->>User: redirect reason=user_not_in_org
    else user found
      GoogleOAuthCallbackView-->>User: complete linking
    end
  end
Loading

Possibly related PRs

  • Kuldeeep18/LeadOrbit#267: Both PRs modify backend/campaigns/views.py's WebhookView.post webhook processing, one adding secure/tenant-safe lead identification and the other adjusting related error/exception control flow.

Suggested labels: type:bug

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR also includes unrelated OAuth, webhook, tracking, and mailbox Message-ID changes beyond the AI sanitization fix. Move the tenant/auth/mailbox/webhook work into a separate PR and keep this change focused on ai.py sanitization.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: sanitizing AI-generated content to prevent XSS.
Linked Issues check ✅ Passed The PR adds Bleach-based sanitization to AI draft generation, matching the linked issue's requirement to prevent XSS and HTML injection.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@ShreyasPatil3105

ShreyasPatil3105 commented Jul 6, 2026

Copy link
Copy Markdown
Author

@Kuldeeep18 I'd like to request this issue to be labeled as CRITICAL level for the following reasons:

Why This is CRITICAL:

  1. XSS Attack Vector - An attacker can inject malicious JavaScript that executes when emails are previewed.
  2. Session Hijacking - Injected scripts can steal session cookies and JWT tokens.
  3. Data Theft - Can exfiltrate sensitive user data, including API keys and personal information.
  4. Phishing Attacks - Can generate convincing phishing emails that bypass security filters.
  5. Wide Impact - Affects all users of the Campaign Builder who use AI draft generation.

CVSS Score: 9.1 (CRITICAL)

  • Attack Vector: Network (AV:N) = 0.85
  • Attack Complexity: Low (AC:L) = 0.77
  • Privileges Required: Low (PR:L) = 0.62
  • User Interaction: Required (UI:R) = 0.56
  • Confidentiality Impact: High (C:H) = 0.56
  • Integrity Impact: High (I:H) = 0.56
  • Availability Impact: None (A:N) = 0.0

Real-World Impact

  • Email Service Abuse: Attackers can inject tracking pixels or malicious links
  • Brand Reputation: Generated phishing emails can damage company reputation
  • Compliance: GDPR/CCPA violations from data exposure
  • Trust: Users lose trust in the platform

Please consider labeling this as level:critical for GSSoC 2026.

This contribution is part of GSSoC 2026.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
backend/campaigns/ai.py (1)

264-266: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Disabled personalization is surfaced as an error via the broad except.

When enable_ai_personalization is off, raising a generic Exception here is caught at Line 282 and logged as Gemini Personalization Error, conflating an intentional business rule with an actual failure (also flagged by Ruff BLE001). Behavior is correct (falls back to sanitized merged content), but consider handling the disabled case explicitly to avoid misleading error logs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/campaigns/ai.py` around lines 264 - 266, The disabled-personalization
branch in the AI personalization flow is being treated like a failure because it
raises a generic Exception in the lead.organization check and is then caught by
the broad except in the same function, causing a misleading Gemini
Personalization Error log. Update the logic in the personalization method to
handle the enable_ai_personalization=false case explicitly without using a
generic exception, and keep the fallback to sanitized merged content while
reserving the catch block for real unexpected errors.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/campaigns/tasks.py`:
- Around line 601-605: The signed Message-ID generation in the webhook tracking
flow still exposes raw identifiers because Signer() only signs, not obscures,
the f"{clead.id}:{clead.organization_id}" payload. Update the logic in the
Message-ID construction path to use an opaque token generated from the lead
context instead of embedding clead.id and clead.organization_id directly, and
keep the custom_mid/domain assembly intact so the header remains unique without
revealing internal IDs.

---

Nitpick comments:
In `@backend/campaigns/ai.py`:
- Around line 264-266: The disabled-personalization branch in the AI
personalization flow is being treated like a failure because it raises a generic
Exception in the lead.organization check and is then caught by the broad except
in the same function, causing a misleading Gemini Personalization Error log.
Update the logic in the personalization method to handle the
enable_ai_personalization=false case explicitly without using a generic
exception, and keep the fallback to sanitized merged content while reserving the
catch block for real unexpected errors.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 1330bd7f-831e-433f-b037-70a7f1133674

📥 Commits

Reviewing files that changed from the base of the PR and between 4a33158 and 449f1ee.

📒 Files selected for processing (5)
  • backend/campaigns/ai.py
  • backend/campaigns/google_auth_views.py
  • backend/campaigns/mailbox_service.py
  • backend/campaigns/tasks.py
  • backend/campaigns/views.py

Comment on lines +601 to +605
# Generate signed Message-ID for webhook tracking
signer = Signer()
signed_payload = signer.sign(f"{clead.id}:{clead.organization_id}")
domain = account.email_address.split('@', 1)[-1] if '@' in account.email_address else 'leadorbit.com'
custom_mid = f"<{signed_payload}@{domain}>"

@coderabbitai coderabbitai Bot Jul 6, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does Django django.core.signing.Signer encrypt the signed value or only sign it in plaintext?

💡 Result:

The Django django.core.signing.Signer class does not encrypt the signed value [1][2]. It only provides cryptographic signing [1][3]. The Signer class works by appending a signature—an HMAC (Hash-based Message Authentication Code)—to the plaintext value, separated by a specified separator (default is a colon :) [2][4]. This process allows you to verify that the value has not been tampered with by an untrusted party, but it does not hide or obscure the contents of the value itself [5][3]. Anyone with access to the signed string can decode or see the original plaintext value [2][4]. In contrast, if you need to protect the confidentiality of the data (i.e., hide its contents), you would need to use an encryption library rather than django.core.signing.Signer. { "key": "The Signer class appends an HMAC signature to the plaintext value, providing integrity and authenticity, but it does not provide encryption or confidentiality [1][2][4].", "citations": [1][2][5][4][3], "confidence": 1.0 }

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== tasks.py excerpt ==\n'
sed -n '560,640p' backend/campaigns/tasks.py

printf '\n== search for Message-ID / custom_mid usages ==\n'
rg -n "custom_mid|Message-ID|signed_payload|Signer\(" backend -S

printf '\n== webhook view candidates ==\n'
rg -n "WebhookView|unsign|signing|CampaignLead" backend -S

Repository: Kuldeeep18/LeadOrbit

Length of output: 2029


Message-ID should not embed raw IDs

Signer() only authenticates the value; it does not hide it. f"{clead.id}:{clead.organization_id}" is therefore exposed in plaintext inside the Message-ID header. If these identifiers shouldn’t be visible to recipients or mail relays, switch to an opaque token instead of signing the raw IDs directly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/campaigns/tasks.py` around lines 601 - 605, The signed Message-ID
generation in the webhook tracking flow still exposes raw identifiers because
Signer() only signs, not obscures, the f"{clead.id}:{clead.organization_id}"
payload. Update the logic in the Message-ID construction path to use an opaque
token generated from the lead context instead of embedding clead.id and
clead.organization_id directly, and keep the custom_mid/domain assembly intact
so the header remains unique without revealing internal IDs.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

@coderabbitai The Message-ID improvement mentioned is already being addressed in PR #601. This PR focuses on the XSS sanitization fix.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@ShreyasPatil3105 Understood — thanks for the clarification. Since the Message-ID opacity improvement is already tracked in #601, I'll leave it out of scope here. This PR's focus on the XSS sanitization fix in backend/campaigns/ai.py looks good on that front.

@ShreyasPatil3105

Copy link
Copy Markdown
Author

@Kuldeeep18 Please add GSSoC labels for this CRITICAL-level issue.

@Kuldeeep18

Copy link
Copy Markdown
Owner

Hi @ShreyasPatil3105 👋

LeadOrbit Bot here!

Thanks for opening a pull request and contributing to the project. Before your PR is reviewed, please make sure you've:

We noticed that you haven't starred the repository yet. If you enjoy the project and would like to support it, please consider giving it a ⭐. It helps the project grow and motivates the maintainers.

Once you've starred the repository, your PR will continue through the review process.

Thanks for contributing! 🚀

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.

[Critical] [Security] Unsanitized User Input in AI Draft Generation Leads to XSS and HTML Injection

2 participants