fix: neutralize untrusted text and gate external URLs in UI sinks - #9565
fix: neutralize untrusted text and gate external URLs in UI sinks#9565mikhail-dcl wants to merge 8 commits into
Conversation
…C-008/034/050/084) Attacker-authored strings reached rich-text TMP labels, and an attacker-authored link could open without consent. SEC-008 — the prompt rendered its destination, and a passport link its title, as rich text, so markup could dress an arbitrary URL up as a familiar one and make the single approval easy to obtain. Rich text is off in the prefabs for labels that carry nothing but untrusted text. The http(s) allowlist and (scheme, host) trust key from #9466 were verified, not changed. SEC-084 — event and place descriptions were linkified without escaping the author's own markup, and a resulting click went straight to the browser sink with no prompt. Descriptions now escape before linkification and route clicks through the external-URL prompt. The escaping is opt-in via a new entry point because the four other linkifier callers build links from pre-authored <link=ID> markup that must stay live. SEC-034 / SEC-050 — profile, member, announcement and notification sinks bound raw names. They now use the filtered ValidatedName where available, escape where the label's own copy is markup that must keep working, and cap length everywhere. RichTextSanitizer is the single escaper, replacing a private duplicate in TransactionRecipientUtils. It also escapes the backslash: TMP decodes a backslash-u sequence into the character it denotes inside the array its tag parser reads, so a brackets-only filter let markup through unparsed. That branch is gated by neither parseCtrlCharacters nor the input-source check above it, which ships commented out, so escaping is the only available defence. This also closes the hole in the transaction-confirmation copy, where a scene name could otherwise hide the recipient and amount. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Follow-up to 7243c73, covering the sinks the first pass left and three more found while checking them. Prefab flags, for labels that render nothing but text another user wrote: place and event names, coordinates and event list entries in the navmap; the community result card and card header descriptions; the NFT prompt description; the chat username; and NameContainer, which covers the announcement author, friend panel, push notification and photo-detail person card at once. Code escaping, for labels whose own copy is markup and would break if turned plain: the navmap <b> host and creator templates, and FriendRequestController's three message sinks. Not previously listed, same class, now fixed: FriendPushNotificationView and FriendPanelUserView bound the raw Name rather than the filtered ValidatedName, FriendRequestController assigned a raw message body to a rich-text label, and the community name, title and owner were unescaped alongside the descriptions. PlaceToast.prefab binds four view fields — LiveEventNameLabel, DescriptionLabel, CoordinatesLabel and ParcelCountLabel — to one TMP component, so turning rich text off for the coordinates would also silence the description's links. Those writers escape in code instead. UntrustedTextLabelsShould now pins both halves of the contract, nine labels plain and five deliberately rich, and asserts that shared binding so the guard cannot be "fixed" by flipping the flag it protects. Also drops the SimpleUserNameElement overload that had no callers, and folds the announcement body cap into RichTextSanitizer.DEFAULT_BODY_LENGTH. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Windows and Mac build successful in Unity Cloud! You can find a link to the downloadable artifact below. |
decentraland-bot
left a comment
There was a problem hiding this comment.
STEP 2 — Root-cause check: PASS
The PR correctly identifies and fixes the root causes:
- TMP markup injection — Untrusted text (user-authored names, descriptions, announcements) reached rich-text TMP labels without escaping, allowing an attacker to inject
<size>,<color>,<link>tags. - Missing consent prompt — Event/place description links opened directly via the OS browser without asking the user to approve.
\uXXXXbypass — TMP decodes backslash-u escape sequences insidePopulateTextProcessingArray(case 117), bypassing a brackets-only filter. The fix neutralizes the backslash alongside the angle brackets.
The fix is at the right level: a shared escaper (RichTextSanitizer) applied at every sink, complemented by prefab-level richText=false where the label renders nothing but untrusted text.
STEP 3 — Design & integration: PASS
RichTextSanitizer is a stateless static utility class — it holds no lifecycle state, manages no entities or resources, and belongs in DCL.UI alongside the TMP labels it protects. No lifecycle owner search needed.
SetAuthorTextWithClickeableLinks combines escaping, assignment, linkification, and consent-prompt routing in a single call. The coupling is intentional — the docstring correctly notes that separating escape from assignment invites drift (SEC-084). It accesses ViewDependencies.GlobalUIViews statically, trading constructor-injected UnityAppWebBrowser for a hidden dependency. ViewDependencies is an established pattern in this codebase, and the trade buys a simpler constructor surface and the guarantee that all description links route through consent. Worth documenting as tech debt for testability in a follow-up.
The removal of webBrowser from EventInfoPanelController and PlaceInfoPanelController constructors is clean — all callers in ExplorePanelPlugin are updated.
STEP 4 — Member audit
| Member | Consumers | Assessment |
|---|---|---|
RichTextSanitizer.Escape |
~6 direct + via SetAuthorTextWithClickeableLinks |
Well-scoped |
RichTextSanitizer.EscapeAttribute |
1 (TransactionRecipientUtils.HighlightLink) |
Distinct responsibility (attribute context); not a merge candidate |
RichTextSanitizer.EscapeAndTruncate |
~15 sinks | Well-scoped |
RichTextSanitizer.Truncate |
~4 sinks | Well-scoped |
SetAuthorTextWithClickeableLinks |
2 sinks (event/place description) | Documents its coupling contract |
IsTrusted |
2 within ExternalUrlPromptController |
Clean extraction of duplicated condition |
SetUserName (private) |
1 (Setup) |
Thin setter with a security contract documented in its summary — acceptable per §11 |
No single-use derived predicates or absent-≠-false problems found.
STEP 5 — Line-level findings
See inline comments. Three P2 findings, no P0/P1.
Additional note (not inline): In ExternalUrlPromptController, the consent dialog displays uri.AbsoluteUri (line 103), but all three OpenUrlMainThreadOnly calls (lines 49, 69, 72) pass uri.OriginalString. The comment at line 100 claims AbsoluteUri is “the canonical form UnityAppWebBrowser hands to Application.OpenURL” — if that’s true, using OriginalString in the controller creates a theoretical mismatch between what the user consents to and what opens. Not blocking (the domain is always the same, and the labels have richText off), but worth aligning in a follow-up.
Security review: No issues found
The escaping is complete:
<,>,\are the only independent markup entry points in TMP.{/}only matter inside<sprite>attributes (which require<first). Unicode normalization (NFKC) would map\back to\, but TMP does not normalize its input buffer, so no bypass exists.\uXXXX/\UXXXXXXXXsequences are handled by escaping\→\.- All description links now route through the consent prompt. Non-web schemes are blocked by the existing
http(s)allowlist inExternalUrlPromptController.Params. - Prefab guards (
UntrustedTextLabelsShould) pin the richText flags on shipped assets, preventing accidental re-enablement.
Summary
Thorough, well-implemented security fix. Two defence layers (prefab flags + code escaping) cover all UI sinks that render untrusted text. The RichTextSanitizer is allocation-free for the common case (no markup) and handles edge cases (surrogate pairs, \uXXXX sequences). Six new test classes (~870 lines) provide comprehensive coverage of the sanitizer, name element, notification text, description links, external URL prompt, and prefab label assertions.
No P0 or P1 issues. Three P2 findings noted in inline comments.
REVIEW_RESULT: PASS ✅
COMPLEXITY: COMPLEX
COMPLEXITY_REASON: Security hardening across UI rendering of untrusted text in navmap, friends, notifications, communities, chat, passport, external URL prompt, with a new shared sanitizer utility and async consent flow changes
QA_REQUIRED: YES
Reviewed by Jarvis 🤖 · Requested by decentraland-bot via GitHub
|
🔍 Jarvis reviewed this PR and found no blocking issues, but assessed it as complex — human DEV review is still required before merging. |
|
Warnings count reduced: 13940 => 13886 Warnings/errors in files changed by this PR (116) |
Review feedback on #9565. The confirmation dialog's mainText renders rich text, and sixteen call sites format community, member and place names into it — the reviewer flagged one of them (CommunityResultCardView's raw currentCommunityName). None of the nine dialog text arguments carries markup of its own, so the fix is the flag rather than an escape at each site: richText off on mainText and subText. additonalUrlText stays rich, since it carries the linkified URL the dialog can show. The ValidatedName-with-fallback pattern that had spread to four call sites now lives on Profile.CompactInfo as ValidatedNameOrRaw. Named for what it does rather than "safe": the fallback is verbatim user input, so a label rendering it still needs richText off or an escape, and a name implying otherwise is how this class of bug comes back. Also clears 36 nullable-reference warnings across the files this PR touches, which unblocks the warning ratchet. Mostly `= null!` on Unity-serialized fields, the established convention here; three private fields in NftPromptController that were declared non-nullable while every call site already treated them as nullable; and PlaceInfoPanelController.thumbnailImage, where the annotation was the lie — it is readonly, assigned unconditionally from a factory that always returns a new instance, and already dereferenced unconditionally elsewhere. Left alone deliberately: Profile.CompactInfo's uninitialized members, because MentionName and UnclaimedName genuinely end up null for a name with no alphanumerics and seeding them would change behaviour for every consumer; and two latent NREs in PlaceInfoPanelController where cameraReelGalleryController and homeButton are assigned under a null check but dereferenced without one. Both want their own change, not this one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
decentraland-bot
left a comment
There was a problem hiding this comment.
PR Review: fix: neutralize untrusted text and gate external URLs in UI sinks
STEP 2 — Root-cause check
Problem: Text written by other users — profile and community names, event/place descriptions, announcements, friend-request messages — reached rich-text TMP labels unsanitized. An attacker-authored <link> could open a URL with no consent prompt, and <size=0> / <color=#00000000> could hide critical copy (such as a transaction's recipient and amount). Additionally, TMP's \uXXXX escape-sequence decoder could be used to bypass a brackets-only filter.
Does the diff fix the cause? Yes. The PR addresses the root cause at every UI sink rather than patching a symptom. Two correct mechanisms are used per label:
richTextoff in the prefab for labels that render nothing but untrusted text.- Code-level escaping via the new
RichTextSanitizerfor labels whose own copy is styled markup that must keep working.
Description links that previously opened through webBrowser.OpenUrlMainThreadOnly (no consent) are now routed through the external-URL consent prompt. The webBrowser dependency is cleanly removed from both PlaceInfoPanelController and EventInfoPanelController.
PASS ✅
STEP 3 — Design & integration
RichTextSanitizer (new static utility, DCL.UI namespace)
This is a pure, stateless string transformation — no ECS entities, no Unity lifecycle, no persistent state. A static class is the correct shape; it belongs with the TMP UI infrastructure it protects, not in an ECS system. The codebase already uses TextMeshProExtensions in the same namespace for TMP helpers.
The string.Create with SpanAction approach is well-suited to the GC-pressure constraints:
- Common case (no markup):
IndexOfMarkupshort-circuits and returns the originalstringreference — zero allocation. Pinned byReturnTheSameInstanceWhenThereIsNothingToEscape. - Escape case: One allocation via
string.Create; thestaticlambda avoids closure allocation. - Truncate+escape: Cut, swaps, and ellipsis all land in one buffer — one allocation instead of Substring + escape + concat.
ValidatedNameOrRaw on Profile.CompactInfo — correctly placed on the type that owns both ValidatedName and Name. Pure computed property, no side effects, no allocation. Used by 4+ call sites.
SetAuthorTextWithClickeableLinks — correct composition: escape → assign → linkify → route clicks through consent prompt. Centralizing this prevents the escape-then-linkify steps from drifting apart across callers.
Removed webBrowser parameters — confirmed both EventInfoPanelController.OpenUrl and PlaceInfoPanelController.OpenUrl were the sole consumers. Three call sites in ExplorePanelPlugin updated. Clean removal, no dangling references.
Deleted SimpleUserNameElement.Setup(string, string, bool, Color) overload — all 5 callers (ProfileInputSuggestionElement, DonationLoadingView, DonationConfirmedView, SimpleProfileView, DonationDefaultView) use the CompactInfo overload. No external consumers of the deleted overload exist.
IsTrusted extraction in ExternalUrlPromptController — used by two call sites (OnViewShow and WaitForCloseIntentAsync), so the extraction eliminates duplication and is justified.
PASS ✅
STEP 4 — Member audit
| Member | Consumer count | Verdict |
|---|---|---|
RichTextSanitizer.Escape |
13 (direct + via EscapeAndTruncate + via SetAuthorTextWithClickeableLinks) |
Core escaper, not single-use |
RichTextSanitizer.EscapeAttribute |
1 (TransactionRecipientUtils.HighlightLink) |
Justified: distinct threat model (attribute position vs content position) |
RichTextSanitizer.Truncate |
5 call sites | Justified: labels with richText off need only a cap |
RichTextSanitizer.EscapeAndTruncate |
15+ call sites | Primary consumer entry point |
ValidatedNameOrRaw |
4 call sites | Justified, all callers pass through EscapeAndTruncate |
IsTrusted |
2 call sites | Justified, eliminates duplication |
PASS ✅
STEP 5 — Line-level findings
See inline comments below. All findings are P2 (minor).
Security review
The sanitizer is sound against TMP's markup parser:
<and>replacement blocks all tag types (<size>,<color>,<link>,<b>,<sprite>, etc.).\replacement closes the\uXXXX/\UXXXXXXXXdecode path inTMP_Text.PopulateTextProcessingArray— tested byNeutralizeMarkupSmuggledAsAUtf16EscapeSequenceand the UTF-32 variant."replacement in attribute position prevents early<link="...">closure.AbsoluteUri(notOriginalString) displayed in the consent prompt — percent-encoding is a second barrier against markup smuggling in the displayed URL.- Null-URI path correctly clears the previous prompt's destination and callback.
- The
http(s)-only scheme allowlist inExternalUrlPolicyis tight and tested. SetAuthorTextWithClickeableLinksorder of operations is correct: escape first (neutralizing injected<link>tags), then linkify barehttps://URLs, then route clicks through the consent prompt.
No P0 or P1 security issues found. ✅
STEP 8 — Non-blocking warnings
No Main.unity modification detected. ✅
REVIEW_RESULT: PASS ✅
COMPLEXITY: COMPLEX
COMPLEXITY_REASON: Touches UI sinks across navmap, friends, communities, chat, notifications, donations, passport, NFT prompt, and external URL prompt; introduces a shared security utility and rearchitects description-link routing.
QA_REQUIRED: YES
Reviewed by Jarvis 🤖 · Requested by mikhail-dcl via GitHub
| [TestCase("Assets/DCL/Communities/CommunitiesBrowser/Prefabs/CommunityResultCard.prefab", nameof(CommunityResultCardView), "communityDescription")] | ||
| [TestCase("Assets/DCL/NftPrompt/Assets/NftPrompt.prefab", nameof(NftPromptView), "<TextDescription>k__BackingField")] |
There was a problem hiding this comment.
[P2] Test coverage gap for CommunityCardHeader.prefab description label. This prefab's description label had richText changed from 1 to 0 in this PR, and CommunityCardView.ConfigureCommunity uses Truncate (no escaping) for that description — relying entirely on the prefab flag for safety. If someone reverts the flag in the inspector, the description becomes injectable and no test catches it.
| [TestCase("Assets/DCL/Communities/CommunitiesBrowser/Prefabs/CommunityResultCard.prefab", nameof(CommunityResultCardView), "communityDescription")] | |
| [TestCase("Assets/DCL/NftPrompt/Assets/NftPrompt.prefab", nameof(NftPromptView), "<TextDescription>k__BackingField")] | |
| [TestCase("Assets/DCL/Communities/CommunitiesBrowser/Prefabs/CommunityResultCard.prefab", nameof(CommunityResultCardView), "communityDescription")] | |
| [TestCase("Assets/DCL/Communities/CommunitiesCard/Prefabs/CommunityCardHeader.prefab", "CommunityCardView", "<communityDescription>k__BackingField")] | |
| [TestCase("Assets/DCL/NftPrompt/Assets/NftPrompt.prefab", nameof(NftPromptView), "<TextDescription>k__BackingField")] |
…(SEC-023) An incoming chat message skipped the formatting pipeline when its text merely began with 🟢/🔴/🟡 followed by system-looking punctuation. That is a content check, so any co-located peer could prefix their message and choose whether their own text was processed. Two things the finding's mitigation note gets wrong, both verified here. ChatEntryMessageContentElement.prefab does carry m_isRichText: 0, but ChatEntryMessageBubbleElement.prefab overrides it back to 1 for the content element nested inside it, and that bubble is embedded in ChatEntry_BASE.prefab — the base of the entries the feed instantiates. Peer message text therefore renders as rich text, with parseCtrlCharacters on, so escape sequences decode there too. And FormatText never sanitized anything: its own tests show it wraps URLs in <link=url> and leaves the rest untouched. Skipping it did not create the injection, which means the injection applied to every peer message, prefix or not. So the fix is escaping, not merely closing the prefix hole: peer text is escaped before formatting, leaving the peer's markup inert while the <link> and <color> runs the formatter adds still work. The skip now reads IsSystemMessage alone, and IsCopyOfSystemMessage, AfterMarkerLooksLikeSystem and TrimStartIgnorables are gone. The spoofing leg needs no change: the feed picks the system prefab (ChatMessageFeedView) and the system icon and colour (CreateMessageViewModelCommand) off IsSystemMessage, which a peer cannot set, so a 🟢 message from a peer renders in an ordinary bubble under its sender's name. Not done here: the finding also asks for IsSystemMessage to be persisted, since the history serializer stores four fields and omits it — which is why the prefix sniff existed. Changing an encrypted-history format needs backward-compatible reads and belongs in its own change. The consequence meanwhile is benign: a reloaded system line is escaped and linkified like any other text, and system copy carries no markup. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
# Conflicts: # Explorer/Assets/DCL/Navmap/PlaceInfoPanelController.cs
The earlier passes covered the community card and browser result card. A community name reaches seven more labels, each authored by whoever owns the community: the event card's community line, the community title header, the "my communities" list, the requests-received group header, both voice-chat call views, and the chat titlebar — which carries a community name for a community channel and the other party's name for a DM. The world chat bubble is escaped one step earlier, in ChatWorldBubbleService, where community data enters the bubble path. Its label is NametagElement, a UI Toolkit Label whose assembly (DCL.AvatarRendering) cannot see this helper, and adding that assembly reference to reach one label is the worse trade. UI Toolkit text has rich text on by default and uses the same bracket syntax, so the risk is real and the same escaper answers it. Also aligns the authoring field with the display path. Name length already agreed — the input caps at 30 against a 64 display cap — but the input accepted characters the display escapes, so typing "<3" would render back as "‹3". The input now rejects exactly what Escape rewrites, reading the set from RichTextSanitizer.IsRewritten so the two cannot drift, with a test pinning that they agree in both directions. This is consistency, not protection: the Communities API is open, so a name that never passes through this field is precisely what the escaping exists for. Test fix, unrelated to the above: the dev merge changed Profile.CompactInfo's first parameter from string to UserId, which left two of these test files uncompilable. They now build the id with UserId.New(...).Unwrap(), the pattern the existing tests use for known-valid constants. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
🚦 CI StatusWindows and Mac build successful in Unity Cloud! You can find a link to the downloadable artifact below. Warnings count reduced: 13156 => 13112 Warnings/errors in files changed by this PR (157)All Unity tests passed ✅
|
A profile name is a schema-valid string that no backend rejects, so the client is the only thing between a crafted one and the labels that render it. Testing that honestly means putting a crafted name on a real profile and letting it come back from the catalyst, rather than trusting a unit test's idea of the payload. /setname publishes the name raw. It deliberately filters nothing: whatever the UI does with it on the way back is the thing under test. Debug-only, via the existing IChatCommand hook. Not because it grants an attacker anything — the profile API is open and a crafted profile is exactly this — but because it is an instrument, not a feature. Two details the dispatcher forces. It splits the message on spaces, so the parameters are rejoined here; and it collapses ", " into "," before splitting, which cannot be undone at this layer. Hence \uXXXX decoding, which is also the way to set characters the chat input will not carry and to reproduce the escape sequences TMP decodes on its own. The reply echoes the name escaped and adds the derived ValidatedName. Command replies are emitted as system messages, the one path deliberately left unsanitized, so echoing raw markup there would inject it into the client's own copy and confuse the test being run. A failed publish restores the previous name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The command is a testing instrument, not shipped behaviour, so pinning it with its own suite buys maintenance rather than confidence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
PR #9565, run #32025873407 Builds: Windows change, Windows baseline, macOS change, macOS baseline How to read this table
Intel Core i5
Exception breakdown
Apple M1
|
Pull Request Description
What does this PR change?
Text written by other users — profile and community names, event/place descriptions, announcements, friend-request messages — reached rich-text TMP labels unsanitized, and an attacker-authored link could open with no consent prompt. Fixes SEC-008, SEC-023, SEC-034, SEC-050 and the client half of SEC-084.
Two mechanisms, chosen per label:
richTextoff in the prefab where a label renders nothing but untrusted text.RichTextSanitizerwhere the label's own copy is markup (a<b>run, or the<link>the description linkifier emits) and turning it plain would break it.RichTextSanitizeralso escapes the backslash, which is not cosmetic: TMP rewrites a\uXXXXsequence into the character it denotes inside the array its tag parser reads (TMP_Text.PopulateTextProcessingArray,case 117). That branch is gated by neitherparseCtrlCharactersnor the input-source check above it, which ships commented out — so a brackets-only filter let markup through unparsed. This also closes the same hole in the already-shipped transaction-confirmation copy, where a scene name could otherwise hide the recipient and amount.A third mechanism applies to chat: gate on provenance, never on content. An incoming message used to skip the formatting pipeline when its text merely began with 🟢/🔴/🟡, so any peer could prefix theirs and choose whether it was processed. The skip now reads
IsSystemMessagealone. Note the injection there was never gated on the prefix at all —FormatTextonly wraps URLs in<link=url>and never escaped anything — so peer text is now escaped before formatting.Changes
RichTextSanitizer(new)Escape(content),EscapeAttribute(tag attribute),Truncate(cap only), shared name/body caps. One allocation per escaping call; none when there is nothing to escape<b>templates keep rich text, values escapedValidatedName; falls back to the raw name when the filter leaves nothing (emoji-only names)Namereplaced with filtered name; message bodies escaped inside their<b>templateIsSystemMessageinstead of a forgeable status-emoji prefix; peer text escaped before linkification.ChatEntryMessageBubbleElementoverrides the content label back to rich text, so this text does render markupTransactionRecipientUtilsSimpleUserNameElement.Setupoverload and unusedwebBrowserctor parameters removedAlready landed in #9466 and only verified here, not changed: the
http(s)-only scheme allowlist and the(scheme, host)trust key that closed the empty-host prompt bypass.Note for reviewers:
PlaceToast.prefabbinds four view fields —LiveEventNameLabel,DescriptionLabel,CoordinatesLabel,ParcelCountLabel— to a single TMP component, so turning rich text off for the coordinates would silence the description's links. Those writers escape in code instead.UntrustedTextLabelsShouldpins both directions (nine labels plain, five deliberately rich) and asserts that shared binding, so the guard cannot be "fixed" by flipping the flag it protects.Server-side halves of SEC-084 (
decentraland/eventsapproval-reset bug,decentraland/placesoutput sanitization) are out of scope for this repo.Test Instructions
Steps (standard run):
Steps (fresh account):
Prerequisites
Set these up first — every check below reuses them.
<size=400%><color=#00FF00>Verified Admin\u003Csize=400%\u003EAdmin👽👽<link="smb://attacker/share">click here</link>,<size=400%>huge, and a plainhttps://decentraland.org🟢 <link="https://evil.example.com">Verified ✔</link><b><size=400%>urgentWhat "pass" looks like
Two different outcomes depending on the row below:
<size=400%>, possibly with slightly odd-looking angle brackets‹ ›). Nothing is huge, coloured, hidden, or clickable.dev. These are the regression rows; a bug here is caused by this PR.UI surfaces to check
created by …, coordinates/world name all inert<link>inert, but the plainhttps://is still blue, clickable, and promptshosted by X - at Yinerthosted by/atmust still be bold@in chatName:)<size=400%>hugeand<link="https://evil.example.com">Verified ✔</link>🟢 Your wallet needs verification/goto 10,10https://decentraland.orgAdditional Testing Notes
…, bodies at 1000. That is expected, not a bug.‹ ›lookalikes. Expected. Straight quotes and apostrophes in ordinary prose must be untouched./gotosystem lines must still look like system lines, and ordinary URLs must still linkify.devbuild side by side.Quality Checklist
🤖 Generated with Claude Code