Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions components/Profile/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { ExplorerTooltip } from "@components/ExplorerTooltip";
import ShowMoreRichText from "@components/ShowMoreRichText";
import { EnsIdentity } from "@lib/api/types/get-ens";
import { formatAddress } from "@lib/utils";
import { formatAddress, sanitizeExternalUrl } from "@lib/utils";
import {
Box,
Button,
Expand Down Expand Up @@ -67,6 +67,11 @@ const Index = ({
}
};

// ENS-supplied URL — must be validated before rendering as an external link
// so a malicious orchestrator can't smuggle in `javascript:` / `data:` /
// schemeless hrefs via their ENS `url` text record.
const safeIdentityUrl = sanitizeExternalUrl(identity?.url);

return (
<Box css={{ marginBottom: "$3" }}>
<Flex
Expand Down Expand Up @@ -272,14 +277,14 @@ const Index = ({
)}
</Flex>
<Flex align="center" css={{ flexWrap: "wrap" }}>
{identity?.url && (
{safeIdentityUrl && (
<A
variant="contrast"
css={{ fontSize: "$2", minWidth: 0, maxWidth: "100%" }}
href={identity.url}
href={safeIdentityUrl}
target="__blank"
rel="noopener noreferrer"
title={identity.url}
title={safeIdentityUrl}
>
<Flex
align="center"
Expand All @@ -301,7 +306,7 @@ const Index = ({
whiteSpace: "nowrap",
}}
>
{identity.url.replace(/(^\w+:|^)\/\//, "")}
{safeIdentityUrl.replace(/(^\w+:|^)\/\//, "")}
</Box>
</Flex>
</A>
Expand Down
31 changes: 31 additions & 0 deletions lib/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,37 @@ export const isImageUrl = (url: string): boolean => {
return /\.(jpg|jpeg|png|gif|webp)$/i.test(url);
};

/**
* Sanitize a user-supplied URL for use as an external `<a href>`.
*
* Auto-prefixes `https://` when the input has no scheme (so values like
* `evil.com/path` aren't treated as relative links), then validates that the
* resulting URL parses and uses an `http:` or `https:` protocol. Anything
* else (e.g. `javascript:`, `data:`, malformed input) is rejected.
*
* @param url - The user-supplied URL.
* @returns The sanitized absolute URL, or `null` if it is unsafe / invalid.
*/
export const sanitizeExternalUrl = (
url: string | null | undefined
): string | null => {
if (!url) return null;
const trimmed = url.trim();
if (!trimmed) return null;
const withScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed)
? trimmed
: `https://${trimmed}`;
try {
const parsed = new URL(withScheme);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
Comment on lines +302 to +307
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

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

sanitizeExternalUrl currently prepends https:// for any input without a detected scheme, but this produces incorrect results for protocol-relative URLs like //example.com/path (it becomes https:////example.com/path, which parses with an empty host and doesn’t reliably become an external link). Handle the //... case explicitly (e.g., prefix with https:) and consider rejecting other relative-leading inputs (like /..., #..., ?...) or requiring parsed.hostname to be non-empty to ensure the result is truly an absolute external URL.

Suggested change
const withScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed)
? trimmed
: `https://${trimmed}`;
try {
const parsed = new URL(withScheme);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
if (/^[/?#]/.test(trimmed) && !trimmed.startsWith("//")) return null;
const withScheme = trimmed.startsWith("//")
? `https:${trimmed}`
: /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed)
? trimmed
: `https://${trimmed}`;
try {
const parsed = new URL(withScheme);
if (
(parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
!parsed.hostname
) {

Copilot uses AI. Check for mistakes.
return null;
}
return parsed.toString();
} catch {
return null;
}
};
Comment on lines +296 to +314
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

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

There are existing Jest unit tests for lib/utils (see lib/utils.test.ts), but the new sanitizeExternalUrl behavior isn’t covered. Add focused tests for allowed inputs (http/https + schemeless hostnames that get https:// prefixed) and rejected inputs (javascript:, data:, malformed URLs, etc.) to prevent regressions.

Copilot uses AI. Check for mistakes.

/**
* Shorten an Ethereum address for display.
* @param address - The address to shorten.
Expand Down
Loading