Skip to content

Commit 9c6e2df

Browse files
authored
feat: subdomain detection is resilient (#497)
* chore: create 1x1.png created with `magick -size 1x1 xc:"#ffffff" public/ipfs-sw-1x1.png` * feat: subdomain detection is resilient * fix: only check if subdomains are supported once * chore: self pr suggestions * fix: unhandled err
1 parent fad1eed commit 9c6e2df

12 files changed

+119
-40
lines changed

public/ipfs-sw-1x1.png

125 Bytes
Loading

src/app.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
1-
import React, { Suspense } from 'react'
1+
import React, { Suspense, useEffect } from 'react'
22
import { RouteContext } from './context/router-context.jsx'
3+
import { checkSubdomainSupport } from './lib/check-subdomain-support.js'
34
import './app.css'
45

56
function App (): JSX.Element {
67
const { currentRoute } = React.useContext(RouteContext)
8+
9+
useEffect(() => {
10+
void checkSubdomainSupport()
11+
}, [])
12+
713
return (
814
<Suspense fallback={<div>Loading...</div>}>
915
{currentRoute?.component != null && <currentRoute.component />}

src/context/config-context.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React, { createContext, useCallback, useEffect, useState } from 'react'
2-
import { defaultDebug, defaultDnsJsonResolvers, defaultEnableGatewayProviders, defaultEnableRecursiveGateways, defaultEnableWebTransport, defaultEnableWss, defaultGateways, defaultRouters, getConfig, resetConfig, type ConfigDb } from '../lib/config-db.js'
2+
import { defaultDebug, defaultDnsJsonResolvers, defaultEnableGatewayProviders, defaultEnableRecursiveGateways, defaultEnableWebTransport, defaultEnableWss, defaultGateways, defaultRouters, defaultSupportsSubdomains, getConfig, resetConfig, type ConfigDb } from '../lib/config-db.js'
33
import { getUiComponentLogger } from '../lib/logger.js'
44
import type { ComponentLogger } from '@libp2p/logger'
55

@@ -20,7 +20,8 @@ export const ConfigContext = createContext<ConfigContextType>({
2020
enableWebTransport: defaultEnableWebTransport,
2121
enableGatewayProviders: defaultEnableGatewayProviders,
2222
enableRecursiveGateways: defaultEnableRecursiveGateways,
23-
debug: defaultDebug
23+
debug: defaultDebug(),
24+
_supportsSubdomains: defaultSupportsSubdomains
2425
})
2526

2627
export const ConfigProvider = ({ children }: { children: JSX.Element[] | JSX.Element, expanded?: boolean }): JSX.Element => {
@@ -31,7 +32,8 @@ export const ConfigProvider = ({ children }: { children: JSX.Element[] | JSX.Ele
3132
const [enableWebTransport, setEnableWebTransport] = useState(defaultEnableWebTransport)
3233
const [enableGatewayProviders, setEnableGatewayProviders] = useState(defaultEnableGatewayProviders)
3334
const [enableRecursiveGateways, setEnableRecursiveGateways] = useState(defaultEnableRecursiveGateways)
34-
const [debug, setDebug] = useState(defaultDebug)
35+
const [debug, setDebug] = useState(defaultDebug())
36+
const [_supportsSubdomains, setSupportsSubdomains] = useState(defaultSupportsSubdomains)
3537
const logger = getUiComponentLogger('config-context')
3638
const log = logger.forComponent('main')
3739

@@ -84,6 +86,9 @@ export const ConfigProvider = ({ children }: { children: JSX.Element[] | JSX.Ele
8486
case 'debug':
8587
setDebug(value)
8688
break
89+
case '_supportsSubdomains':
90+
setSupportsSubdomains(value)
91+
break
8792
default:
8893
log.error(`Unknown config key: ${key}`)
8994
throw new Error(`Unknown config key: ${key}`)
@@ -96,7 +101,7 @@ export const ConfigProvider = ({ children }: { children: JSX.Element[] | JSX.Ele
96101
}
97102

98103
return (
99-
<ConfigContext.Provider value={{ setConfig: setConfigLocal, resetConfig: resetConfigLocal, gateways, routers, dnsJsonResolvers, enableWss, enableWebTransport, enableGatewayProviders, enableRecursiveGateways, debug }}>
104+
<ConfigContext.Provider value={{ setConfig: setConfigLocal, resetConfig: resetConfigLocal, gateways, routers, dnsJsonResolvers, enableWss, enableWebTransport, enableGatewayProviders, enableRecursiveGateways, debug, _supportsSubdomains }}>
100105
{children}
101106
</ConfigContext.Provider>
102107
)

src/context/service-worker-context.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export const ServiceWorkerProvider = ({ children }): React.JSX.Element => {
2828

2929
useEffect(() => {
3030
if (isServiceWorkerRegistered) {
31-
void findOriginIsolationRedirect(window.location).then((originRedirect) => {
31+
void findOriginIsolationRedirect(window.location, uiLogger).then((originRedirect) => {
3232
if (originRedirect !== null) {
3333
window.location.replace(originRedirect)
3434
}

src/lib/check-subdomain-support.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { areSubdomainsSupported, setSubdomainsSupported } from './config-db.js'
2+
import { isSubdomainGatewayRequest } from './path-or-subdomain.js'
3+
4+
/**
5+
* Note that this function should only be used by the UI, as it relies on the
6+
* loading images from the subdomain gateway to determine if subdomains are
7+
* supported.
8+
*/
9+
export async function checkSubdomainSupport (): Promise<void> {
10+
if (!isSubdomainGatewayRequest(location) && await areSubdomainsSupported() === null) {
11+
const testUrl = `${location.protocol}//bafkqaaa.ipfs.${location.host}/ipfs-sw-1x1.png`
12+
await new Promise<boolean>((resolve, reject) => {
13+
const img = new Image()
14+
img.onload = () => {
15+
resolve(true)
16+
}
17+
img.onerror = () => {
18+
resolve(false)
19+
}
20+
img.src = testUrl
21+
}).then(async (supportsSubdomains) => {
22+
await setSubdomainsSupported(supportsSubdomains)
23+
}).catch(() => {
24+
// eslint-disable-next-line no-console
25+
console.error('Error checking for subdomain support')
26+
})
27+
}
28+
}

src/lib/config-db.ts

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { type ComponentLogger, enable } from '@libp2p/logger'
22
import { GenericIDB, type BaseDbConfig } from './generic-db.js'
33

4-
export interface ConfigDb extends BaseDbConfig {
4+
export interface ConfigDbWithoutPrivateFields extends BaseDbConfig {
55
gateways: string[]
66
routers: string[]
77
dnsJsonResolvers: Record<string, string>
@@ -12,6 +12,14 @@ export interface ConfigDb extends BaseDbConfig {
1212
debug: string
1313
}
1414

15+
/**
16+
* Private fields for app-only config.
17+
* These are not configurable by the user, and are only for programmatic use and changing functionality.
18+
*/
19+
export interface ConfigDb extends ConfigDbWithoutPrivateFields {
20+
_supportsSubdomains: null | boolean
21+
}
22+
1523
export const defaultGateways = ['https://trustless-gateway.link']
1624
export const defaultRouters = ['https://delegated-ipfs.dev']
1725
export const defaultDnsJsonResolvers: Record<string, string> = {
@@ -21,11 +29,12 @@ export const defaultEnableRecursiveGateways = true
2129
export const defaultEnableWss = true
2230
export const defaultEnableWebTransport = false
2331
export const defaultEnableGatewayProviders = true
32+
export const defaultSupportsSubdomains: null | boolean = null
2433

2534
/**
2635
* On dev/testing environments, (inbrowser.dev, localhost:${port}, or 127.0.0.1) we should set the default debug config to helia:sw-gateway*,helia:sw-gateway*:trace so we don't need to go set it manually
2736
*/
28-
export const defaultDebug = self.location.hostname.search(/localhost|inbrowser\.dev|127\.0\.0\.1/) === -1 ? '' : 'helia:sw-gateway*,helia:sw-gateway*:trace'
37+
export const defaultDebug = (): string => self.location.hostname.search(/localhost|inbrowser\.dev|127\.0\.0\.1/) === -1 ? '' : 'helia:sw-gateway*,helia:sw-gateway*:trace'
2938

3039
const configDb = new GenericIDB<ConfigDb>('helia-sw', 'config')
3140

@@ -40,17 +49,18 @@ export async function resetConfig (logger: ComponentLogger): Promise<void> {
4049
await configDb.put('enableWebTransport', defaultEnableWebTransport)
4150
await configDb.put('enableRecursiveGateways', defaultEnableRecursiveGateways)
4251
await configDb.put('enableGatewayProviders', defaultEnableGatewayProviders)
43-
await configDb.put('debug', defaultDebug)
52+
await configDb.put('debug', defaultDebug())
53+
// leave private/app-only fields as is
4454
} catch (err) {
4555
log('error resetting config in db', err)
4656
} finally {
4757
configDb.close()
4858
}
4959
}
5060

51-
export async function setConfig (config: ConfigDb, logger: ComponentLogger): Promise<void> {
61+
export async function setConfig (config: ConfigDbWithoutPrivateFields, logger: ComponentLogger): Promise<void> {
5262
const log = logger.forComponent('set-config')
53-
enable(config.debug ?? defaultDebug) // set debug level first.
63+
enable(config.debug ?? defaultDebug()) // set debug level first.
5464
await validateConfig(config, logger)
5565
try {
5666
log('config-debug: setting config %O for domain %s', config, window.location.origin)
@@ -62,7 +72,8 @@ export async function setConfig (config: ConfigDb, logger: ComponentLogger): Pro
6272
await configDb.put('enableWss', config.enableWss)
6373
await configDb.put('enableWebTransport', config.enableWebTransport)
6474
await configDb.put('enableGatewayProviders', config.enableGatewayProviders)
65-
await configDb.put('debug', config.debug ?? defaultDebug)
75+
await configDb.put('debug', config.debug ?? defaultDebug())
76+
// ignore private/app-only fields
6677
} catch (err) {
6778
log('error setting config in db', err)
6879
} finally {
@@ -79,8 +90,8 @@ export async function getConfig (logger: ComponentLogger): Promise<ConfigDb> {
7990
let enableWss
8091
let enableWebTransport
8192
let enableGatewayProviders
82-
8393
let debug = ''
94+
let _supportsSubdomains = defaultSupportsSubdomains
8495

8596
try {
8697
await configDb.open()
@@ -96,8 +107,10 @@ export async function getConfig (logger: ComponentLogger): Promise<ConfigDb> {
96107
enableWebTransport = await configDb.get('enableWebTransport') ?? defaultEnableWebTransport
97108
enableGatewayProviders = await configDb.get('enableGatewayProviders') ?? defaultEnableGatewayProviders
98109

99-
debug = await configDb.get('debug') ?? defaultDebug
110+
debug = await configDb.get('debug') ?? defaultDebug()
100111
enable(debug)
112+
113+
_supportsSubdomains ??= await configDb.get('_supportsSubdomains')
101114
} catch (err) {
102115
log('error loading config from db', err)
103116
} finally {
@@ -124,15 +137,44 @@ export async function getConfig (logger: ComponentLogger): Promise<ConfigDb> {
124137
enableWss,
125138
enableWebTransport,
126139
enableGatewayProviders,
127-
debug
140+
debug,
141+
_supportsSubdomains
128142
}
129143
}
130144

131-
export async function validateConfig (config: ConfigDb, logger: ComponentLogger): Promise<void> {
145+
export async function validateConfig (config: ConfigDbWithoutPrivateFields, logger: ComponentLogger): Promise<void> {
132146
const log = logger.forComponent('validate-config')
133147

134148
if (!config.enableRecursiveGateways && !config.enableGatewayProviders && !config.enableWss && !config.enableWebTransport) {
135149
log.error('Config is invalid. At least one of the following must be enabled: recursive gateways, gateway providers, wss, or webtransport.')
136150
throw new Error('Config is invalid. At least one of the following must be enabled: recursive gateways, gateway providers, wss, or webtransport.')
137151
}
138152
}
153+
154+
/**
155+
* Private field setters/getters
156+
*/
157+
export async function setSubdomainsSupported (supportsSubdomains: boolean, logger?: ComponentLogger): Promise<void> {
158+
const log = logger?.forComponent('set-subdomains-supported')
159+
try {
160+
await configDb.open()
161+
await configDb.put('_supportsSubdomains', supportsSubdomains)
162+
} catch (err) {
163+
log?.('error setting subdomain support in db', err)
164+
} finally {
165+
configDb.close()
166+
}
167+
}
168+
169+
export async function areSubdomainsSupported (logger?: ComponentLogger): Promise<null | boolean> {
170+
const log = logger?.forComponent('are-subdomains-supported')
171+
try {
172+
await configDb.open()
173+
return await configDb.get('_supportsSubdomains') ?? defaultSupportsSubdomains
174+
} catch (err) {
175+
log?.('error loading subdomain support from db', err)
176+
} finally {
177+
configDb.close()
178+
}
179+
return false
180+
}

src/lib/path-or-subdomain.ts

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { base32 } from 'multiformats/bases/base32'
22
import { base36 } from 'multiformats/bases/base36'
33
import { CID } from 'multiformats/cid'
4+
import { areSubdomainsSupported } from './config-db.js'
45
import { dnsLinkLabelEncoder } from './dns-link-labels.js'
56
import { pathRegex, subdomainRegex } from './regex.js'
7+
import type { ComponentLogger } from '@libp2p/logger'
68

79
export const isPathOrSubdomainRequest = (location: Pick<Location, 'host' | 'pathname'>): boolean => {
810
return isPathGatewayRequest(location) || isSubdomainGatewayRequest(location)
@@ -22,27 +24,21 @@ export const isPathGatewayRequest = (location: Pick<Location, 'host' | 'pathname
2224
* Origin isolation check and enforcement
2325
* https://github.com/ipfs-shipyard/helia-service-worker-gateway/issues/30
2426
*/
25-
export const findOriginIsolationRedirect = async (location: Pick<Location, 'protocol' | 'host' | 'pathname' | 'search' | 'hash' >): Promise<string | null> => {
27+
export const findOriginIsolationRedirect = async (location: Pick<Location, 'protocol' | 'host' | 'pathname' | 'search' | 'hash' >, logger?: ComponentLogger): Promise<string | null> => {
28+
const log = logger?.forComponent('find-origin-isolation-redirect')
2629
if (isPathGatewayRequest(location) && !isSubdomainGatewayRequest(location)) {
27-
const redirect = await isSubdomainIsolationSupported(location)
28-
if (redirect) {
30+
log?.trace('checking for subdomain support')
31+
if (await areSubdomainsSupported() === true) {
32+
log?.trace('subdomain support is enabled')
2933
return toSubdomainRequest(location)
34+
} else {
35+
log?.trace('subdomain support is disabled')
3036
}
3137
}
38+
log?.trace('no need to check for subdomain support', isPathGatewayRequest(location), isSubdomainGatewayRequest(location))
3239
return null
3340
}
3441

35-
const isSubdomainIsolationSupported = async (location: Pick<Location, 'protocol' | 'host' | 'pathname'>): Promise<boolean> => {
36-
// TODO: do this test once and expose it as cookie / config flag somehow
37-
const testUrl = `${location.protocol}//bafkqaaa.ipfs.${location.host}`
38-
try {
39-
const response: Response = await fetch(testUrl)
40-
return response.status === 200
41-
} catch (_) {
42-
return false
43-
}
44-
}
45-
4642
const toSubdomainRequest = (location: Pick<Location, 'protocol' | 'host' | 'pathname' | 'search' | 'hash'>): string => {
4743
const segments = location.pathname.split('/').filter(segment => segment !== '')
4844
const ns = segments[0]

src/pages/config.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ export interface ConfigPageProps extends React.HTMLProps<HTMLElement> {
8282
// Config Page can be loaded either as a page or as a component in the landing helper-ui page
8383
const ConfigPage: FunctionComponent<ConfigPageProps> = () => {
8484
const { gotoPage } = useContext(RouteContext)
85-
const { setConfig, resetConfig, gateways, routers, dnsJsonResolvers, debug, enableGatewayProviders, enableRecursiveGateways, enableWss, enableWebTransport } = useContext(ConfigContext)
85+
const { setConfig, resetConfig, gateways, routers, dnsJsonResolvers, debug, enableGatewayProviders, enableRecursiveGateways, enableWss, enableWebTransport, _supportsSubdomains } = useContext(ConfigContext)
8686
const [isSaving, setIsSaving] = useState(false)
8787
const [error, setError] = useState<Error | null>(null)
8888
const [resetKey, setResetKey] = useState(0)
@@ -95,7 +95,7 @@ const ConfigPage: FunctionComponent<ConfigPageProps> = () => {
9595
}
9696
// we get the iframe origin from a query parameter called 'origin', if this is loaded in an iframe
9797
const targetOrigin = decodeURIComponent(window.location.hash.split('@origin=')[1])
98-
const config = { gateways, routers, dnsJsonResolvers, debug, enableGatewayProviders, enableRecursiveGateways, enableWss, enableWebTransport }
98+
const config = { gateways, routers, dnsJsonResolvers, debug, enableGatewayProviders, enableRecursiveGateways, enableWss, enableWebTransport, _supportsSubdomains }
9999
log.trace('config-page: postMessage config to origin ', config, targetOrigin)
100100
/**
101101
* The reload page in the parent window is listening for this message, and then it passes a RELOAD_CONFIG message to the service worker

src/pages/redirects-interstitial.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import React, { useEffect } from 'react'
2+
import { checkSubdomainSupport } from '../lib/check-subdomain-support.js'
23
import { findOriginIsolationRedirect } from '../lib/path-or-subdomain.js'
34
import { translateIpfsRedirectUrl } from '../lib/translate-ipfs-redirect-url.js'
45
import LoadingPage from './loading.jsx'
@@ -15,11 +16,12 @@ export default function RedirectsInterstitial (): React.JSX.Element {
1516
const [isSubdomainCheckDone, setIsSubdomainCheckDone] = React.useState<boolean>(false)
1617
useEffect(() => {
1718
async function doWork (): Promise<void> {
19+
await checkSubdomainSupport()
1820
setSubdomainRedirectUrl(await findOriginIsolationRedirect(translateIpfsRedirectUrl(window.location.href)))
1921
setIsSubdomainCheckDone(true)
2022
}
2123
void doWork()
22-
})
24+
}, [])
2325

2426
useEffect(() => {
2527
if (subdomainRedirectUrl != null && window.location.href !== subdomainRedirectUrl) {

src/sw.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -410,7 +410,7 @@ async function storeReponseInCache ({ response, isMutable, cacheKey, event }: St
410410

411411
async function fetchHandler ({ path, request, event }: FetchHandlerArg): Promise<Response> {
412412
// test and enforce origin isolation before anything else is executed
413-
const originLocation = await findOriginIsolationRedirect(new URL(request.url))
413+
const originLocation = await findOriginIsolationRedirect(new URL(request.url), swLogger)
414414
if (originLocation !== null) {
415415
const body = 'Gateway supports subdomain mode, redirecting to ensure Origin isolation..'
416416
return new Response(body, {

0 commit comments

Comments
 (0)