Skip to content

Commit 815ea3c

Browse files
authored
feat: display origin isolation warnings (#615)
* feat: show origin isolation warning if subdomain not supported * chore: some styling updates to origin warning * test: add origin isolation warning test * chore: apply self suggestions from code review * chore: fix lint
1 parent b081577 commit 815ea3c

14 files changed

+255
-11
lines changed

src/context/router-context.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import React, { useCallback, useEffect, type ReactElement } from 'react'
1+
import React, { useCallback, useEffect, type ReactNode } from 'react'
22

33
export interface Route {
44
default?: boolean
55
path?: string
66
shouldRender?(): Promise<boolean>
7-
component: React.LazyExoticComponent<(...args: any[]) => ReactElement | null>
7+
component: React.LazyExoticComponent<(...args: any[]) => ReactNode>
88
}
99

1010
export const RouteContext = React.createContext<{

src/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const LazyHelperUi = React.lazy(async () => import('./pages/helper-ui.jsx'))
1414
const LazyRedirectPage = React.lazy(async () => import('./pages/redirect-page.jsx'))
1515
const LazyInterstitial = React.lazy(async () => import('./pages/redirects-interstitial.jsx'))
1616
const LazyServiceWorkerErrorPage = React.lazy(async () => import('./pages/errors/no-service-worker.jsx'))
17+
const LazySubdomainWarningPage = React.lazy(async () => import('./pages/subdomain-warning.jsx'))
1718

1819
let ErrorPage: null | React.LazyExoticComponent<() => ReactElement> = LazyServiceWorkerErrorPage
1920
if ('serviceWorker' in navigator) {
@@ -23,6 +24,7 @@ if ('serviceWorker' in navigator) {
2324
const routes: Route[] = [
2425
{ default: true, component: ErrorPage ?? LazyHelperUi },
2526
{ shouldRender: async () => renderChecks.shouldRenderNoServiceWorkerError(), component: LazyServiceWorkerErrorPage },
27+
{ shouldRender: renderChecks.shouldRenderSubdomainWarningPage, component: LazySubdomainWarningPage },
2628
{ shouldRender: async () => renderChecks.shouldRenderRedirectsInterstitial(), component: LazyInterstitial },
2729
{ path: '#/ipfs-sw-config', shouldRender: async () => renderChecks.shouldRenderConfigPage(), component: LazyConfig },
2830
{

src/lib/path-or-subdomain.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export const findOriginIsolationRedirect = async (location: Pick<Location, 'prot
2828
const log = logger?.forComponent('find-origin-isolation-redirect')
2929
if (isPathGatewayRequest(location) && !isSubdomainGatewayRequest(location)) {
3030
log?.trace('checking for subdomain support')
31-
if (await areSubdomainsSupported() === true) {
31+
if (await areSubdomainsSupported(logger) === true) {
3232
log?.trace('subdomain support is enabled')
3333
return toSubdomainRequest(location)
3434
} else {

src/lib/routing-render-checks.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,11 @@ export function shouldRenderRedirectsInterstitial (): boolean {
3636
export function shouldRenderNoServiceWorkerError (): boolean {
3737
return !('serviceWorker' in navigator)
3838
}
39+
40+
export async function shouldRenderSubdomainWarningPage (): Promise<boolean> {
41+
if (window.location.hash.startsWith('#/ipfs-sw-origin-isolation-warning')) {
42+
return true
43+
}
44+
45+
return false
46+
}

src/pages/subdomain-warning.tsx

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import React, { useCallback, useEffect, useState, type ReactNode } from 'react'
2+
import Header from '../components/Header.jsx'
3+
import './default-page-styles.css'
4+
import './loading.css'
5+
import { ServiceWorkerReadyButton } from '../components/sw-ready-button.jsx'
6+
import { ServiceWorkerProvider } from '../context/service-worker-context.jsx'
7+
8+
function IpAddressRecommendations ({ currentHost }: { currentHost: string }): ReactNode {
9+
return (
10+
<div>
11+
<span>Current Host: {currentHost}</span>
12+
<p>Ip addresses do not support origin isolation.</p>
13+
<p>If you're the website administrator, please ensure your domain has proper DNS configuration</p>
14+
</div>
15+
)
16+
}
17+
18+
function DefaultRecommendations ({ currentHost }: { currentHost: string }): ReactNode {
19+
return (
20+
<div>
21+
<span>Current Host: {currentHost}</span>
22+
<p>
23+
For the best experience, this website should be accessed through a subdomain gateway
24+
(e.g., <code>cid.ipfs.{currentHost}</code> instead of <code>{currentHost}/ipfs/cid</code>).
25+
</p>
26+
27+
<p>
28+
If you're the website administrator, please ensure your domain has proper DNS configuration
29+
for wildcard subdomains (<code>*.ipfs.{currentHost}</code> and <code>*.ipns.{currentHost}</code>).
30+
</p>
31+
</div>
32+
)
33+
}
34+
/**
35+
* Warning page to display when subdomain setup is not available
36+
* This UI is similar to browser security warnings and informs users about missing features
37+
*/
38+
export default function SubdomainWarningPage (): ReactNode {
39+
const [acceptedRisk, setAcceptedRisk] = useState(sessionStorage.getItem('ipfs-sw-gateway-accepted-path-gateway-risk') != null ?? false)
40+
const [isSaving, setIsSaving] = useState(false)
41+
const originalUrl = new URL(window.location.href).searchParams.get('helia-sw')
42+
43+
const handleAcceptRisk = useCallback(async () => {
44+
setIsSaving(true)
45+
// Store the user's choice in sessionStorage so it persists during the session
46+
sessionStorage.setItem('ipfs-sw-gateway-accepted-path-gateway-risk', 'true')
47+
// post to SW to accept the risk
48+
try {
49+
await fetch('/#/ipfs-sw-accept-origin-isolation-warning').then(() => {
50+
setAcceptedRisk(true)
51+
})
52+
} catch (error) {
53+
// eslint-disable-next-line no-console
54+
console.error('Error accepting risk', error)
55+
} finally {
56+
setIsSaving(false)
57+
}
58+
}, [])
59+
60+
useEffect(() => {
61+
if (acceptedRisk) {
62+
window.location.href = originalUrl ?? '/'
63+
}
64+
}, [originalUrl, acceptedRisk])
65+
66+
const currentHost = window.location.host
67+
68+
const isCurrentHostAnIpAddress = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/.test(currentHost)
69+
70+
let RecommendationsElement: (props: { currentHost: string }) => ReactNode = DefaultRecommendations
71+
if (isCurrentHostAnIpAddress) {
72+
RecommendationsElement = IpAddressRecommendations
73+
}
74+
75+
return (
76+
<ServiceWorkerProvider>
77+
<Header />
78+
<main className='pa4-l bg-red mw7 mb5 center pa4 e2e-subdomain-warning mt4'>
79+
<div className="flex items-center mb3 bg-red">
80+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mr2">
81+
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"></path>
82+
<line x1="12" y1="9" x2="12" y2="13"></line>
83+
<line x1="12" y1="17" x2="12.01" y2="17"></line>
84+
</svg>
85+
<h1 className="ma0 f3">Warning: Subdomain Gateway Not Available</h1>
86+
</div>
87+
88+
<div className="ba b--yellow-dark pa3 mb4 bg-red-muted">
89+
<p className="ma0 mb2 b">This website is using a path-based IPFS gateway without proper origin isolation.</p>
90+
<p className="ma0">
91+
Without subdomain support, the following features will be missing:
92+
</p>
93+
<ul className="mt2">
94+
<li>Origin isolation for security</li>
95+
<li>Support for _redirects functionality</li>
96+
<li>Proper web application functionality</li>
97+
</ul>
98+
</div>
99+
100+
<RecommendationsElement currentHost={currentHost} />
101+
102+
<div className="flex justify-center mt4">
103+
<ServiceWorkerReadyButton id="accept-warning" label={isSaving ? 'Accepting...' : 'I understand the risks - Continue anyway'} waitingLabel='Waiting for service worker registration...' onClick={() => { void handleAcceptRisk() }} />
104+
</div>
105+
</main>
106+
</ServiceWorkerProvider>
107+
)
108+
}

src/sw.ts

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { getSubdomainParts } from './lib/get-subdomain-parts.js'
55
import { getVerifiedFetch } from './lib/get-verified-fetch.js'
66
import { isConfigPage } from './lib/is-config-page.js'
77
import { swLogger } from './lib/logger.js'
8-
import { findOriginIsolationRedirect } from './lib/path-or-subdomain.js'
8+
import { findOriginIsolationRedirect, isPathGatewayRequest } from './lib/path-or-subdomain.js'
99
import type { VerifiedFetch } from '@helia/verified-fetch'
1010

1111
/**
@@ -42,6 +42,7 @@ interface StoreReponseInCacheOptions {
4242
*/
4343
interface LocalSwConfig {
4444
installTimestamp: number
45+
originIsolationWarningAccepted: boolean
4546
}
4647

4748
/**
@@ -203,6 +204,13 @@ async function requestRouting (event: FetchEvent, url: URL): Promise<boolean> {
203204
log.error('sw-config reload request, error updating verifiedFetch', err)
204205
}))
205206
return false
207+
} else if (isAcceptOriginIsolationWarningRequest(event)) {
208+
event.waitUntil(setOriginIsolationWarningAccepted().then(() => {
209+
log.trace('origin isolation warning accepted')
210+
}).catch((err) => {
211+
log.error('origin isolation warning accepted, error', err)
212+
}))
213+
return false
206214
} else if (isSwConfigGETRequest(event)) {
207215
log.trace('sw-config GET request')
208216
event.waitUntil(new Promise<void>((resolve) => {
@@ -301,6 +309,10 @@ function isSwConfigGETRequest (event: FetchEvent): boolean {
301309
return event.request.url.includes('/#/ipfs-sw-config-get')
302310
}
303311

312+
function isAcceptOriginIsolationWarningRequest (event: FetchEvent): boolean {
313+
return event.request.url.includes('/#/ipfs-sw-accept-origin-isolation-warning')
314+
}
315+
304316
function isSwAssetRequest (event: FetchEvent): boolean {
305317
const isActualSwAsset = /^.+\/(?:ipfs-sw-).+$/.test(event.request.url)
306318
// if path is not set, then it's a request for index.html which we should consider a sw asset
@@ -430,8 +442,9 @@ async function storeReponseInCache ({ response, isMutable, cacheKey, event }: St
430442
}
431443

432444
async function fetchHandler ({ path, request, event }: FetchHandlerArg): Promise<Response> {
445+
const originalUrl = new URL(request.url)
433446
// test and enforce origin isolation before anything else is executed
434-
const originLocation = await findOriginIsolationRedirect(new URL(request.url), swLogger)
447+
const originLocation = await findOriginIsolationRedirect(originalUrl, swLogger)
435448
if (originLocation !== null) {
436449
const body = 'Gateway supports subdomain mode, redirecting to ensure Origin isolation..'
437450
return new Response(body, {
@@ -441,6 +454,18 @@ async function fetchHandler ({ path, request, event }: FetchHandlerArg): Promise
441454
Location: originLocation
442455
}
443456
})
457+
} else if (isPathGatewayRequest(originalUrl) && !(await getOriginIsolationWarningAccepted())) {
458+
const newUrl = new URL(originalUrl.href)
459+
newUrl.pathname = '/'
460+
newUrl.hash = '/ipfs-sw-origin-isolation-warning'
461+
newUrl.searchParams.set('helia-sw', request.url)
462+
return new Response('Origin isolation is not supported, please accept the risk to continue.', {
463+
status: 307,
464+
headers: {
465+
'Content-Type': 'text/plain',
466+
Location: newUrl.href
467+
}
468+
})
444469
}
445470

446471
/**
@@ -671,6 +696,30 @@ async function addInstallTimestampToConfig (): Promise<void> {
671696
}
672697
}
673698

699+
async function setOriginIsolationWarningAccepted (): Promise<void> {
700+
try {
701+
const swidb = getSwConfig()
702+
await swidb.open()
703+
await swidb.put('originIsolationWarningAccepted', true)
704+
swidb.close()
705+
} catch (e) {
706+
log.error('addInstallTimestampToConfig error: ', e)
707+
}
708+
}
709+
710+
async function getOriginIsolationWarningAccepted (): Promise<boolean> {
711+
try {
712+
const swidb = getSwConfig()
713+
await swidb.open()
714+
const accepted = await swidb.get('originIsolationWarningAccepted')
715+
swidb.close()
716+
return accepted ?? false
717+
} catch (e) {
718+
log.error('getOriginIsolationWarningAccepted error: ', e)
719+
return false
720+
}
721+
}
722+
674723
/**
675724
* To be called on 'install' sw event. This will clear out the old swAssets cache,
676725
* which is used for storing the service worker's css,js, and html assets.

test-e2e/byte-range.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@ import { testPathRouting as test, expect } from './fixtures/config-test-fixtures
22
import { doRangeRequest } from './fixtures/do-range-request.js'
33

44
test.describe('byte-ranges', () => {
5+
test.beforeEach(async ({ page }) => {
6+
// we need to send a request to the service worker to accept the origin isolation warning
7+
await page.evaluate(async () => {
8+
const response = await fetch('/#/ipfs-sw-accept-origin-isolation-warning')
9+
if (!response.ok) {
10+
throw new Error('Failed to accept origin isolation warning')
11+
}
12+
})
13+
})
14+
515
test('should be able to get a single character', async ({ page }) => {
616
test.setTimeout(60000)
717
const { text, byteSize, statusCode } = await doRangeRequest({ page, range: 'bytes=1-2', path: '/ipfs/bafkqaddimvwgy3zao5xxe3debi' })

test-e2e/first-hit.test.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ test.describe('first-hit ipfs-hosted', () => {
1919
expect(response?.status()).toBe(200)
2020
const headers = await response?.allHeaders()
2121

22+
// accept the origin isolation warning
23+
await expect(page).toHaveURL(/#\/ipfs-sw-origin-isolation-warning/)
24+
await page.click('.e2e-subdomain-warning button')
25+
2226
expect(headers?.['content-type']).toContain('text/html')
2327

2428
// wait for loading page to finish '.loading-page' to be removed
@@ -68,12 +72,11 @@ test.describe('first-hit direct-hosted', () => {
6872

6973
// first loads the root page
7074
expect(response?.status()).toBe(200)
71-
const headers = await response?.allHeaders()
7275

73-
expect(headers?.['content-type']).toContain('text/html')
76+
await expect(page).toHaveURL(/#\/ipfs-sw-origin-isolation-warning/)
77+
await page.click('.e2e-subdomain-warning button')
7478

75-
// wait for loading page to finish '.loading-page' to be removed
76-
await page.waitForSelector('.loading-page', { state: 'detached' })
79+
await expect(page).toHaveURL('http://127.0.0.1:3333/ipfs/bafkqablimvwgy3y')
7780

7881
// and we verify the content was returned
7982
await page.waitForSelector('text=hello', { timeout: 25000 })

test-e2e/fixtures/config-test-fixtures.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,13 @@ export const test = base.extend<{ rootDomain: string, baseURL: string, protocol:
3333
export const testPathRouting = test.extend<{ rootDomain: string, baseURL: string, protocol: string }>({
3434
rootDomain: [rootDomain, { scope: 'test' }],
3535
protocol: [baseURLProtocol, { scope: 'test' }],
36+
// eslint-disable-next-line no-empty-pattern
37+
baseURL: async ({ }, use) => {
38+
// Override baseURL to always be http://127.0.0.1:3333 for path routing tests
39+
await use('http://127.0.0.1:3333')
40+
},
3641
page: async ({ page, rootDomain }, use) => {
37-
if (!rootDomain.includes('localhost')) {
42+
if (!rootDomain.includes('localhost') && !rootDomain.includes('127.0.0.1')) {
3843
// for non localhost tests, we skip path routing tests
3944
testPathRouting.skip()
4045
return

test-e2e/hamt-dir.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
import { testPathRouting as test, expect } from './fixtures/config-test-fixtures.js'
22

33
test.describe('hamt-dir', () => {
4+
test.beforeEach(async ({ page }) => {
5+
// we need to send a request to the service worker to accept the origin isolation warning
6+
await page.evaluate(async () => {
7+
const response = await fetch('/#/ipfs-sw-accept-origin-isolation-warning')
8+
if (!response.ok) {
9+
throw new Error('Failed to accept origin isolation warning')
10+
}
11+
})
12+
})
413
test('can open UnixFS file from HAMT-sharded directory', async ({ page }) => {
514
const response = await page.goto('http://127.0.0.1:3333/ipfs/bafybeidbclfqleg2uojchspzd4bob56dqetqjsj27gy2cq3klkkgxtpn4i/685.txt')
615

0 commit comments

Comments
 (0)