Skip to content

Harden active rendered surfaces against XSS#82

Open
vagxrth wants to merge 1 commit into
saiy2k:mainfrom
vagxrth:fix/xss-hardening-active-surfaces
Open

Harden active rendered surfaces against XSS#82
vagxrth wants to merge 1 commit into
saiy2k:mainfrom
vagxrth:fix/xss-hardening-active-surfaces

Conversation

@vagxrth

@vagxrth vagxrth commented Apr 20, 2026

Copy link
Copy Markdown
Contributor

Closes #44

Summary

This PR implements a focused XSS hardening pass across the active/exported rendered surfaces, with the primary fix centered on nostr-post and extended to the other active components and dialogs that were still interpolating untrusted text or URLs too loosely.

The approach stays intentionally narrow:

  • plain text fields now use escaping and URL validation
  • the nostr-post inline content path uses a limited DOMPurify pass only for the small generated inline fragment
  • no public API, attribute, event, or schema changes
  • no docs, WordPress, or legacy-module refactors in this PR

What Changed

Shared sanitization layer

Added a shared sanitization module under src/common with:

  • sanitizeHttpUrl(url)
    • allows only http: / https:
    • returns escaped, attribute-safe URL strings
    • returns '' for invalid or disallowed URLs
  • sanitizeMultilineText(text)
    • escapes HTML
    • preserves line breaks as <br />
  • sanitizePostInlineFragment(html)
    • uses DOMPurify only for the narrow nostr-post inline fragment path
    • allows only:
      • tags: a, span, br
      • attrs: href, target, rel, class, data-username

Also updated escapeHtml() to a Node-safe string implementation so the helper and render tests can run cleanly in the current Vitest environment.

nostr-post hardening

Re-secured the post parse/render flow so user text is never inserted raw:

  • mention/display text is escaped before being reintroduced
  • generated mention/link markup is passed through a tokenized inline-fragment renderer
  • final inline fragment output is sanitized before render
  • plain links/media now use validated URLs only
  • embedded note placeholder attributes are escaped
  • author display text and avatar URLs in post wrappers are now escaped/validated
  • embedded post wrappers now escape author text and validate avatar/media URLs

This preserves intended behavior like:

  • line breaks
  • generated njump.me links
  • username mentions
  • media rendering
  • embedded-post placeholders

while preventing raw injected markup/scripts from surviving.

Other active surface hardening

Applied the same escape-or-validate rule to the remaining active renderers:

nostr-profile

  • validates banner, picture, and website
  • escapes display name in text/attributes
  • escapes profile error output
  • omits invalid website links
  • falls back to a safe avatar placeholder when the image URL is invalid

nostr-profile-badge

  • validates profile image URL instead of only escaping it

nostr-follow-button

  • validates avatar URL before rendering
  • falls back to the normal icon if the avatar URL is invalid

nostr-zap-button zappers dialog

  • zap comments now render as escaped multiline text, not HTML
  • author/profile URLs remain validated
  • extracted the pure zap-entry renderer for focused testing

nostr-like-button likers dialog

  • tightened author/profile URL handling to use validated URLs consistently

nostr-livestream

  • validates author, participant, preview, stream, and recording URLs before rendering clickable/loadable elements

Testing

Added targeted Node-safe tests for:

  • shared sanitization helpers
  • profile render escaping and invalid-URL omission
  • zap comment escaping and multiline rendering

Commands run:

  • npm test -- --run
  • npm run build
  • npm run build-storybook

Verification Notes

The new tests passed.

