Skip to content

Commit 90e476d

Browse files
authored
fix: refactor origin check to cover the full spec (#5292)
1 parent f739f3b commit 90e476d

5 files changed

Lines changed: 564 additions & 26 deletions

File tree

.changeset/shaggy-spiders-cheer.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@reown/appkit-controllers': patch
3+
---
4+
5+
Fixes issue where origin check would fail fordomains with nested wildcards
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
/**
2+
* Parses a string as a URL.
3+
* @param value - The string to parse.
4+
* @returns The parsed URL object or null if invalid.
5+
*/
6+
export function parseUrl(value: string): URL | null {
7+
try {
8+
return new URL(value)
9+
} catch {
10+
return null
11+
}
12+
}
13+
14+
/**
15+
* Parses a schemeless host:port pattern from a string.
16+
* @param pattern - The input pattern string.
17+
* @returns An object containing the host and optional port.
18+
*/
19+
export function parseSchemelessHostPort(pattern: string): { host: string; port?: string } {
20+
const parts = pattern.split('/')
21+
const withoutPath = parts.length > 0 && parts[0] !== undefined ? parts[0] : ''
22+
const lastColon = withoutPath.lastIndexOf(':')
23+
if (lastColon === -1) {
24+
return { host: withoutPath }
25+
}
26+
27+
return {
28+
host: withoutPath.slice(0, lastColon),
29+
port: withoutPath.slice(lastColon + 1)
30+
}
31+
}
32+
33+
/**
34+
* Parses an origin string into its scheme, host, and optional port.
35+
* @param origin - The origin string to parse.
36+
* @returns An object with scheme, host, and optional port, or null if invalid.
37+
*/
38+
export function parseOriginRaw(
39+
origin: string
40+
): { scheme: string; host: string; port?: string } | null {
41+
const schemeIdx = origin.indexOf('://')
42+
if (schemeIdx === -1) {
43+
return null
44+
}
45+
const scheme = origin.slice(0, schemeIdx)
46+
const start = schemeIdx + 3
47+
let end = origin.indexOf('/', start)
48+
if (end === -1) {
49+
end = origin.length
50+
}
51+
const hostPort = origin.slice(start, end)
52+
const lastColon = hostPort.lastIndexOf(':')
53+
if (lastColon === -1) {
54+
return { scheme, host: hostPort }
55+
}
56+
57+
return { scheme, host: hostPort.slice(0, lastColon), port: hostPort.slice(lastColon + 1) }
58+
}
59+
60+
/**
61+
* Checks if the current origin matches a non-wildcard pattern.
62+
* @param currentOrigin - The current origin as a string.
63+
* @param pattern - The pattern string to match.
64+
* @returns True if the pattern matches, otherwise false.
65+
*/
66+
export function matchNonWildcardPattern(currentOrigin: string, pattern: string): boolean {
67+
// If pattern explicitly specifies a scheme, compare origins (ignore path)
68+
if (pattern.includes('://')) {
69+
const url = parseUrl(pattern)
70+
71+
return url ? url.origin === currentOrigin : false
72+
}
73+
74+
// Schemeless: treat as hostname[:port]
75+
const { host, port } = parseSchemelessHostPort(pattern)
76+
// Extract raw host[:port] from the origin string to preserve case-sensitivity
77+
const schemeIdx = currentOrigin.indexOf('://')
78+
if (schemeIdx !== -1) {
79+
const start = schemeIdx + 3
80+
let end = currentOrigin.indexOf('/', start)
81+
if (end === -1) {
82+
end = currentOrigin.length
83+
}
84+
const rawHostPort = currentOrigin.slice(start, end)
85+
if (port !== undefined) {
86+
return `${host}:${port}` === rawHostPort
87+
}
88+
89+
const rawHostOnly = rawHostPort.split(':')[0]
90+
91+
return host === rawHostOnly
92+
}
93+
94+
// Fallback to URL parsing when origin isn't a standard scheme URL
95+
const current = parseUrl(currentOrigin)
96+
if (!current) {
97+
return false
98+
}
99+
if (port !== undefined) {
100+
return host === current.hostname && port === (current.port || undefined)
101+
}
102+
103+
return host === current.hostname
104+
}
105+
106+
/**
107+
* Checks if the current origin matches a wildcard pattern.
108+
* @param current - The current origin as a URL object.
109+
* @param currentOrigin - The current origin as a string.
110+
* @param pattern - The wildcard pattern string to use.
111+
* @returns True if matches the wildcard pattern, otherwise false.
112+
*/
113+
export function matchWildcardPattern(
114+
current: URL,
115+
currentOrigin: string,
116+
pattern: string
117+
): boolean {
118+
// Extract scheme if present and strip path
119+
let working = pattern
120+
let scheme: string | undefined = undefined
121+
const schemeIdx = working.indexOf('://')
122+
if (schemeIdx !== -1) {
123+
scheme = working.slice(0, schemeIdx)
124+
working = working.slice(schemeIdx + 3)
125+
}
126+
const slashIdx = working.indexOf('/')
127+
if (slashIdx !== -1) {
128+
working = working.slice(0, slashIdx)
129+
}
130+
131+
// Split host and optional port
132+
let hostPart = working
133+
let portPart: string | undefined = undefined
134+
const lastColon = hostPart.lastIndexOf(':')
135+
if (lastColon !== -1) {
136+
portPart = hostPart.slice(lastColon + 1)
137+
hostPart = hostPart.slice(0, lastColon)
138+
}
139+
140+
// Validate wildcard usage (only full-label '*')
141+
const patternLabels = hostPart.split('.')
142+
for (const label of patternLabels) {
143+
if (label.includes('*') && label !== '*') {
144+
return false
145+
}
146+
}
147+
148+
// Scheme must match when specified
149+
const currentScheme = current.protocol.replace(/:$/u, '')
150+
if (scheme && scheme !== currentScheme) {
151+
return false
152+
}
153+
154+
// Port must match exactly when specified (or '*' allows any)
155+
if (portPart !== undefined) {
156+
if (portPart !== '*' && portPart !== current.port) {
157+
return false
158+
}
159+
}
160+
161+
/**
162+
* Host must have the same number of labels; '*' matches exactly one label.
163+
* Uses raw host from the original origin to preserve case-sensitivity.
164+
*/
165+
const raw = parseOriginRaw(currentOrigin)
166+
const hostForCompare = raw ? raw.host : current.hostname
167+
const currentLabels = hostForCompare.split('.')
168+
if (patternLabels.length !== currentLabels.length) {
169+
return false
170+
}
171+
172+
for (let i = patternLabels.length - 1; i >= 0; i -= 1) {
173+
const p = patternLabels[i]
174+
const c = currentLabels[i]
175+
if (p !== '*' && p !== c) {
176+
return false
177+
}
178+
}
179+
180+
return true
181+
}

packages/controllers/src/utils/WalletConnectUtil.ts

Lines changed: 89 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313

1414
import { EnsController } from '../controllers/EnsController.js'
1515
import type { OptionsControllerState } from '../controllers/OptionsController.js'
16+
import { matchNonWildcardPattern, matchWildcardPattern, parseUrl } from './UrlUtils.js'
1617

1718
interface ListenWcProviderParams {
1819
universalProvider: UniversalProvider
@@ -70,9 +71,21 @@ export const WcHelpersUtil = {
7071
USER_REJECTED: 5000,
7172
USER_REJECTED_METHODS: 5002
7273
},
74+
75+
/**
76+
* Retrieves the array of supported methods for a given chain namespace.
77+
* @param chainNamespace - The chain namespace.
78+
* @returns An array of method strings.
79+
*/
7380
getMethodsByChainNamespace(chainNamespace: ChainNamespace): string[] {
7481
return DEFAULT_METHODS[chainNamespace as keyof typeof DEFAULT_METHODS] || []
7582
},
83+
84+
/**
85+
* Creates a default WalletConnect namespace configuration for the given chain namespace.
86+
* @param chainNamespace - The chain namespace.
87+
* @returns The default Namespace object.
88+
*/
7689
createDefaultNamespace(chainNamespace: ChainNamespace): Namespace {
7790
return {
7891
methods: this.getMethodsByChainNamespace(chainNamespace),
@@ -82,6 +95,12 @@ export const WcHelpersUtil = {
8295
}
8396
},
8497

98+
/**
99+
* Applies overrides to the base WalletConnect NamespaceConfig.
100+
* @param baseNamespaces - The base namespace configuration.
101+
* @param overrides - Optional overrides for methods, chains, events, rpcMap.
102+
* @returns The resulting NamespaceConfig with overrides applied.
103+
*/
85104
applyNamespaceOverrides(
86105
baseNamespaces: NamespaceConfig,
87106
overrides?: OptionsControllerState['universalProviderConfigOverride']
@@ -170,6 +189,13 @@ export const WcHelpersUtil = {
170189
return result
171190
},
172191

192+
/**
193+
* Creates WalletConnect namespaces based on CAIP network definitions,
194+
* optionally applying custom overrides.
195+
* @param caipNetworks - Array of CaipNetwork definitions.
196+
* @param configOverride - Optional overrides for namespaces.
197+
* @returns The resulting NamespaceConfig.
198+
*/
173199
createNamespaces(
174200
caipNetworks: CaipNetwork[],
175201
configOverride?: OptionsControllerState['universalProviderConfigOverride']
@@ -210,13 +236,25 @@ export const WcHelpersUtil = {
210236
return this.applyNamespaceOverrides(defaultNamespaces, configOverride)
211237
},
212238

239+
/**
240+
* Resolves a Reown/ENS name to its first matching address across configured networks.
241+
* @param name - The ENS or Reown name to resolve.
242+
* @returns The resolved address as a string, or false if not found.
243+
*/
213244
resolveReownName: async (name: string) => {
214245
const wcNameAddress = await EnsController.resolveName(name)
215-
const networkNameAddresses = Object.values(wcNameAddress?.addresses) || []
246+
const networkNameAddresses = wcNameAddress?.addresses
247+
? Object.values(wcNameAddress.addresses)
248+
: []
216249

217250
return networkNameAddresses[0]?.address || false
218251
},
219252

253+
/**
254+
* Extracts all CAIP network IDs used in given WalletConnect namespaces.
255+
* @param namespaces - WalletConnect Namespaces object.
256+
* @returns Array of CAIP network IDs (chainNamespace:chainId).
257+
*/
220258
getChainsFromNamespaces(namespaces: SessionTypes.Namespaces = {}): CaipNetworkId[] {
221259
return Object.values(namespaces).flatMap<CaipNetworkId>(namespace => {
222260
const chains = (namespace.chains || []) as CaipNetworkId[]
@@ -230,6 +268,11 @@ export const WcHelpersUtil = {
230268
})
231269
},
232270

271+
/**
272+
* Type guard to check if an object is a WalletConnect session event data.
273+
* @param data - The data to check.
274+
* @returns True if data matches SessionEventData structure.
275+
*/
233276
isSessionEventData(data: unknown): data is WcHelpersUtil.SessionEventData {
234277
return (
235278
typeof data === 'object' &&
@@ -246,6 +289,11 @@ export const WcHelpersUtil = {
246289
)
247290
},
248291

292+
/**
293+
* Detects if an error object represents a user-rejected WalletConnect request.
294+
* @param error - The error object to check.
295+
* @returns True if user rejected request, otherwise false.
296+
*/
249297
isUserRejectedRequestError(error: unknown) {
250298
try {
251299
if (typeof error === 'object' && error !== null) {
@@ -266,42 +314,54 @@ export const WcHelpersUtil = {
266314
}
267315
},
268316

317+
/**
318+
* Checks if a current origin is allowed by configured allowed and default origin patterns.
319+
* Localhost and 127.0.0.1 are always allowed.
320+
* @param currentOrigin - The current web origin.
321+
* @param allowedPatterns - Patterns from project configuration.
322+
* @param defaultAllowedOrigins - Built-in or default allowed patterns.
323+
* @returns True if the origin is allowed, false otherwise.
324+
*/
269325
isOriginAllowed(
270326
currentOrigin: string,
271327
allowedPatterns: string[],
272328
defaultAllowedOrigins: string[]
273329
): boolean {
274-
for (const pattern of [...allowedPatterns, ...defaultAllowedOrigins]) {
275-
if (pattern.includes('*')) {
276-
// Convert wildcard pattern to regex, escape special chars, replace *, match whole string
277-
const escapedPattern = pattern.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')
278-
const regexString = `^${escapedPattern.replace(/\\\*/gu, '.*')}$`
279-
const regex = new RegExp(regexString, 'u')
330+
const patterns = [...allowedPatterns, ...defaultAllowedOrigins]
331+
// Spec: empty allowlist allows all origins
332+
if (allowedPatterns.length === 0) {
333+
return true
334+
}
335+
// Parse current origin up-front
336+
const current = parseUrl(currentOrigin)
337+
if (!current) {
338+
// Legacy exact string equality when pattern has no wildcard
339+
return patterns.some(pattern => !pattern.includes('*') && pattern === currentOrigin)
340+
}
341+
342+
// Local development is always permitted
343+
if (current.hostname === 'localhost' || current.hostname === '127.0.0.1') {
344+
return true
345+
}
280346

281-
if (regex.test(currentOrigin)) {
347+
for (const pattern of patterns) {
348+
if (pattern.includes('*')) {
349+
if (matchWildcardPattern(current, currentOrigin, pattern)) {
282350
return true
283351
}
284-
} else {
285-
/**
286-
* There are some cases where pattern is getting just the origin, where using new URL(pattern).origin will throw an error
287-
* thus we a try catch to handle this case
288-
*/
289-
try {
290-
if (new URL(pattern).origin === currentOrigin) {
291-
return true
292-
}
293-
} catch (e) {
294-
if (pattern === currentOrigin) {
295-
return true
296-
}
297-
}
352+
// Keep checking remaining patterns
353+
} else if (matchNonWildcardPattern(currentOrigin, pattern)) {
354+
return true
298355
}
299356
}
300357

301-
// No match found
302358
return false
303359
},
304360

361+
/**
362+
* Attaches event listeners to a UniversalProvider instance for WalletConnect events.
363+
* @param params - The listener parameters including handlers for connect, disconnect, etc.
364+
*/
305365
listenWcProvider({
306366
universalProvider,
307367
namespace,
@@ -384,6 +444,12 @@ export const WcHelpersUtil = {
384444
}
385445
},
386446

447+
/**
448+
* Retrieves and parses the unique set of accounts for a given WalletConnect namespace.
449+
* @param universalProvider - The UniversalProvider instance.
450+
* @param namespace - The chain namespace to extract accounts for.
451+
* @returns Array of parsed CaipAddress objects.
452+
*/
387453
getWalletConnectAccounts(universalProvider: UniversalProvider, namespace: ChainNamespace) {
388454
const accountsAdded = new Set<string>()
389455

0 commit comments

Comments
 (0)