Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
} from 'sentry/types/breadcrumbs';
import {defined} from 'sentry/utils';
import {ellipsize} from 'sentry/utils/string/ellipsize';
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 = {
Expand Down Expand Up @@ -133,13 +133,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 (
<Fragment>
{children}
<BreadcrumbText>
{defined(method) && `${method}: `}
{isValidUrl ? (
{showUrlAsLink ? (
<Link
role="link"
onClick={() => openNavigateToExternalLinkModal({linkText: url})}
Expand Down
6 changes: 3 additions & 3 deletions static/app/components/events/eventTags/eventTagsTreeRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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});
},
Expand Down Expand Up @@ -418,7 +418,7 @@ function EventTagsTreeValue({
tagValue = defaultValue;
}

return isUrl(content.value) ? (
return isValidUrl(content.value) ? (
<TagLinkText>
<ExternalLink
onClick={e => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) && (
<StyledExternalLink href={help_link}>
<IconOpen size="xs" />
</StyledExternalLink>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 = (
<ExternalLink
key={`link-${index}`}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import type {Frame} from 'sentry/types/event';
import type {Meta} from 'sentry/types/group';
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';

/**
* File paths can get very long, so increase it for the tooltips within this component.
Expand Down Expand Up @@ -60,7 +60,7 @@ export function DefaultTitle({

const handleExternalLink = (event: React.MouseEvent<HTMLAnchorElement>) => {
event.stopPropagation();
if (isPotentiallyThirdParty && frame.absPath && isUrl(frame.absPath)) {
if (isPotentiallyThirdParty && frame.absPath && isValidUrl(frame.absPath)) {
event.preventDefault();
openNavigateToExternalLinkModal({linkText: frame.absPath});
}
Expand Down Expand Up @@ -164,7 +164,7 @@ export function DefaultTitle({
);
}

if (frame.absPath && isUrl(frame.absPath)) {
if (frame.absPath && isValidUrl(frame.absPath)) {
title.push(
<StyledExternalLink href={frame.absPath} key="share" onClick={handleExternalLink}>
<IconOpen size="xs" />
Expand Down
4 changes: 2 additions & 2 deletions static/app/components/events/interfaces/frame/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
}

Expand Down
4 changes: 2 additions & 2 deletions static/app/components/events/interfaces/request/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
}
Expand Down
5 changes: 3 additions & 2 deletions static/app/components/stackTrace/frame/frameHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions static/app/components/structuredEventData/linkHint.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@ 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;
meta?: Record<any, any>;
}

export function LinkHint({meta, value}: Props) {
if (!isUrl(value) || defined(meta)) {
if (!isValidUrl(value) || defined(meta)) {
return null;
}

Expand Down
66 changes: 37 additions & 29 deletions static/app/utils/discover/fieldRenderers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {Button} from '@sentry/scraps/button';
import {ExternalLink, Link} from '@sentry/scraps/link';
import {Tooltip} from '@sentry/scraps/tooltip';

import {openNavigateToExternalLinkModal} from 'sentry/actionCreators/modal';
import {Count} from 'sentry/components/count';
import {deviceNameMapper} from 'sentry/components/deviceName';
import type {MenuItemProps} from 'sentry/components/dropdownMenu';
Expand Down Expand Up @@ -57,7 +58,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 DashboardFilters, type Widget} from 'sentry/views/dashboards/types';
import {
findLinkedDashboardForField,
Expand Down Expand Up @@ -193,6 +194,28 @@ export function nullableValue(value: string | null): string | React.ReactElement
}
}

/**
* Renders navigable URLs as external links that open the external-link modal.
* Invalid/template URLs render as plain text without an anchor tag.
*/
export function renderUrlCellValue(value: unknown): React.ReactNode {
if (typeof value !== 'string' || !isValidUrl(value)) {
return nullableValue(typeof value === 'string' ? value : null);
}

return (
<ExternalLink
href={value}
onClick={e => {
e.preventDefault();
openNavigateToExternalLinkModal({linkText: value});
}}
>
{value}
</ExternalLink>
);
}

// TODO: Remove this, use `SIZE_UNIT_MULTIPLIERS` instead
export const SIZE_UNITS = {
bit: 1 / 8,
Expand Down Expand Up @@ -360,27 +383,18 @@ export const FIELD_FORMATTERS: FieldFormatters = {
? data[field]
: emptyValue;

if (isUrl(value)) {
return (
<Tooltip title={value} containerDisplayMode="block" showOnlyOnOverflow>
<Container>
<ExternalLink href={value} data-test-id="group-tag-url">
{value}
</ExternalLink>
</Container>
</Tooltip>
);
}

if (value && typeof value === 'string') {
return (
<Tooltip title={value} containerDisplayMode="block" showOnlyOnOverflow>
<Container>{nullableValue(value)}</Container>
</Tooltip>
);
}

return <Container>{nullableValue(value)}</Container>;
return (
<Tooltip
title={value}
showOnlyOnOverflow
containerDisplayMode="block"
disabled={typeof value !== 'string'}
>
<Container>
<span data-test-id="group-tag-url">{renderUrlCellValue(value)}</span>
</Container>
Comment thread
cursor[bot] marked this conversation as resolved.
</Tooltip>
);
},
},
array: {
Expand Down Expand Up @@ -585,13 +599,7 @@ const SPECIAL_FIELDS: Record<string, SpecialField> = {
showOnlyOnOverflow
maxWidth={400}
>
<Container>
{isUrl(value) ? (
<ExternalLink href={value}>{value}</ExternalLink>
) : (
nullableValue(value)
)}
</Container>
<Container>{renderUrlCellValue(value)}</Container>
</Tooltip>
);
},
Expand Down
27 changes: 27 additions & 0 deletions static/app/utils/string/isValidUrl.spec.tsx
Original file line number Diff line number Diff line change
@@ -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);
});
});
19 changes: 17 additions & 2 deletions static/app/utils/string/isValidUrl.tsx
Original file line number Diff line number Diff line change
@@ -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;
}
Expand Down
Loading
Loading