build and build-storybook still surface pre-existing TypeScript/declaration warnings in untouched legacy/disabled areas such as:

  • src/nostr-comment/*
  • src/nostr-dm/*
  • src/nostr-live-chat/*
  • existing unused import noise in src/nostr-zap-button/zap-utils.ts

These warnings were already outside the scope of this issue and were not introduced by this PR.

Summary by CodeRabbit

  • Bug Fixes

    • Enhanced security by sanitizing user-provided URLs and HTML content to prevent malicious code injection.
    • Improved handling of invalid or unsafe URLs in avatars, images, and links with appropriate fallback placeholders.
    • Fixed text rendering with proper character escaping and line break preservation.
  • Improvements

    • Better user mention and link rendering in posts.

@coderabbitai

coderabbitai Bot commented Apr 20, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Added comprehensive XSS prevention across the component library by introducing a new sanitize.ts module with URL validation, multiline text escaping, and HTML fragment sanitization. Updated all components to use sanitization functions for dynamic URLs and text, refactored token-based rendering for post mentions, and added tests validating that malicious HTML and URLs are neutralized.

Changes

Cohort / File(s) Summary
Core Sanitization Utilities
src/common/sanitize.ts, src/common/__tests__/sanitize.test.ts
Added new sanitization module exporting sanitizeHttpUrl, sanitizeMultilineText, and sanitizePostInlineFragment, with DOMPurify integration for browser environments and comprehensive test coverage validating URL protocol filtering, HTML escaping, and dangerous content rejection.
URL Escaping Refactor
src/common/utils.ts
Replaced DOM-based escapeHtml with pure string-based implementation using chained .replace() for &, <, >, ", and ' characters; added formatting/semicolon consistency improvements.
Post Rendering Pipeline
src/nostr-post/inline-fragment.ts, src/nostr-post/parse-text.ts, src/nostr-post/render-content.ts, src/nostr-post/render.ts, src/nostr-post/nostr-post.ts
Introduced token-based mention rendering (createProfileMentionToken, createUsernameMentionToken, renderPostInlineText) to abstract and sanitize profile/username mentions; refactored parseText and render-content to use token helpers and sanitize media/link URLs; applied sanitizeHttpUrl to author images and embedded media.
Profile Components
src/nostr-profile/render.ts, src/nostr-profile/__tests__/render.test.ts, src/nostr-profile/style.ts, src/nostr-profile-badge/render.ts
Added URL sanitization for picture, website, and banner fields; HTML-escaped displayName and error messages; added .avatar-placeholder CSS rule and conditionally rendered placeholders when sanitized URLs are empty; added tests validating malicious HTML and URLs are neutralized.
Like Dialog Rendering
src/nostr-like-button/dialog-likers.ts
Updated renderLikeEntry to sanitize njump.me URLs and profile picture sources, escape author pubkeys, and conditionally render links only when sanitized URLs are non-empty; refactored link rendering from URL-gated anchors to fallback spans.
Zap Button Rendering
src/nostr-zap-button/render-zap-entry.ts, src/nostr-zap-button/dialog-zappers.ts, src/nostr-zap-button/__tests__/dialog-zappers.test.ts
Extracted renderZapEntry into dedicated module with EnhancedZapDetails interface; sanitizes author picture URLs and comment text, escapes author names/pubkeys, validates njump.me links; added test validating comment HTML escaping and line-break preservation.
Livestream & Follow Button Components
src/nostr-livestream/render.ts, src/nostr-follow-button/render.ts
Applied sanitizeHttpUrl to author images, streaming player sources, preview images, and recording links; removed isValidUrl in favor of sanitization-based conditional rendering; added formatting/trailing-comma consistency.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

The review scope is substantial with 18 affected files including new security-critical modules (sanitize.ts, inline-fragment.ts, render-zap-entry.ts), heterogeneous refactoring patterns across multiple post/profile/dialog components, and widespread integration of sanitization logic. Verification requires confirming consistent application of URL validation and HTML escaping throughout the rendering pipeline, validating test coverage of XSS scenarios, and ensuring legitimate content (e.g., link targets, images) remains functional after sanitization.

Poem

🐰 With sanitized URLs and escaped text so bright,
No malicious scripts will wreck our site!
Tokens dance through mentions, pure and clean,
The safest Nostr components ever seen! ✨🔐

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 22.06% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ 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 "Harden active rendered surfaces against XSS" directly summarizes the main objective of the PR - implementing XSS hardening across rendered components.
Linked Issues check ✅ Passed The PR fully addresses #44 objectives: identifies dynamic content locations, implements HTML escaping and URL sanitization via new sanitization utilities, preserves legitimate functionality, and adds comprehensive tests.
Out of Scope Changes check ✅ Passed All changes are directly scoped to XSS hardening: new sanitization module, URL validation, HTML escaping in components, and related test files. No unrelated refactoring or API changes detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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 and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/nostr-profile/render.ts (1)

61-68: ⚠️ Potential issue | 🟠 Major

Escape npub before interpolating it into the follow-button attribute.

npub is a plain string in RenderProfileOptions; inserting it raw into an HTML attribute can reopen attribute-injection XSS if an invalid value reaches this renderer.

🛡️ Proposed fix
   const banner = sanitizeHttpUrl(userProfile?.banner || '');
   const safeDisplayName = escapeHtml(displayName);
+  const safeNpub = escapeHtml(npub);
 
   const renderFollowButton = () => {
     if (!showFollow || npub === '') return '';
     return `
       <nostr-follow-button
-        npub="${npub}">
+        npub="${safeNpub}">
       </nostr-follow-button>
     `;
   };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/nostr-profile/render.ts` around lines 61 - 68, The follow-button
interpolation uses raw npub which can enable attribute-injection XSS; in
renderFollowButton (the function returning the <nostr-follow-button> string)
escape npub before inserting it into the npub attribute (use the existing
escapeHtml helper like safeDisplayName does) and interpolate the escaped value
instead of the raw npub, keeping the existing showFollow and empty-npub guard
logic.
🧹 Nitpick comments (5)
src/nostr-livestream/render.ts (1)

170-194: Optional: don't echo the rejected URL back to the user.

When the recording URL fails sanitization, the fallback renders Watch Recording: ${escapeHtml(url)} which surfaces a javascript:… / data:… / malformed string to the reader. It's escaped so it can't execute, but it's also not useful information. A generic "Recording unavailable" (or simply omitting the block) would be a better UX without changing the security posture.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/nostr-livestream/render.ts` around lines 170 - 194, In
renderRecordingLink, avoid echoing the rejected URL back to the user on sanitize
failure; instead, when sanitizeHttpUrl(url) returns falsy, render a generic
fallback (e.g., "Recording unavailable" or omit the block) rather than using
escapeHtml(url). Update the false-branch in renderRecordingLink to show a
non-clickable message without the original URL string and keep the clickable
branch unchanged.
src/common/sanitize.ts (1)

32-47: sanitizeHttpUrl returns HTML-entity-escaped output; callers must only inject it into HTML attribute context.

escapeHtml(parsedUrl.toString()) turns & into &amp;, " into &quot;, etc. That is correct inside href="..."/src="...", but the returned value is not a plain URL — using it as a fetch target, as a window.location assignment, as an <a>'s .href DOM property, or for equality comparison will be wrong (e.g., ?a=1&amp;b=2). Consider:

  • renaming to something like sanitizeHttpUrlForAttr to signal the contract, and/or
  • adding a short JSDoc note warning that the result is attribute-safe (HTML-escaped), not a bare URL.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/common/sanitize.ts` around lines 32 - 47, The function sanitizeHttpUrl
currently returns an HTML-escaped string (via escapeHtml(parsedUrl.toString())),
which is safe for injecting into HTML attributes but not for programmatic use
(fetch, window.location, .href, comparisons); update the API to make this
contract explicit by either renaming sanitizeHttpUrl to something like
sanitizeHttpUrlForAttr and adding a JSDoc on that function stating the output is
HTML-escaped and only for attribute contexts, and also provide (or suggest
adding) a separate sanitizeHttpUrlRaw (or an option/flag) that returns the
unescaped validated URL string; locate references to sanitizeHttpUrl and adjust
callsites or add the new function accordingly so callers that need a raw URL use
the unescaped validator instead of the escaped output.
src/common/__tests__/sanitize.test.ts (1)

1-42: Consider adding a test for sanitizePostInlineFragment.

The third exported helper isn't covered here. A quick test in the Node/Vitest environment would lock in the non-browser fallback behavior (whatever you decide it should be — see the comment on sanitize.ts line 53-66) and make regressions loud. Even a single assertion confirming that a crafted <script> payload is neutralized would be valuable.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/common/__tests__/sanitize.test.ts` around lines 1 - 42, Add a unit test
for the exported function sanitizePostInlineFragment alongside the existing
sanitizeHttpUrl/sanitizeMultilineText tests: import sanitizePostInlineFragment
and assert that a crafted inline payload (e.g., containing a <script> tag or on*
attribute) is neutralized and returned as safe HTML (matching the non-browser
fallback behavior), plus at least one assertion that preserves allowed
content/line breaks if applicable; this will lock in
sanitizePostInlineFragment's Node/Vitest behavior and catch regressions.
src/nostr-zap-button/__tests__/dialog-zappers.test.ts (1)

6-27: Good XSS regression test; consider expanding coverage.

The test nails the core cases (HTML escape, newline→<br />, javascript: picture neutralized). Two cheap additions that would strengthen this suite without much effort:

  • Assert the <div class="zap-entry" …> contains the expected escaped data-author-pubkey when authorPubkey contains " / < characters.
  • Cover the authorName escape path with a <script>-bearing name to pin down that contract too.

Not blocking.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/nostr-zap-button/__tests__/dialog-zappers.test.ts` around lines 6 - 27,
The test should be extended to assert that renderZapEntry properly escapes
special chars in attributes and author name: update the existing test (or add a
new one) that calls renderZapEntry with an authorPubkey containing characters
like '"' and '<' and an authorName containing a <script> payload, then assert
the resulting HTML's <div class="zap-entry" ...> contains the expected escaped
value in the data-author-pubkey attribute and that the authorName appears
escaped (no raw <script> tag present). Use the same renderZapEntry helper in
dialog-zappers.test.ts so the new assertions verify attribute-escaping and
name-escaping paths alongside the existing comment-escaping checks.
src/nostr-profile/__tests__/render.test.ts (1)

6-103: LGTM — covers both escaping and URL-scheme omission paths.

Good mix: displayName/errorMessage escaping plus javascript:/data: omission for picture, website, and banner with placeholder assertions. One small nit: consider also asserting that href="https://…" does appear for a valid website in a positive-path test to guard against an over-aggressive regression in sanitizeHttpUrl later.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/nostr-profile/__tests__/render.test.ts` around lines 6 - 103, Add a
positive-path assertion that a valid http(s) website is rendered to guard
against over-aggressive URL sanitization: update the test that calls
renderProfile (the second "omits invalid banner..." case or add a new small
test) to include a userProfile.website set to "https://example.com" (or similar)
and assert the produced HTML contains href="https://example.com" (or a properly
escaped equivalent). Locate the call to renderProfile in this test and add the
assertion so it verifies renderProfile (and underlying sanitizeHttpUrl) allows
legitimate https URLs while still omitting javascript: and data: schemes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/common/sanitize.ts`:
- Around line 53-66: sanitizePostInlineFragment currently falls back to
sanitizeMultilineText in non-browser environments which re-escapes already-safe,
synthesized markup and mangles <a>/<span> mention HTML; change the non-browser
branch in sanitizePostInlineFragment so that when getPostInlinePurifier()
returns null it returns the input html unchanged (instead of calling
sanitizeMultilineText). This preserves the already-escaped dynamic pieces
produced by renderPostInlineText (which uses escapeHtml and sanitizeHttpUrl) and
the whitelisted tags/attrs injected by the pipeline.

In `@src/nostr-post/inline-fragment.ts`:
- Around line 10-52: The token encoding fails for payloads containing literal
parentheses because encodeURIComponent doesn't escape "(" or ")", so update the
token builders to percent-encode parentheses after encodeURIComponent: in
createProfileMentionToken call encodeURIComponent on href and displayName then
replace "(" with "%28" and ")" with "%29" (same for createUsernameMentionToken
on username) so the existing PROFILE_MENTION_TOKEN_REGEX and
USERNAME_MENTION_TOKEN_REGEX continue to parse unambiguously; no changes to
decode side required.

In `@src/nostr-post/parse-text.ts`:
- Around line 31-56: The loop in parse-text.ts that processes noteMatches uses
textContent.replace(fullMatch, ' ') which only replaces the first occurrence;
update all replacements (the occurrences using textContent.replace(fullMatch,
...), textContent.replace(replacement.original, replacement.replacement), and
similar calls) to replace every instance by using String.prototype.replaceAll or
a global RegExp with the literal escaped match (ensuring special regex chars are
escaped) so that all duplicate nostr:, note references, and `@username`
occurrences are removed/replaced; locate the logic around noteMatches, the
embeddedNotes handling, and the replacement code paths (variables fullMatch,
noteId, textContent, and replacement.original/replacement.replacement) and
switch them to replaceAll or an escaped global-regex alternative.

In `@src/nostr-post/render-content.ts`:
- Around line 145-152: The embedded post HTML inserts the date directly into the
template in renderEmbeddedPost, so escape the date before interpolation to avoid
XSS: call escapeHtml(date) where the template uses ${date} (same place that uses
displayName and nip05), update the construction of embedHtml (or the template
variable used before assigning to temp.innerHTML) to use the escaped value, and
ensure the rest of the flow (placeholder, temp, temp.firstElementChild!) remains
unchanged.

---

Outside diff comments:
In `@src/nostr-profile/render.ts`:
- Around line 61-68: The follow-button interpolation uses raw npub which can
enable attribute-injection XSS; in renderFollowButton (the function returning
the <nostr-follow-button> string) escape npub before inserting it into the npub
attribute (use the existing escapeHtml helper like safeDisplayName does) and
interpolate the escaped value instead of the raw npub, keeping the existing
showFollow and empty-npub guard logic.

---

Nitpick comments:
In `@src/common/__tests__/sanitize.test.ts`:
- Around line 1-42: Add a unit test for the exported function
sanitizePostInlineFragment alongside the existing
sanitizeHttpUrl/sanitizeMultilineText tests: import sanitizePostInlineFragment
and assert that a crafted inline payload (e.g., containing a <script> tag or on*
attribute) is neutralized and returned as safe HTML (matching the non-browser
fallback behavior), plus at least one assertion that preserves allowed
content/line breaks if applicable; this will lock in
sanitizePostInlineFragment's Node/Vitest behavior and catch regressions.

In `@src/common/sanitize.ts`:
- Around line 32-47: The function sanitizeHttpUrl currently returns an
HTML-escaped string (via escapeHtml(parsedUrl.toString())), which is safe for
injecting into HTML attributes but not for programmatic use (fetch,
window.location, .href, comparisons); update the API to make this contract
explicit by either renaming sanitizeHttpUrl to something like
sanitizeHttpUrlForAttr and adding a JSDoc on that function stating the output is
HTML-escaped and only for attribute contexts, and also provide (or suggest
adding) a separate sanitizeHttpUrlRaw (or an option/flag) that returns the
unescaped validated URL string; locate references to sanitizeHttpUrl and adjust
callsites or add the new function accordingly so callers that need a raw URL use
the unescaped validator instead of the escaped output.

In `@src/nostr-livestream/render.ts`:
- Around line 170-194: In renderRecordingLink, avoid echoing the rejected URL
back to the user on sanitize failure; instead, when sanitizeHttpUrl(url) returns
falsy, render a generic fallback (e.g., "Recording unavailable" or omit the
block) rather than using escapeHtml(url). Update the false-branch in
renderRecordingLink to show a non-clickable message without the original URL
string and keep the clickable branch unchanged.

In `@src/nostr-profile/__tests__/render.test.ts`:
- Around line 6-103: Add a positive-path assertion that a valid http(s) website
is rendered to guard against over-aggressive URL sanitization: update the test
that calls renderProfile (the second "omits invalid banner..." case or add a new
small test) to include a userProfile.website set to "https://example.com" (or
similar) and assert the produced HTML contains href="https://example.com" (or a
properly escaped equivalent). Locate the call to renderProfile in this test and
add the assertion so it verifies renderProfile (and underlying sanitizeHttpUrl)
allows legitimate https URLs while still omitting javascript: and data: schemes.

In `@src/nostr-zap-button/__tests__/dialog-zappers.test.ts`:
- Around line 6-27: The test should be extended to assert that renderZapEntry
properly escapes special chars in attributes and author name: update the
existing test (or add a new one) that calls renderZapEntry with an authorPubkey
containing characters like '"' and '<' and an authorName containing a <script>
payload, then assert the resulting HTML's <div class="zap-entry" ...> contains
the expected escaped value in the data-author-pubkey attribute and that the
authorName appears escaped (no raw <script> tag present). Use the same
renderZapEntry helper in dialog-zappers.test.ts so the new assertions verify
attribute-escaping and name-escaping paths alongside the existing
comment-escaping checks.
🪄 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

Run ID: e1e7bb25-6d3b-4042-8d3a-3f537009ae31

📥 Commits

Reviewing files that changed from the base of the PR and between dfc22e5 and a748f03.

📒 Files selected for processing (18)
  • src/common/__tests__/sanitize.test.ts
  • src/common/sanitize.ts
  • src/common/utils.ts
  • src/nostr-follow-button/render.ts
  • src/nostr-like-button/dialog-likers.ts
  • src/nostr-livestream/render.ts
  • src/nostr-post/inline-fragment.ts
  • src/nostr-post/nostr-post.ts
  • src/nostr-post/parse-text.ts
  • src/nostr-post/render-content.ts
  • src/nostr-post/render.ts
  • src/nostr-profile-badge/render.ts
  • src/nostr-profile/__tests__/render.test.ts
  • src/nostr-profile/render.ts
  • src/nostr-profile/style.ts
  • src/nostr-zap-button/__tests__/dialog-zappers.test.ts
  • src/nostr-zap-button/dialog-zappers.ts
  • src/nostr-zap-button/render-zap-entry.ts

Comment thread src/common/sanitize.ts
Comment on lines +53 to +66
export function sanitizePostInlineFragment(html: string): string {
const purifier = getPostInlinePurifier();

if (!purifier) {
return sanitizeMultilineText(html);
}

return purifier.sanitize(html, {
ALLOWED_TAGS: POST_INLINE_ALLOWED_TAGS,
ALLOWED_ATTR: POST_INLINE_ALLOWED_ATTRS,
ALLOW_DATA_ATTR: false,
ALLOWED_URI_REGEXP: POST_INLINE_URI_REGEXP,
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm the only caller of sanitizePostInlineFragment and its input shape.
rg -nP -C3 '\bsanitizePostInlineFragment\s*\('

Repository: saiy2k/nostr-components

Length of output: 836


🏁 Script executed:

# First, let's read the inline-fragment.ts file to see the full function
cat -n src/nostr-post/inline-fragment.ts

Repository: saiy2k/nostr-components

Length of output: 2504


🏁 Script executed:

# Check the sanitizeMultilineText function to confirm it escapes HTML
rg -A10 'function sanitizeMultilineText' src/common/sanitize.ts

Repository: saiy2k/nostr-components

Length of output: 392


🏁 Script executed:

# Check what tags and attributes are allowed in the POST_INLINE configuration
rg -B2 -A10 'POST_INLINE_ALLOWED_TAGS|POST_INLINE_ALLOWED_ATTRS|POST_INLINE_URI_REGEXP' src/common/sanitize.ts

Repository: saiy2k/nostr-components

Length of output: 818


🏁 Script executed:

# Check the token replacement functions to understand what markup they generate
rg -B2 -A15 'function replaceProfileMentionTokens|function replaceUsernameMentionTokens' src/nostr-post/inline-fragment.ts

Repository: saiy2k/nostr-components

Length of output: 1217


Non-browser environment will visibly mangle constructed mention HTML.

renderPostInlineText in src/nostr-post/inline-fragment.ts calls sanitizePostInlineFragment after it has synthesized <a …>@name</a> and <span class="nostr-mention" …>@user</span> markup from escaped tokens. In the browser, DOMPurify preserves those tags per the allowlist. In a non-browser environment (SSR, unit tests, or any consumer without window), the function falls back to sanitizeMultilineText, which calls escapeHtml() on the entire input — converting the freshly-built <a>/<span> tags into &lt;a href=…&gt;@alice&lt;/a&gt; literal text rather than rendered mentions.

The input to sanitizePostInlineFragment is already safe: all dynamic content (href, displayName, username) is escaped by escapeHtml(), URLs are validated via sanitizeHttpUrl(), and only whitelisted tags (<a>, <span>, <br />) with whitelisted attributes are injected by the pipeline itself. Return the input unchanged in the non-browser path rather than re-escaping trusted internal markup.

Proposed fix
 export function sanitizePostInlineFragment(html: string): string {
   const purifier = getPostInlinePurifier();
 
   if (!purifier) {
-    return sanitizeMultilineText(html);
+    // No DOMPurify available (SSR / tests). The pipeline in
+    // renderPostInlineText has already escaped all dynamic text and only
+    // injected mention markup from a small, trusted allowlist, so return
+    // the fragment as-is rather than re-escaping our own <a>/<span>/<br>.
+    return html;
   }
 
   return purifier.sanitize(html, {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function sanitizePostInlineFragment(html: string): string {
const purifier = getPostInlinePurifier();
if (!purifier) {
return sanitizeMultilineText(html);
}
return purifier.sanitize(html, {
ALLOWED_TAGS: POST_INLINE_ALLOWED_TAGS,
ALLOWED_ATTR: POST_INLINE_ALLOWED_ATTRS,
ALLOW_DATA_ATTR: false,
ALLOWED_URI_REGEXP: POST_INLINE_URI_REGEXP,
});
}
export function sanitizePostInlineFragment(html: string): string {
const purifier = getPostInlinePurifier();
if (!purifier) {
// No DOMPurify available (SSR / tests). The pipeline in
// renderPostInlineText has already escaped all dynamic text and only
// injected mention markup from a small, trusted allowlist, so return
// the fragment as-is rather than re-escaping our own <a>/<span>/<br>.
return html;
}
return purifier.sanitize(html, {
ALLOWED_TAGS: POST_INLINE_ALLOWED_TAGS,
ALLOWED_ATTR: POST_INLINE_ALLOWED_ATTRS,
ALLOW_DATA_ATTR: false,
ALLOWED_URI_REGEXP: POST_INLINE_URI_REGEXP,
});
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/common/sanitize.ts` around lines 53 - 66, sanitizePostInlineFragment
currently falls back to sanitizeMultilineText in non-browser environments which
re-escapes already-safe, synthesized markup and mangles <a>/<span> mention HTML;
change the non-browser branch in sanitizePostInlineFragment so that when
getPostInlinePurifier() returns null it returns the input html unchanged
(instead of calling sanitizeMultilineText). This preserves the already-escaped
dynamic pieces produced by renderPostInlineText (which uses escapeHtml and
sanitizeHttpUrl) and the whitelisted tags/attrs injected by the pipeline.

Comment on lines +10 to +52
const PROFILE_MENTION_TOKEN_PREFIX = '__NOSTRC_PROFILE_MENTION__(';
const PROFILE_MENTION_TOKEN_REGEX =
/__NOSTRC_PROFILE_MENTION__\(([^)]*)\)\(([^)]*)\)__/g;
const USERNAME_MENTION_TOKEN_PREFIX = '__NOSTRC_USERNAME_MENTION__(';
const USERNAME_MENTION_TOKEN_REGEX =
/__NOSTRC_USERNAME_MENTION__\(([^)]*)\)__/g;

export function createProfileMentionToken(
href: string,
displayName: string,
): string {
return `${PROFILE_MENTION_TOKEN_PREFIX}${encodeURIComponent(href)})(${encodeURIComponent(displayName)})__`;
}

export function createUsernameMentionToken(username: string): string {
return `${USERNAME_MENTION_TOKEN_PREFIX}${encodeURIComponent(username)})__`;
}

function replaceProfileMentionTokens(fragment: string): string {
return fragment.replace(
PROFILE_MENTION_TOKEN_REGEX,
(_match, encodedHref, encodedDisplayName) => {
const href = sanitizeHttpUrl(decodeURIComponent(encodedHref));
const displayName = escapeHtml(decodeURIComponent(encodedDisplayName));

if (!href) {
return `@${displayName}`;
}

return `<a href="${href}" target="_blank" rel="noopener noreferrer">@${displayName}</a>`;
},
);
}

function replaceUsernameMentionTokens(fragment: string): string {
return fragment.replace(
USERNAME_MENTION_TOKEN_REGEX,
(_match, encodedUsername) => {
const username = escapeHtml(decodeURIComponent(encodedUsername));
return `<span class="nostr-mention" data-username="${username}">@${username}</span>`;
},
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Token payload can contain literal ), which breaks the regex parser.

encodeURIComponent deliberately does not escape ( or ) (MDN reference). So a display name like "Alice (work)" or any username/href containing ) ends up inside the token body, and the greedy ([^)]*) groups in PROFILE_MENTION_TOKEN_REGEX / USERNAME_MENTION_TOKEN_REGEX either match the wrong span or fail entirely — leaving raw __NOSTRC_PROFILE_MENTION__(…)(…)__ strings visible to the user.

Encode ( and ) manually when building/consuming the token so the delimiters are unambiguous:

Proposed fix
+function encodeTokenPart(value: string): string {
+  return encodeURIComponent(value).replace(/\(/g, '%28').replace(/\)/g, '%29');
+}
+
 export function createProfileMentionToken(
   href: string,
   displayName: string,
 ): string {
-  return `${PROFILE_MENTION_TOKEN_PREFIX}${encodeURIComponent(href)})(${encodeURIComponent(displayName)})__`;
+  return `${PROFILE_MENTION_TOKEN_PREFIX}${encodeTokenPart(href)})(${encodeTokenPart(displayName)})__`;
 }
 
 export function createUsernameMentionToken(username: string): string {
-  return `${USERNAME_MENTION_TOKEN_PREFIX}${encodeURIComponent(username)})__`;
+  return `${USERNAME_MENTION_TOKEN_PREFIX}${encodeTokenPart(username)})__`;
 }

(decodeURIComponent already handles %28/%29, so the decode side needs no changes.)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/nostr-post/inline-fragment.ts` around lines 10 - 52, The token encoding
fails for payloads containing literal parentheses because encodeURIComponent
doesn't escape "(" or ")", so update the token builders to percent-encode
parentheses after encodeURIComponent: in createProfileMentionToken call
encodeURIComponent on href and displayName then replace "(" with "%28" and ")"
with "%29" (same for createUsernameMentionToken on username) so the existing
PROFILE_MENTION_TOKEN_REGEX and USERNAME_MENTION_TOKEN_REGEX continue to parse
unambiguously; no changes to decode side required.

Comment on lines +31 to +56
for (const match of noteMatches) {
const fullMatch = match[0];
const noteId = match[2];
const position = match.index || 0;

// Store the note ID and its position for later processing
embeddedNotes.push({
id: noteId,
position: position,
});

// Fetch the embedded post
try {
if (!embeddedPosts.has(noteId)) {
const embeddedPost = await nostrService.getPost(noteId);
if (embeddedPost) {
embeddedPosts.set(noteId, embeddedPost);
}
} catch (error) {
console.error(`Failed to fetch embedded post ${noteId}:`, error);
}

// Remove the note reference from the text to prevent @ symbols being added
textContent = textContent.replace(fullMatch, ' ');
} catch (error) {
console.error(`Failed to fetch embedded post ${noteId}:`, error);
}

// Handle Nostr URI schema for mentions - batch process to avoid multiple async operations
const nostrURISchemaMatches = [...textContent.matchAll(new RegExp(nip21.NOSTR_URI_REGEX, 'g'))];
const uriReplacements: { original: string; replacement: string }[] = [];

// Process all URIs concurrently
const uriPromises = nostrURISchemaMatches.map(async (match) => {
try {
const parsedNostrURI = nip21.parse(match[0]);
const decordedData = parsedNostrURI.decoded.data;

let pubkey = '';
if (typeof decordedData === 'string') {
pubkey = decordedData;
} else {
pubkey = (decordedData as ProfilePointer).pubkey;
}

if (pubkey) {
const user = nostrService.getNDK().getUser({ pubkey });
const profile = await user.fetchProfile();
const name = profile?.displayName || '';
// Remove the note reference from the text to prevent @ symbols being added
textContent = textContent.replace(fullMatch, ' ');
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Minor: String.prototype.replace with a string arg only replaces the first occurrence.

In the three occurrences where textContent.replace(fullMatch, ...) / textContent.replace(replacement.original, replacement.replacement) are called (lines 55, 100-103, 123-126), passing a string as the first argument only replaces the first match. If the same nostr:… URI, note reference, or @username appears more than once in a post, later occurrences will remain in the text and may be rendered raw or mismatched against subsequent regex passes. Sanitization downstream (renderPostInlineText → DOMPurify) still prevents XSS, but the user-visible rendering is subtly broken for duplicate mentions/notes.

This appears to be pre-existing behavior; just worth flagging since the refactor reorganized this codepath. A simple fix is to use replaceAll (or a global regex) with the literal escaped.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/nostr-post/parse-text.ts` around lines 31 - 56, The loop in parse-text.ts
that processes noteMatches uses textContent.replace(fullMatch, ' ') which only
replaces the first occurrence; update all replacements (the occurrences using
textContent.replace(fullMatch, ...), textContent.replace(replacement.original,
replacement.replacement), and similar calls) to replace every instance by using
String.prototype.replaceAll or a global RegExp with the literal escaped match
(ensuring special regex chars are escaped) so that all duplicate nostr:, note
references, and `@username` occurrences are removed/replaced; locate the logic
around noteMatches, the embeddedNotes handling, and the replacement code paths
(variables fullMatch, noteId, textContent, and
replacement.original/replacement.replacement) and switch them to replaceAll or
an escaped global-regex alternative.

Comment on lines +145 to 152
const temp = document.createElement('div');
temp.innerHTML = embedHtml;

// Replace the placeholder with the embedded post
placeholder.parentNode?.replaceChild(
temp.firstElementChild!,
placeholder
);
}
}
}

export async function renderEmbeddedPostContent(
noteId: string,
embeddedPosts: Map<string, NDKEvent>,
event: NDKEvent | null,
nostrService: any
): Promise<string> {
const post = embeddedPosts.get(noteId);
if (!post) return '<div class="embedded-post-error">Post not found</div>';

let authorProfile: NDKUserProfile | null = null;
try {
authorProfile = await post.author.fetchProfile();
} catch (error) {
console.error(
`Failed to fetch profile for embedded post ${noteId}:`,
error
// Replace the placeholder with the embedded post
placeholder.parentNode?.replaceChild(
temp.firstElementChild!,
placeholder,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect renderEmbeddedPost to confirm it escapes displayName / nip05
fd -t f 'render.ts' src/nostr-post | xargs -I{} sh -c 'echo "=== {} ==="; cat {}'

Repository: saiy2k/nostr-components

Length of output: 8621


Escape the date parameter in renderEmbeddedPost.

displayName and nip05 are correctly escaped via escapeHtml() before interpolation, mitigating the original concern. However, the date parameter is inserted directly into the template without escaping (<div class="embedded-post-date">${date}</div>). Apply escapeHtml(date) to prevent XSS if this parameter becomes user-controllable.

🧰 Tools
🪛 ast-grep (0.42.1)

[warning] 145-145: Direct HTML content assignment detected. Modifying innerHTML, outerHTML, or using document.write with unsanitized content can lead to XSS vulnerabilities. Use secure alternatives like textContent or sanitize HTML with libraries like DOMPurify.
Context: temp.innerHTML = embedHtml
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://www.dhairyashah.dev/posts/why-innerhtml-is-a-bad-idea-and-how-to-avoid-it/
- https://cwe.mitre.org/data/definitions/79.html

(unsafe-html-content-assignment)


[warning] 145-145: Direct modification of innerHTML or outerHTML properties detected. Modifying these properties with unsanitized user input can lead to XSS vulnerabilities. Use safe alternatives or sanitize content first.
Context: temp.innerHTML = embedHtml
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://owasp.org/www-community/xss-filter-evasion-cheatsheet
- https://cwe.mitre.org/data/definitions/79.html

(dom-content-modification)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/nostr-post/render-content.ts` around lines 145 - 152, The embedded post
HTML inserts the date directly into the template in renderEmbeddedPost, so
escape the date before interpolation to avoid XSS: call escapeHtml(date) where
the template uses ${date} (same place that uses displayName and nip05), update
the construction of embedHtml (or the template variable used before assigning to
temp.innerHTML) to use the escaped value, and ensure the rest of the flow
(placeholder, temp, temp.firstElementChild!) remains unchanged.

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.

Sanitize dynamic text/HTML content before rendering to prevent XSS vulnerabilities

1 participant