diff --git a/static/app/components/events/breadcrumbs/breadcrumbItemContent.tsx b/static/app/components/events/breadcrumbs/breadcrumbItemContent.tsx index 995ba8eb1d6ed8..93ec63d790dfe7 100644 --- a/static/app/components/events/breadcrumbs/breadcrumbItemContent.tsx +++ b/static/app/components/events/breadcrumbs/breadcrumbItemContent.tsx @@ -14,7 +14,7 @@ import { type RawCrumb, } from 'sentry/types/breadcrumbs'; import {defined} from 'sentry/utils'; -import {isUrl} from 'sentry/utils/string/isUrl'; +import {isValidUrl} from 'sentry/utils/string/isValidUrl'; import {usePrismTokens} from 'sentry/utils/usePrismTokens'; const DEFAULT_STRUCTURED_DATA_PROPS = { @@ -117,13 +117,13 @@ function HTTPCrumbContent({ status_code: statusCode, ...otherData } = cleanBreadcrumbData(breadcrumb?.data) ?? {}; - const isValidUrl = !meta && defined(url) && isUrl(url); + const showUrlAsLink = !meta && defined(url) && isValidUrl(url); return ( {children} {defined(method) && `${method}: `} - {isValidUrl ? ( + {showUrlAsLink ? ( openNavigateToExternalLinkModal({linkText: url})} diff --git a/static/app/components/events/eventTags/eventTagsTreeRow.tsx b/static/app/components/events/eventTags/eventTagsTreeRow.tsx index a71321d07c514b..1d327f807540c3 100644 --- a/static/app/components/events/eventTags/eventTagsTreeRow.tsx +++ b/static/app/components/events/eventTags/eventTagsTreeRow.tsx @@ -21,7 +21,7 @@ import type {DetailedProject} from 'sentry/types/project'; import {escapeIssueTagKey, generateQueryWithTag} from 'sentry/utils'; import {isEmptyObject} from 'sentry/utils/object/isEmptyObject'; import {useUpdateProject} from 'sentry/utils/project/useUpdateProject'; -import {isUrl} from 'sentry/utils/string/isUrl'; +import {isValidUrl} from 'sentry/utils/string/isValidUrl'; import {useCopyToClipboard} from 'sentry/utils/useCopyToClipboard'; import {useLocation} from 'sentry/utils/useLocation'; import {useOrganization} from 'sentry/utils/useOrganization'; @@ -303,7 +303,7 @@ function EventTagsTreeRowDropdown({ { key: 'external-link', label: t('Visit this external link'), - hidden: !isUrl(content.value), + hidden: !isValidUrl(content.value), onAction: () => { openNavigateToExternalLinkModal({linkText: content.value}); }, @@ -418,7 +418,7 @@ function EventTagsTreeValue({ tagValue = defaultValue; } - return isUrl(content.value) ? ( + return isValidUrl(content.value) ? ( { diff --git a/static/app/components/events/interfaces/crashContent/exception/mechanism.tsx b/static/app/components/events/interfaces/crashContent/exception/mechanism.tsx index 3db1ce6e8b82fd..4785572182fb0e 100644 --- a/static/app/components/events/interfaces/crashContent/exception/mechanism.tsx +++ b/static/app/components/events/interfaces/crashContent/exception/mechanism.tsx @@ -12,7 +12,7 @@ import {Pills} from 'sentry/components/pills'; import {IconOpen} from 'sentry/icons'; import {t} from 'sentry/locale'; import type {StackTraceMechanism} from 'sentry/types/stacktrace'; -import {isUrl} from 'sentry/utils/string/isUrl'; +import {isValidUrl} from 'sentry/utils/string/isValidUrl'; type Props = { data: StackTraceMechanism; @@ -24,7 +24,7 @@ export function Mechanism({data: mechanism, meta: mechanismMeta}: Props) { const {errno, signal, mach_exception} = meta; - const linkElement = help_link && isUrl(help_link) && ( + const linkElement = help_link && isValidUrl(help_link) && ( diff --git a/static/app/components/events/interfaces/crashContent/exception/utils.tsx b/static/app/components/events/interfaces/crashContent/exception/utils.tsx index 56bae8cd302c1c..163331d150747e 100644 --- a/static/app/components/events/interfaces/crashContent/exception/utils.tsx +++ b/static/app/components/events/interfaces/crashContent/exception/utils.tsx @@ -6,7 +6,7 @@ import {ExternalLink} from '@sentry/scraps/link'; import {openNavigateToExternalLinkModal} from 'sentry/actionCreators/modal'; import {IconOpen} from 'sentry/icons'; -import {isUrl} from 'sentry/utils/string/isUrl'; +import {isValidUrl} from 'sentry/utils/string/isValidUrl'; interface RenderLinksInTextProps { exceptionText: string; @@ -38,10 +38,10 @@ export const renderLinksInText = ({ const elements = parts.flatMap((part, index) => { const url = urls[index]!; - const isUrlValid = isUrl(url); + const linkIsValid = isValidUrl(url); let link: ReactElement | undefined; - if (isUrlValid) { + if (linkIsValid) { link = ( ) => { event.stopPropagation(); - if (isPotentiallyThirdParty && frame.absPath && isUrl(frame.absPath)) { + if (isPotentiallyThirdParty && frame.absPath && isValidUrl(frame.absPath)) { event.preventDefault(); openNavigateToExternalLinkModal({linkText: frame.absPath}); } @@ -164,7 +164,7 @@ export function DefaultTitle({ ); } - if (frame.absPath && isUrl(frame.absPath)) { + if (frame.absPath && isValidUrl(frame.absPath)) { title.push( diff --git a/static/app/components/events/interfaces/frame/utils.tsx b/static/app/components/events/interfaces/frame/utils.tsx index a53f22cbe50e86..9a21459b7650ee 100644 --- a/static/app/components/events/interfaces/frame/utils.tsx +++ b/static/app/components/events/interfaces/frame/utils.tsx @@ -5,7 +5,7 @@ import type {PlatformKey} from 'sentry/types/project'; import type {StacktraceType} from 'sentry/types/stacktrace'; import {defined} from 'sentry/utils'; import {isEmptyObject} from 'sentry/utils/object/isEmptyObject'; -import {isUrl} from 'sentry/utils/string/isUrl'; +import {isValidUrl} from 'sentry/utils/string/isValidUrl'; import {safeURL} from 'sentry/utils/url/safeURL'; export function trimPackage(pkg: string) { @@ -182,7 +182,7 @@ export function isPotentiallyThirdPartyFrame(frame: Frame, event: Event): boolea const eventOrigin = extractEventOrigin(event); - if (!frame.absPath || !isUrl(eventOrigin) || !isUrl(frame.absPath)) { + if (!frame.absPath || !isValidUrl(eventOrigin) || !isValidUrl(frame.absPath)) { return false; } diff --git a/static/app/components/events/interfaces/request/index.tsx b/static/app/components/events/interfaces/request/index.tsx index 40f3864d90f816..6684623b48906d 100644 --- a/static/app/components/events/interfaces/request/index.tsx +++ b/static/app/components/events/interfaces/request/index.tsx @@ -23,7 +23,7 @@ import {t, tct} from 'sentry/locale'; import type {EntryRequest, Event} from 'sentry/types/event'; import {EntryType} from 'sentry/types/event'; import {defined} from 'sentry/utils'; -import {isUrl} from 'sentry/utils/string/isUrl'; +import {isValidUrl} from 'sentry/utils/string/isValidUrl'; import {SectionKey} from 'sentry/views/issueDetails/streamline/context'; import {FoldSection} from 'sentry/views/issueDetails/streamline/foldSection'; @@ -133,7 +133,7 @@ export function Request({data, event}: RequestProps) { let fullUrl = getFullUrl(data); - if (!isUrl(fullUrl)) { + if (!isValidUrl(fullUrl)) { // Check if the url passed in is a safe url to avoid XSS fullUrl = undefined; } diff --git a/static/app/components/stackTrace/frame/frameHeader.tsx b/static/app/components/stackTrace/frame/frameHeader.tsx index 4e41d40fd9fdf5..905551cd5710ef 100644 --- a/static/app/components/stackTrace/frame/frameHeader.tsx +++ b/static/app/components/stackTrace/frame/frameHeader.tsx @@ -16,7 +16,7 @@ import {t} from 'sentry/locale'; import type {Event, Frame} from 'sentry/types/event'; import type {PlatformKey} from 'sentry/types/project'; import {defined} from 'sentry/utils'; -import {isUrl} from 'sentry/utils/string/isUrl'; +import {isValidUrl} from 'sentry/utils/string/isValidUrl'; function getFrameDisplayPath(frame: Frame, platform: PlatformKey, event: Event) { const framePlatform = getPlatform(frame.platform, platform); @@ -197,7 +197,8 @@ function FrameLocationTooltip({ frame: Frame; frameDisplayPath: string; }) { - const externalUrl = frame.absPath && isUrl(frame.absPath) ? frame.absPath : undefined; + const externalUrl = + frame.absPath && isValidUrl(frame.absPath) ? frame.absPath : undefined; const absPath = frame.absPath && frame.absPath !== frameDisplayPath && !externalUrl ? frame.absPath diff --git a/static/app/components/structuredEventData/linkHint.tsx b/static/app/components/structuredEventData/linkHint.tsx index 4a913e6f35eb07..83c7766b81a59e 100644 --- a/static/app/components/structuredEventData/linkHint.tsx +++ b/static/app/components/structuredEventData/linkHint.tsx @@ -6,7 +6,7 @@ import {openNavigateToExternalLinkModal} from 'sentry/actionCreators/modal'; import {IconOpen} from 'sentry/icons'; import {t} from 'sentry/locale'; import {defined} from 'sentry/utils'; -import {isUrl} from 'sentry/utils/string/isUrl'; +import {isValidUrl} from 'sentry/utils/string/isValidUrl'; interface Props { value: string; @@ -14,7 +14,7 @@ interface Props { } export function LinkHint({meta, value}: Props) { - if (!isUrl(value) || defined(meta)) { + if (!isValidUrl(value) || defined(meta)) { return null; } diff --git a/static/app/utils/discover/fieldRenderers.spec.tsx b/static/app/utils/discover/fieldRenderers.spec.tsx index 3d17c058ec9151..80ac2c51bcffe9 100644 --- a/static/app/utils/discover/fieldRenderers.spec.tsx +++ b/static/app/utils/discover/fieldRenderers.spec.tsx @@ -6,13 +6,18 @@ import {WidgetFixture} from 'sentry-fixture/widget'; import {initializeOrg} from 'sentry-test/initializeOrg'; import {act, render, screen, waitFor} from 'sentry-test/reactTestingLibrary'; +import {openNavigateToExternalLinkModal} from 'sentry/actionCreators/modal'; import {ProjectsStore} from 'sentry/stores/projectsStore'; import {EventView} from 'sentry/utils/discover/eventView'; -import {getFieldRenderer} from 'sentry/utils/discover/fieldRenderers'; +import {getFieldRenderer, renderUrlCellValue} from 'sentry/utils/discover/fieldRenderers'; import {SPAN_OP_RELATIVE_BREAKDOWN_FIELD} from 'sentry/utils/discover/fields'; import {WidgetType, type DashboardFilters} from 'sentry/views/dashboards/types'; import {SpanFields} from 'sentry/views/insights/types'; +jest.mock('sentry/actionCreators/modal', () => ({ + openNavigateToExternalLinkModal: jest.fn(), +})); + const theme = ThemeFixture(); describe('getFieldRenderer', () => { @@ -97,6 +102,22 @@ describe('getFieldRenderer', () => { expect(screen.getByText(data.url)).toBeInTheDocument(); }); + it('propagates URL clicks to parent cell actions without opening the modal', () => { + const onClick = jest.fn(); + const value = 'https://example.com'; + + render( +
+ {renderUrlCellValue(value)} +
+ ); + + screen.getByRole('link', {name: value}).click(); + + expect(onClick).toHaveBeenCalled(); + expect(openNavigateToExternalLinkModal).not.toHaveBeenCalled(); + }); + it('can render empty string fields', () => { const renderer = getFieldRenderer('url', {url: 'string'}); data.url = ''; @@ -112,6 +133,54 @@ describe('getFieldRenderer', () => { expect(screen.getByText('(empty string)')).toBeInTheDocument(); }); + it('can render numeric values with the string renderer', () => { + const renderer = getFieldRenderer('numeric', {numeric: 'string'}); + + render( + renderer(data, { + location, + navigate: jest.fn(), + organization, + theme, + }) as React.ReactElement + ); + + expect(screen.getByText(data.numeric)).toBeInTheDocument(); + }); + + it('renders the last element when string fields are stored as arrays', () => { + const renderer = getFieldRenderer('url', {url: 'string'}); + data.url = ['https://example.com/old', 'https://example.com/new']; + + render( + renderer(data, { + location, + navigate: jest.fn(), + organization, + theme, + }) as React.ReactElement + ); + + expect(screen.getByText('https://example.com/new')).toBeInTheDocument(); + }); + + it('renders the last non-url element when string fields are stored as arrays', () => { + const renderer = getFieldRenderer('url', {url: 'string'}); + data.url = ['old-value', 'latest-value']; + + render( + renderer(data, { + location, + navigate: jest.fn(), + organization, + theme, + }) as React.ReactElement + ); + + expect(screen.getByText('latest-value')).toBeInTheDocument(); + expect(document.querySelector('a')).not.toBeInTheDocument(); + }); + it('renders gen_ai.output.messages assistant content', () => { const renderer = getFieldRenderer( SpanFields.GEN_AI_OUTPUT_MESSAGES, diff --git a/static/app/utils/discover/fieldRenderers.tsx b/static/app/utils/discover/fieldRenderers.tsx index a2dd7e2bdc3fd3..01f1516794da10 100644 --- a/static/app/utils/discover/fieldRenderers.tsx +++ b/static/app/utils/discover/fieldRenderers.tsx @@ -56,7 +56,7 @@ import {toPercent} from 'sentry/utils/number/toPercent'; import {generateProfileFlamechartRouteWithQuery} from 'sentry/utils/profiling/routes'; import {Projects} from 'sentry/utils/projects'; import {decodeScalar} from 'sentry/utils/queryString'; -import {isUrl} from 'sentry/utils/string/isUrl'; +import {isValidUrl} from 'sentry/utils/string/isValidUrl'; import type {ReactRouter3Navigate} from 'sentry/utils/useNavigate'; import {type DashboardFilters, type Widget} from 'sentry/views/dashboards/types'; import { @@ -194,6 +194,26 @@ export function nullableValue(value: string | null): string | React.ReactElement } } +/** + * Renders navigable URLs as external links. + * Invalid/template URLs render as plain text without an anchor tag. + */ +export function renderUrlCellValue(value: unknown): React.ReactNode { + if (value === null || value === undefined) { + return emptyValue; + } + + if (typeof value !== 'string') { + return typeof value === 'number' || typeof value === 'boolean' ? value : emptyValue; + } + + if (!isValidUrl(value)) { + return nullableValue(value); + } + + return {value}; +} + // TODO: Remove this, use `SIZE_UNIT_MULTIPLIERS` instead export const SIZE_UNITS = { bit: 1 / 8, @@ -356,32 +376,23 @@ export const FIELD_FORMATTERS: FieldFormatters = { renderFunc: (field, data) => { // Some fields have long arrays in them, only show the tail of the data. const value = Array.isArray(data[field]) - ? data[field].slice(-1) + ? (data[field].at(-1) ?? emptyValue) : defined(data[field]) ? data[field] : emptyValue; - if (isUrl(value)) { - return ( - - - - {value} - - - - ); - } - - if (value && typeof value === 'string') { - return ( - - {nullableValue(value)} - - ); - } - - return {nullableValue(value)}; + return ( + + + {renderUrlCellValue(value)} + + + ); }, }, array: { @@ -586,13 +597,7 @@ const SPECIAL_FIELDS: Record = { showOnlyOnOverflow maxWidth={400} > - - {isUrl(value) ? ( - {value} - ) : ( - nullableValue(value) - )} - + {renderUrlCellValue(value)} ); }, diff --git a/static/app/utils/string/isValidUrl.spec.tsx b/static/app/utils/string/isValidUrl.spec.tsx new file mode 100644 index 00000000000000..1424508fe7670b --- /dev/null +++ b/static/app/utils/string/isValidUrl.spec.tsx @@ -0,0 +1,27 @@ +import {isValidUrl} from 'sentry/utils/string/isValidUrl'; + +describe('isValidUrl', () => { + it.each([ + 'https://example.com/path', + 'http://localhost:8080/api', + 'http://my-service/api', + 'http://127.0.0.1/path', + 'http://[::1]/path', + ])('returns true for navigable URL %s', url => { + expect(isValidUrl(url)).toBe(true); + }); + + it.each([ + 'not-a-url', + 'ftp://example.com/path', + 'http://*/v1/api/auth/register', + 'http://{host}/v1/api/auth/register', + ])('returns false for non-navigable URL %s', url => { + expect(isValidUrl(url)).toBe(false); + }); + + it('returns false for javascript URLs', () => { + // eslint-disable-next-line no-script-url + expect(isValidUrl('javascript:void(0)')).toBe(false); + }); +}); diff --git a/static/app/utils/string/isValidUrl.tsx b/static/app/utils/string/isValidUrl.tsx index ede71397831dde..9e90ddb42c6d34 100644 --- a/static/app/utils/string/isValidUrl.tsx +++ b/static/app/utils/string/isValidUrl.tsx @@ -1,12 +1,27 @@ import {isUrl} from 'sentry/utils/string/isUrl'; -export function isValidUrl(str: any): boolean { +// URL.parse accepts hostnames like `*` or `{host}` that are not real navigable hosts. +// Reject wildcard/template characters so we render these as plain text instead of links. +const INVALID_HOSTNAME_CHARS = /[*{}]/; +const INVALID_AUTHORITY = /^https?:\/\/[^/?#]*[*{}]/; + +export function isValidUrl(str: unknown): boolean { + if (typeof str !== 'string') { + return false; + } + // javascript:void(0) is a valid url so ensure it starts with http:// or https:// if (!isUrl(str)) { return false; } + + if (INVALID_AUTHORITY.test(str)) { + return false; + } + try { - return !!new URL(str); + const {hostname} = new URL(str); + return !!hostname && !INVALID_HOSTNAME_CHARS.test(hostname); } catch { return false; } diff --git a/static/app/views/discover/table/cellAction.spec.tsx b/static/app/views/discover/table/cellAction.spec.tsx index 82172e30446176..1c37c02aecd016 100644 --- a/static/app/views/discover/table/cellAction.spec.tsx +++ b/static/app/views/discover/table/cellAction.spec.tsx @@ -191,6 +191,95 @@ describe('Discover -> CellAction', () => { ); }); + it('does not offer link actions for wildcard URLs', async () => { + const urlView = EventView.fromLocation( + LocationFixture({ + query: { + ...location.query, + field: ['url'], + }, + }) + ); + + render( + + http://*/v1/api/auth/register + + ); + + await openMenu(); + + expect( + screen.queryByRole('menuitemradio', {name: 'Open external link'}) + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('menuitemradio', {name: 'Open link'}) + ).not.toBeInTheDocument(); + }); + + it('does not offer open link for invalid external anchors', async () => { + const urlView = EventView.fromLocation( + LocationFixture({ + query: { + ...location.query, + field: ['url'], + }, + }) + ); + const wildcardUrl = 'http://*/v1/api/auth/register'; + + render( + + {wildcardUrl} + + ); + + await openMenu(); + + expect( + screen.queryByRole('menuitemradio', {name: 'Open external link'}) + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('menuitemradio', {name: 'Open link'}) + ).not.toBeInTheDocument(); + }); + + it('uses the full anchor href for external link actions', async () => { + const urlView = EventView.fromLocation( + LocationFixture({ + query: { + ...location.query, + field: ['url'], + }, + }) + ); + const fullUrl = 'https://example.com/v1/api/auth/register'; + + render( + + /v1/api/auth/register + + ); + + await openMenu(); + + expect( + screen.getByRole('menuitemradio', {name: 'Open external link'}) + ).toHaveAttribute('href', fullUrl); + }); + it('error.handled with null adds condition', async () => { renderComponent({ eventView: view, diff --git a/static/app/views/discover/table/cellAction.tsx b/static/app/views/discover/table/cellAction.tsx index 3f2af1bb344f49..5dbc0d89dbfa3f 100644 --- a/static/app/views/discover/table/cellAction.tsx +++ b/static/app/views/discover/table/cellAction.tsx @@ -19,11 +19,33 @@ import { import {getDuration} from 'sentry/utils/duration/getDuration'; import {FieldKey} from 'sentry/utils/fields'; import {isUrl} from 'sentry/utils/string/isUrl'; +import {isValidUrl} from 'sentry/utils/string/isValidUrl'; import type {MutableSearch} from 'sentry/utils/tokenizeSearch'; import {stripURLOrigin} from 'sentry/utils/url/stripURLOrigin'; import type {TableColumn} from './types'; +/** + * Returns true when href should surface the in-app "Open link" cell action. + * External http(s) URLs must not be treated as in-app routes after stripping the origin. + */ +function isInternalNavigationTarget(target: string): boolean { + if (target.startsWith('/') && !target.startsWith('//')) { + return true; + } + + if (!isUrl(target)) { + return false; + } + + try { + const url = new URL(target); + return url.origin === window.location.origin; + } catch { + return false; + } +} + export enum Actions { ADD = 'add', EXCLUDE = 'exclude', @@ -212,7 +234,9 @@ function makeCellActions({ return null; } - let value = dataRow[column.name]; + let value = dataRow[column.key]; + const externalLinkTarget = + to && !isInternalNavigationTarget(to) && isValidUrl(to) ? to : undefined; // error.handled is a strange field where null = true. if ( @@ -238,12 +262,14 @@ function makeCellActions({ onAction: () => handleCellAction(action, value!), to: action === Actions.OPEN_INTERNAL_LINK && to ? stripURLOrigin(to) : undefined, externalHref: - action === Actions.OPEN_EXTERNAL_LINK ? (value as string) : undefined, + action === Actions.OPEN_EXTERNAL_LINK + ? (externalLinkTarget ?? (value as string)) + : undefined, }); } } - if (to && to !== value) { + if (to && to !== value && isInternalNavigationTarget(to)) { const field = String(column.key); addMenuItem(Actions.OPEN_INTERNAL_LINK, getInternalLinkActionLabel(field)); } @@ -301,7 +327,7 @@ function makeCellActions({ ); } - if (isUrl(value)) { + if (externalLinkTarget || isValidUrl(value)) { addMenuItem(Actions.OPEN_EXTERNAL_LINK, t('Open external link')); } @@ -411,7 +437,13 @@ export function CellAction({ const aTags = e.currentTarget.getElementsByTagName('a'); if (aTags?.[0]) { const href = aTags[0].href; - setTarget(href); + if (isInternalNavigationTarget(href) || isValidUrl(href)) { + setTarget(href); + } else { + setTarget(undefined); + } + } else { + setTarget(undefined); } e.preventDefault(); } diff --git a/static/app/views/explore/components/traceItemAttributes/attributesTree.tsx b/static/app/views/explore/components/traceItemAttributes/attributesTree.tsx index 8d756de0420c06..c2ecb13ca52c8b 100644 --- a/static/app/views/explore/components/traceItemAttributes/attributesTree.tsx +++ b/static/app/views/explore/components/traceItemAttributes/attributesTree.tsx @@ -11,7 +11,7 @@ import {defined} from 'sentry/utils'; import type {EventsMetaType} from 'sentry/utils/discover/eventView'; import {type RenderFunctionBaggage} from 'sentry/utils/discover/fieldRenderers'; import {isEmptyObject} from 'sentry/utils/object/isEmptyObject'; -import {isUrl} from 'sentry/utils/string/isUrl'; +import {isValidUrl} from 'sentry/utils/string/isValidUrl'; import {useCopyToClipboard} from 'sentry/utils/useCopyToClipboard'; import {prettifyAttributeName} from 'sentry/views/explore/components/traceItemAttributes/utils'; import type {TraceItemResponseAttribute} from 'sentry/views/explore/hooks/useTraceItemDetails'; @@ -418,7 +418,7 @@ function AttributesTreeRowDropdown({ ]; // Add external link option if the value is a URL - if (isUrl(String(content.value))) { + if (isValidUrl(String(content.value))) { items.push({ key: 'external-link', label: t('Visit this external link'), diff --git a/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.tsx b/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.tsx index 783e2f68a2a58c..b0485507f9a786 100644 --- a/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.tsx +++ b/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.tsx @@ -5,7 +5,7 @@ import {openNavigateToExternalLinkModal} from 'sentry/actionCreators/modal'; import {ExternalLink} from 'sentry/components/links/externalLink'; import {StructuredEventData} from 'sentry/components/structuredEventData'; import {type RenderFunctionBaggage} from 'sentry/utils/discover/fieldRenderers'; -import {isUrl} from 'sentry/utils/string/isUrl'; +import {isValidUrl} from 'sentry/utils/string/isValidUrl'; import {AnnotatedAttributeTooltip} from 'sentry/views/explore/components/annotatedAttributeTooltip'; import {InlineJsonHighlight} from 'sentry/views/explore/components/traceItemAttributes/inlineJsonHighlight'; import {getAttributeItem} from 'sentry/views/explore/components/traceItemAttributes/utils'; @@ -95,7 +95,7 @@ export function AttributesTreeValue ); } - if (isUrl(value)) { + if (isValidUrl(value)) { return ( { ); expect(screen.getByTestId('platform-icon-javascript')).toBeInTheDocument(); }); + + it.each([ + ['span.name', 6], + ['span.description', 5], + ])( + 'renders wildcard URL %s as plain text without anchor or link actions', + async (field, columnIndex) => { + const wildcardUrl = 'http://*/v1/api/auth/register'; + render( + + + , + {organization} + ); + + expect(screen.getByText(wildcardUrl)).toBeInTheDocument(); + expect(document.querySelector('a')).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', {name: 'Actions'})); + + expect( + screen.queryByRole('menuitemradio', {name: 'Open external link'}) + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('menuitemradio', {name: 'Open link'}) + ).not.toBeInTheDocument(); + } + ); + + it.each([ + ['span.name', 6], + ['span.description', 5], + ])('uses the full URL for %s external link actions', async (field, columnIndex) => { + const fullUrl = 'https://example.com/v1/api/auth/register'; + render( + + + , + {organization} + ); + + expect(screen.getByRole('link', {name: fullUrl})).toHaveAttribute('href', fullUrl); + + await userEvent.click(screen.getByRole('button', {name: 'Actions'})); + + expect( + screen.getByRole('menuitemradio', {name: 'Open external link'}) + ).toHaveAttribute('href', fullUrl); + }); }); diff --git a/static/app/views/explore/tables/fieldRenderer.tsx b/static/app/views/explore/tables/fieldRenderer.tsx index 26d36e3d382139..406fbab91000f4 100644 --- a/static/app/views/explore/tables/fieldRenderer.tsx +++ b/static/app/views/explore/tables/fieldRenderer.tsx @@ -3,7 +3,7 @@ import {useTheme} from '@emotion/react'; import styled from '@emotion/styled'; import {Container as ScrapsContainer} from '@sentry/scraps/layout'; -import {ExternalLink, Link} from '@sentry/scraps/link'; +import {Link} from '@sentry/scraps/link'; import {Text} from '@sentry/scraps/text'; import {Tooltip} from '@sentry/scraps/tooltip'; @@ -17,11 +17,14 @@ import {defined} from 'sentry/utils'; import type {TableDataRow} from 'sentry/utils/discover/discoverQuery'; import type {EventData, MetaType} from 'sentry/utils/discover/eventView'; import {EventView} from 'sentry/utils/discover/eventView'; -import {getFieldRenderer, nullableValue} from 'sentry/utils/discover/fieldRenderers'; +import { + getFieldRenderer, + nullableValue, + renderUrlCellValue, +} from 'sentry/utils/discover/fieldRenderers'; import {Container} from 'sentry/utils/discover/styles'; import {generateLinkToEventInTraceView} from 'sentry/utils/discover/urls'; import {getShortEventId} from 'sentry/utils/events'; -import {isValidUrl} from 'sentry/utils/string/isValidUrl'; import {MutableSearch} from 'sentry/utils/tokenizeSearch'; import {useLocation} from 'sentry/utils/useLocation'; import {useNavigate} from 'sentry/utils/useNavigate'; @@ -365,13 +368,7 @@ function spanDescriptionRenderFunc(field: string, projects: Record )} - - {isValidUrl(value) ? ( - {value} - ) : ( - nullableValue(value) - )} - + {renderUrlCellValue(value)} diff --git a/static/app/views/issueDetails/groupTags/tagDetailsDrawerContent.tsx b/static/app/views/issueDetails/groupTags/tagDetailsDrawerContent.tsx index 5bcf87f7a3ab1d..1429711a44bbe3 100644 --- a/static/app/views/issueDetails/groupTags/tagDetailsDrawerContent.tsx +++ b/static/app/views/issueDetails/groupTags/tagDetailsDrawerContent.tsx @@ -28,7 +28,7 @@ import type {Group, Tag, TagValue} from 'sentry/types/group'; import {escapeIssueTagKey, generateQueryWithTag, percent} from 'sentry/utils'; import {selectJsonWithHeaders} from 'sentry/utils/api/apiOptions'; import {SavedQueryDatasets} from 'sentry/utils/discover/types'; -import {isUrl} from 'sentry/utils/string/isUrl'; +import {isValidUrl} from 'sentry/utils/string/isValidUrl'; import {useCopyToClipboard} from 'sentry/utils/useCopyToClipboard'; import {useLocation} from 'sentry/utils/useLocation'; import {useNavigate} from 'sentry/utils/useNavigate'; @@ -256,7 +256,7 @@ function TagDetailsValue({ return ( {valueComponent} - {isUrl(tagValue.value) && ( + {isValidUrl(tagValue.value) && ( } diff --git a/static/app/views/performance/newTraceDetails/traceDrawer/details/transaction/sections/request.tsx b/static/app/views/performance/newTraceDetails/traceDrawer/details/transaction/sections/request.tsx index 74c0701af7ed74..407cb3e5315f74 100644 --- a/static/app/views/performance/newTraceDetails/traceDrawer/details/transaction/sections/request.tsx +++ b/static/app/views/performance/newTraceDetails/traceDrawer/details/transaction/sections/request.tsx @@ -20,7 +20,7 @@ import {t} from 'sentry/locale'; import {EntryType, type EntryRequest, type EventTransaction} from 'sentry/types/event'; import type {Meta} from 'sentry/types/group'; import {defined} from 'sentry/utils'; -import {isUrl} from 'sentry/utils/string/isUrl'; +import {isValidUrl} from 'sentry/utils/string/isValidUrl'; import { TraceDrawerComponents, type SectionCardKeyValueList, @@ -48,7 +48,7 @@ export function Request({event}: {event: EventTransaction}) { let fullUrl = getFullUrl(data); - if (!isUrl(fullUrl)) { + if (!isValidUrl(fullUrl)) { // Check if the url passed in is a safe url to avoid XSS fullUrl = undefined; }