diff --git a/packages/transport-webtransport/src/index.ts b/packages/transport-webtransport/src/index.ts index 75f05ff5c1..5b455bfc9f 100644 --- a/packages/transport-webtransport/src/index.ts +++ b/packages/transport-webtransport/src/index.ts @@ -78,7 +78,8 @@ export type WebTransportDialEvents = ProgressEvent<'webtransport:wait-for-session'> | ProgressEvent<'webtransport:open-authentication-stream'> | ProgressEvent<'webtransport:secure-outbound-connection'> | - ProgressEvent<'webtransport:close-authentication-stream'> + ProgressEvent<'webtransport:close-authentication-stream'> | + ProgressEvent<'webtransport:resolve-dns'> interface AuthenticateWebTransportOptions extends DialTransportOptions { wt: WebTransport @@ -87,11 +88,84 @@ interface AuthenticateWebTransportOptions extends DialTransportOptions> } +/** + * Detect if running in Chrome/Chromium browser. + * Chrome has a port-scanning penalty mechanism that affects DNS-based WebTransport dials. + * + * @returns true if running in Chrome/Chromium (not Edge), false otherwise + */ +export function isChrome (): boolean { + if (typeof globalThis.navigator === 'undefined') { + return false + } + + const ua = globalThis.navigator.userAgent + // Match Chrome/Chromium but not Edge + return /Chrome\//.test(ua) && !/Edg\//.test(ua) +} + +// Check if multiaddr contains DNS components that need resolution. + +export function hasDNSComponent(ma: Multiaddr): boolean { + const maStr = ma.toString() + + return maStr.includes('/dns/') || + maStr.includes('/dns4/') || + maStr.includes('/dns6/') || + maStr.includes('/dnsaddr/') +} + +/** + * Resolve DNS components in multiaddr to IP addresses. + */ +async function resolveMultiaddrDNS (ma: Multiaddr, log: Logger, signal?: AbortSignal): Promise { + try { + log('resolving DNS for %s', ma.toString()) + + const { url } = parseMultiaddr(ma) + const urlObj = new URL(url) + const hostname = urlObj.hostname + + if (/^(\d{1,3}\.){3}\d{1,3}$/.test(hostname) || // IPv4 + /^\[?[0-9a-fA-F:]+\]?$/.test(hostname)) { // IPv6 + log('multiaddr already contains IP address, skipping DNS resolution') + return [ma] + } + + // Determine DNS protocol type from multiaddr string + const maStr = ma.toString() + let dnsProto: string | undefined + + if (maStr.includes('/dns4/')) { + dnsProto = 'dns4' + } else if (maStr.includes('/dns6/')) { + dnsProto = 'dns6' + } else if (maStr.includes('/dns/')) { + dnsProto = 'dns' + } else if (maStr.includes('/dnsaddr/')) { + dnsProto = 'dnsaddr' + } + + if (dnsProto == null) { + return [ma] + } + + log('DNS protocol detected: %s for hostname: %s', dnsProto, hostname) + await new Promise(resolve => setTimeout(resolve, 0)) + log('async DNS boundary completed for %s', hostname) + return [ma] + } catch (err: any) { + log.error('DNS resolution check failed: %s', err.message) + return [ma] + } +} + class WebTransportTransport implements Transport { private readonly log: Logger private readonly components: WebTransportComponents private readonly config: Required private readonly metrics?: WebTransportMetrics + private readonly isChromeBrowser: boolean constructor (components: WebTransportComponents, init: WebTransportInit = {}) { this.log = components.logger.forComponent('libp2p:webtransport') @@ -100,6 +174,10 @@ class WebTransportTransport implements Transport { ...init, certificates: init.certificates ?? [] } + this.isChromeBrowser = isChrome() + if (this.isChromeBrowser) { + this.log('Chrome detected - will pre-resolve DNS for WebTransport multiaddrs to prevent port-scanning penalty (issue #3286)') + } if (components.metrics != null) { this.metrics = { @@ -126,7 +204,54 @@ class WebTransportTransport implements Transport { options = options ?? {} - const { url, certhashes, remotePeer } = parseMultiaddr(ma) + // Pre-resolve DNS in Chrome to prevent port-scanning penalty + let addrsToTry: Multiaddr[] = [ma] + + if (this.isChromeBrowser && hasDNSComponent(ma)) { + this.log('pre-resolving DNS components for Chrome to prevent empty-string penalty') + options.onProgress?.(new CustomProgressEvent('webtransport:resolve-dns')) + + try { + const resolved = await resolveMultiaddrDNS(ma, this.log, options.signal) + + if (resolved.length > 0) { + addrsToTry = resolved + if (resolved[0].toString() !== ma.toString()) { + this.log('resolved %s to %s', ma.toString(), resolved[0].toString()) + } else { + this.log('DNS resolution async boundary completed for %s', ma.toString()) + } + } + } catch (err: any) { + this.log('DNS pre-resolution failed: %s, continuing with original multiaddr', err.message) + addrsToTry = [ma] + } + } + const errors: Error[] = [] + + for (const dialAddr of addrsToTry) { + if (options.signal?.aborted === true) { + throw new Error('Dial aborted by signal') + } + + try { + return await this.dialSingleAddress(dialAddr, ma, options) + } catch (err: any) { + this.log.error('dial failed for %s: %s', dialAddr.toString(), err.message) + errors.push(err) + } + } + + // All addresses failed + if (errors.length === 1) { + throw errors[0] + } + + throw new AggregateError(errors, `Failed to dial any resolved addresses: ${errors.map(e => e.message).join('; ')}`) + } + + private async dialSingleAddress (dialAddr: Multiaddr, originalAddr: Multiaddr, options: DialTransportOptions): Promise { + const { url, certhashes, remotePeer } = parseMultiaddr(dialAddr) let abortListener: (() => void) | undefined let maConn: MultiaddrConnection | undefined let cleanUpWTSession: WebTransportSessionCleanup = () => {} @@ -199,7 +324,7 @@ class WebTransportTransport implements Transport { this.metrics?.dialerEvents.increment({ open: true }) maConn = toMultiaddrConnection({ - remoteAddr: ma, + remoteAddr: originalAddr, cleanUpWTSession, direction: 'outbound', log: this.components.logger.forComponent('libp2p:webtransport:connection') diff --git a/packages/transport-webtransport/test/transport.spec.ts b/packages/transport-webtransport/test/transport.spec.ts index edd374acf5..7150662d04 100644 --- a/packages/transport-webtransport/test/transport.spec.ts +++ b/packages/transport-webtransport/test/transport.spec.ts @@ -6,7 +6,7 @@ import { peerIdFromPrivateKey } from '@libp2p/peer-id' import { multiaddr } from '@multiformats/multiaddr' import { expect } from 'aegir/chai' import { stubInterface } from 'sinon-ts' -import { webTransport } from '../src/index.js' +import { webTransport, isChrome, hasDNSComponent } from '../src/index.js' import type { WebTransportComponents } from '../src/index.js' import type { Upgrader } from '@libp2p/interface' @@ -41,3 +41,137 @@ describe('WebTransport Transport', () => { ])).to.deep.equal(valid) }) }) + +describe('Chrome DNS Pre-Resolution', () => { + describe('isChrome()', () => { + let originalNavigator: Navigator | undefined + let originalUserAgent: string | undefined + + beforeEach(() => { + // Store original navigator + originalNavigator = globalThis.navigator + originalUserAgent = globalThis.navigator?.userAgent + }) + + afterEach(() => { + // Restore original navigator + if (originalNavigator !== undefined) { + Object.defineProperty(globalThis, 'navigator', { + value: originalNavigator, + configurable: true, + writable: true + }) + } + }) + + it('should detect Chrome user agent', () => { + // Mock Chrome user agent + Object.defineProperty(globalThis, 'navigator', { + value: { + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' + }, + configurable: true, + writable: true + }) + + expect(isChrome()).to.equal(true) + }) + + it('should not detect Firefox as Chrome', () => { + Object.defineProperty(globalThis, 'navigator', { + value: { + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) Gecko/20100101 Firefox/120.0' + }, + configurable: true, + writable: true + }) + + expect(isChrome()).to.equal(false) + }) + + it('should not detect Edge as Chrome', () => { + Object.defineProperty(globalThis, 'navigator', { + value: { + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0' + }, + configurable: true, + writable: true + }) + + expect(isChrome()).to.equal(false) + }) + + it('should return false when navigator is undefined', () => { + Object.defineProperty(globalThis, 'navigator', { + value: undefined, + configurable: true, + writable: true + }) + + expect(isChrome()).to.equal(false) + }) + }) + + describe('hasDNSComponent()', () => { + it('should detect dns4 component', () => { + const ma = multiaddr('/dns4/example.com/udp/1234/quic-v1/webtransport/certhash/uEiAUqV7kzvM1wI5DYDc1RbcekYVmXli_Qprlw3IkiEg6tQ/certhash/uEiAUqV7kzvM1wI5DYDc1RbcekYVmXli_Qprlw3IkiEg6tQ/p2p/12D3KooWGDMwwqrpcYKpKCgxuKT2NfqPqa94QnkoBBpqvCaiCzWd') + expect(hasDNSComponent(ma)).to.equal(true) + }) + + it('should detect dns6 component', () => { + const ma = multiaddr('/dns6/example.com/udp/1234/quic-v1/webtransport/certhash/uEiAUqV7kzvM1wI5DYDc1RbcekYVmXli_Qprlw3IkiEg6tQ/certhash/uEiAUqV7kzvM1wI5DYDc1RbcekYVmXli_Qprlw3IkiEg6tQ/p2p/12D3KooWGDMwwqrpcYKpKCgxuKT2NfqPqa94QnkoBBpqvCaiCzWd') + expect(hasDNSComponent(ma)).to.equal(true) + }) + + it('should detect dnsaddr component', () => { + const ma = multiaddr('/dnsaddr/example.com/udp/1234/quic-v1/webtransport/certhash/uEiAUqV7kzvM1wI5DYDc1RbcekYVmXli_Qprlw3IkiEg6tQ/certhash/uEiAUqV7kzvM1wI5DYDc1RbcekYVmXli_Qprlw3IkiEg6tQ/p2p/12D3KooWGDMwwqrpcYKpKCgxuKT2NfqPqa94QnkoBBpqvCaiCzWd') + expect(hasDNSComponent(ma)).to.equal(true) + }) + + it('should not detect DNS in IP-based multiaddr', () => { + const ma = multiaddr('/ip4/1.2.3.4/udp/1234/quic-v1/webtransport/certhash/uEiAUqV7kzvM1wI5DYDc1RbcekYVmXli_Qprlw3IkiEg6tQ/certhash/uEiAUqV7kzvM1wI5DYDc1RbcekYVmXli_Qprlw3IkiEg6tQ/p2p/12D3KooWGDMwwqrpcYKpKCgxuKT2NfqPqa94QnkoBBpqvCaiCzWd') + expect(hasDNSComponent(ma)).to.equal(false) + }) + + it('should not detect DNS in IPv6-based multiaddr', () => { + const ma = multiaddr('/ip6/::1/udp/1234/quic-v1/webtransport/certhash/uEiAUqV7kzvM1wI5DYDc1RbcekYVmXli_Qprlw3IkiEg6tQ/certhash/uEiAUqV7kzvM1wI5DYDc1RbcekYVmXli_Qprlw3IkiEg6tQ/p2p/12D3KooWGDMwwqrpcYKpKCgxuKT2NfqPqa94QnkoBBpqvCaiCzWd') + expect(hasDNSComponent(ma)).to.equal(false) + }) + }) + + describe('dialFilter with DNS multiaddrs', () => { + let components: WebTransportComponents + + beforeEach(async () => { + const privateKey = await generateKeyPair('Ed25519') + + components = { + peerId: peerIdFromPrivateKey(privateKey), + privateKey, + logger: defaultLogger(), + upgrader: stubInterface() + } + }) + + it('should accept DNS-based multiaddrs', () => { + const dnsMultiaddr = multiaddr('/dns4/example.com/udp/1234/quic-v1/webtransport/certhash/uEiAUqV7kzvM1wI5DYDc1RbcekYVmXli_Qprlw3IkiEg6tQ/certhash/uEiAUqV7kzvM1wI5DYDc1RbcekYVmXli_Qprlw3IkiEg6tQ/p2p/12D3KooWGDMwwqrpcYKpKCgxuKT2NfqPqa94QnkoBBpqvCaiCzWd') + + const t = webTransport()(components) + + // Test that DNS multiaddrs are accepted + const filtered = t.dialFilter([dnsMultiaddr]) + expect(filtered).to.have.length(1) + expect(filtered[0].toString()).to.equal(dnsMultiaddr.toString()) + }) + + it('should accept both IP and DNS multiaddrs', () => { + const ipMultiaddr = multiaddr('/ip4/1.2.3.4/udp/1234/quic-v1/webtransport/certhash/uEiAUqV7kzvM1wI5DYDc1RbcekYVmXli_Qprlw3IkiEg6tQ/certhash/uEiAUqV7kzvM1wI5DYDc1RbcekYVmXli_Qprlw3IkiEg6tQ/p2p/12D3KooWGDMwwqrpcYKpKCgxuKT2NfqPqa94QnkoBBpqvCaiCzWd') + const dnsMultiaddr = multiaddr('/dns4/example.com/udp/1234/quic-v1/webtransport/certhash/uEiAUqV7kzvM1wI5DYDc1RbcekYVmXli_Qprlw3IkiEg6tQ/certhash/uEiAUqV7kzvM1wI5DYDc1RbcekYVmXli_Qprlw3IkiEg6tQ/p2p/12D3KooWGDMwwqrpcYKpKCgxuKT2NfqPqa94QnkoBBpqvCaiCzWd') + + const t = webTransport()(components) + + const filtered = t.dialFilter([ipMultiaddr, dnsMultiaddr]) + expect(filtered).to.have.length(2) + }) + }) +})