Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: enforce origin isolation on subdomain gws #60

Merged
merged 10 commits into from
Feb 28, 2024
1 change: 1 addition & 0 deletions public/_redirects
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
/config /#/config 302
/* /?helia-sw=/:splat 302
5 changes: 2 additions & 3 deletions src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,14 @@ import React, { useContext } from 'react'
import Config from './components/config.tsx'
import { ConfigContext } from './context/config-context.tsx'
import HelperUi from './helper-ui.tsx'
import { isConfigPage } from './lib/is-config-page.ts'
import { isPathOrSubdomainRequest } from './lib/path-or-subdomain.ts'
import RedirectPage from './redirectPage.tsx'

function App (): JSX.Element {
const { isConfigExpanded, setConfigExpanded } = useContext(ConfigContext)
if (window.location.pathname === '/config') {
if (isConfigPage()) {
setConfigExpanded(true)
}
if (window.location.pathname === '/config') {
return <Config />
}

Expand Down
1 change: 1 addition & 0 deletions src/components/config.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export default (): JSX.Element | null => {
return
}
// we get the iframe origin from a query parameter called 'origin', if this is loaded in an iframe
// TODO: why we need this origin here? where is targetOrigin used?
const targetOrigin = decodeURIComponent(window.location.search.split('origin=')[1])
Comment on lines +47 to 48
Copy link
Member Author

Choose a reason for hiding this comment

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

@SgtPooki is this something we want to keep, or can it be removed?
iirc if we don't have query param, we would reuse cache, making subdomain page loads faster

Copy link
Member

@SgtPooki SgtPooki Feb 28, 2024

Choose a reason for hiding this comment

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

This is only for loading config in iframe. Iframe needs to know parent origin to postmessage.

Target origin (main window) is subdomain, iframe is root domain.

Copy link
Member

Choose a reason for hiding this comment

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

So if we can do that another way, we could remove this

Copy link
Member Author

Choose a reason for hiding this comment

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

Could we use #hash instead? It does not get sent to server, but JS in inframe should see it.

const config = await getConfig()

Expand Down
3 changes: 2 additions & 1 deletion src/context/config-context.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { createContext, useState } from 'react'
import { isConfigPage } from '../lib/is-config-page.ts'

const isLoadedInIframe = window.self !== window.top

Expand All @@ -9,7 +10,7 @@ export const ConfigContext = createContext({

export const ConfigProvider = ({ children, expanded = isLoadedInIframe }: { children: JSX.Element[] | JSX.Element, expanded?: boolean }): JSX.Element => {
const [isConfigExpanded, setConfigExpanded] = useState(expanded)
const isExplicitlyLoadedConfigPage = window.location.pathname === '/config'
const isExplicitlyLoadedConfigPage = isConfigPage()

const setConfigExpandedWrapped = (value: boolean): void => {
if (isLoadedInIframe || isExplicitlyLoadedConfigPage) {
Expand Down
3 changes: 2 additions & 1 deletion src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import App from './app.tsx'
import { ConfigProvider } from './context/config-context.tsx'
import { ServiceWorkerProvider } from './context/service-worker-context.tsx'
import { loadConfigFromLocalStorage } from './lib/config-db.ts'
import { isConfigPage } from './lib/is-config-page.ts'

await loadConfigFromLocalStorage()

Expand All @@ -16,7 +17,7 @@ const root = ReactDOMClient.createRoot(container)
root.render(
<React.StrictMode>
<ServiceWorkerProvider>
<ConfigProvider expanded={window.location.pathname === '/config'}>
<ConfigProvider expanded={isConfigPage()}>
<App />
</ConfigProvider>
</ServiceWorkerProvider>
Expand Down
5 changes: 5 additions & 0 deletions src/lib/is-config-page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export function isConfigPage (): boolean {
const isConfigPathname = window.location.pathname === '/config'
const isConfigHashPath = window.location.hash === '#/config' // needed for _redirects and IPFS hosted sw gateways
return isConfigPathname || isConfigHashPath
}
82 changes: 76 additions & 6 deletions src/lib/path-or-subdomain.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,84 @@
import { base32 } from 'multiformats/bases/base32'
import { base36 } from 'multiformats/bases/base36'
import { CID } from 'multiformats/cid'
import { dnsLinkLabelEncoder } from './dns-link-labels.ts'

// TODO: dry, this is same regex code as in getSubdomainParts
const subdomainRegex = /^(?<id>[^/]+)\.(?<protocol>ip[fn]s)\.[^/]+$/
const pathRegex = /^\/(?<protocol>ip[fn]s)\/(?<path>.*)$/

export const isPathOrSubdomainRequest = (location: Pick<Location, 'hostname' | 'pathname'>): boolean => {
const subdomain = location.hostname
const subdomainMatch = subdomain.match(subdomainRegex)
export const isPathOrSubdomainRequest = (location: Pick<Location, 'host' | 'pathname'>): boolean => {
return isPathGatewayRequest(location) || isSubdomainGatewayRequest(location)
}

export const isSubdomainGatewayRequest = (location: Pick<Location, 'host' | 'pathname'>): boolean => {
const subdomainMatch = location.host.match(subdomainRegex)
return subdomainMatch?.groups != null
}

export const isPathGatewayRequest = (location: Pick<Location, 'host' | 'pathname'>): boolean => {
const pathMatch = location.pathname.match(pathRegex)
const isPathBasedRequest = pathMatch?.groups != null
const isSubdomainRequest = subdomainMatch?.groups != null
return pathMatch?.groups != null
}

/**
* Origin isolation check and enforcement
* https://github.com/ipfs-shipyard/helia-service-worker-gateway/issues/30
*/
export const findOriginIsolationRedirect = async (location: Pick<Location, 'protocol' | 'host' | 'pathname' | 'search' | 'hash' >): Promise<string | null> => {
if (isPathGatewayRequest(location) && !isSubdomainGatewayRequest(location)) {
const redirect = await isSubdomainIsolationSupported(location)
if (redirect) {
return toSubdomainRequest(location)
}
}
return null
}

const isSubdomainIsolationSupported = async (location: Pick<Location, 'protocol' | 'host' | 'pathname'>): Promise<boolean> => {
Copy link
Member

Choose a reason for hiding this comment

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

its worth adding a comment here saying that for our SW usecase, we're only worried about checking for a 200 response and no TLS error.

// TODO: do this test once and expose it as cookie / config flag somehow
const testUrl = `${location.protocol}//bafkqaaa.ipfs.${location.host}`
try {
const response: Response = await fetch(testUrl)
return response.status === 200
} catch (_) {
return false
}
}
Comment on lines +38 to +47
Copy link
Member Author

@lidel lidel Feb 28, 2024

Choose a reason for hiding this comment

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

💭 this is not a big deal because it only happens once when on /ipfs/cid paths, is not executed when on subdomain host, but perhaps we could leverage config passing setup based on iframe introduced in #24


const toSubdomainRequest = (location: Pick<Location, 'protocol' | 'host' | 'pathname' | 'search' | 'hash'>): string => {
const segments = location.pathname.split('/').filter(segment => segment !== '')
const ns = segments[0]
let id = segments[1]

return isPathBasedRequest || isSubdomainRequest
// DNS labels are case-insensitive, and the length limit is 63.
// We ensure base32 if CID, base36 if ipns,
// or inlined according to https://specs.ipfs.tech/http-gateways/subdomain-gateway/#host-request-header if DNSLink name
try {
switch (ns) {
case 'ipfs':
// Base32 is case-insensitive and allows CID with popular hashes like sha2-256 to fit in a single DNS label
id = CID.parse(id).toV1().toString(base32)
break
case 'ipns':
// IPNS Names are represented as Base36 CIDv1 with libp2p-key codec
// https://specs.ipfs.tech/ipns/ipns-record/#ipns-name
// eslint-disable-next-line no-case-declarations
const ipnsName = CID.parse(id).toV1()
Copy link
Member

Choose a reason for hiding this comment

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

why are we CID.parse ing a dnslink/peerid?

Copy link
Member Author

Choose a reason for hiding this comment

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

Valid CIDv1 in base32 will be also a valid DNS name.
So we parse string as CID first, and ONLY assume DNS if CID parsing failed.

If we did it the other way, ISP or DNS operator could return spoofed DNS name that is also a valid CID, and do MITM on users who only use cryptographic identifies.

// /ipns/ namespace uses Base36 instead of 32 because ED25519 keys need to fit in DNS label of max length 63
id = ipnsName.toString(base36)
break
default:
throw new Error('Unknown namespace: ' + ns)
}
} catch (_) {
// not a CID, so we assume a DNSLink name and inline it according to
// https://specs.ipfs.tech/http-gateways/subdomain-gateway/#host-request-header
if (id.includes('.')) {
id = dnsLinkLabelEncoder(id)
}
}
const remainingPath = `/${segments.slice(2).join('/')}`
const newLocation = new URL(`${location.protocol}//${id}.${ns}.${location.host}${remainingPath}${location.search}${location.hash}`)
return newLocation.href
}
14 changes: 14 additions & 0 deletions src/sw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { HeliaServiceWorkerCommsChannel, type ChannelMessage } from './lib/chann
import { getSubdomainParts } from './lib/get-subdomain-parts.ts'
import { heliaFetch } from './lib/heliaFetch.ts'
import { error, log, trace } from './lib/logger.ts'
import { findOriginIsolationRedirect } from './lib/path-or-subdomain.ts'
import type { Helia } from '@helia/interface'

declare let self: ServiceWorkerGlobalScope
Expand Down Expand Up @@ -49,6 +50,19 @@ interface FetchHandlerArg {
}

const fetchHandler = async ({ path, request }: FetchHandlerArg): Promise<Response> => {
// test and enforce origin isolation before anything else is executed
const originLocation = await findOriginIsolationRedirect(new URL(request.url))
if (originLocation !== null) {
const body = 'Gateway supports subdomain mode, redirecting to ensure Origin isolation..'
return new Response(body, {
status: 301,
headers: {
'Content-Type': 'text/plain',
Location: originLocation
}
})
}

if (helia == null) {
helia = await getHelia()
}
Expand Down
16 changes: 8 additions & 8 deletions tests/path-or-subdomain.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,44 +5,44 @@ import { isPathOrSubdomainRequest } from '../src/lib/path-or-subdomain.ts'
describe('isPathOrSubdomainRequest', () => {
it('returns true for path-based request', () => {
expect(isPathOrSubdomainRequest({
hostname: 'example.com',
host: 'example.com',
pathname: '/ipfs/bafyFoo'
})).to.equal(true)
expect(isPathOrSubdomainRequest({
hostname: 'example.com',
host: 'example.com',
pathname: '/ipns/specs.ipfs.tech'
})).to.equal(true)
})

it('returns true for subdomain request', () => {
expect(isPathOrSubdomainRequest({
hostname: 'bafyFoo.ipfs.example.com',
host: 'bafyFoo.ipfs.example.com',
pathname: '/'
})).to.equal(true)
expect(isPathOrSubdomainRequest({
hostname: 'docs.ipfs.tech.ipns.example.com',
host: 'docs.ipfs.tech.ipns.example.com',
pathname: '/'
})).to.equal(true)
})

it('returns true for inlined dnslink subdomain request', () => {
expect(isPathOrSubdomainRequest({
hostname: 'bafyFoo.ipfs.example.com',
host: 'bafyFoo.ipfs.example.com',
pathname: '/'
})).to.equal(true)
expect(isPathOrSubdomainRequest({
hostname: 'specs-ipfs-tech.ipns.example.com',
host: 'specs-ipfs-tech.ipns.example.com',
pathname: '/'
})).to.equal(true)
})

it('returns false for non-path and non-subdomain request', () => {
expect(isPathOrSubdomainRequest({
hostname: 'example.com',
host: 'example.com',
pathname: '/foo/bar'
})).to.equal(false)
expect(isPathOrSubdomainRequest({
hostname: 'foo.bar.example.com',
host: 'foo.bar.example.com',
pathname: '/'
})).to.equal(false)
})
Expand Down
6 changes: 5 additions & 1 deletion webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ const dev = {
// Only update what has changed on hot reload
hot: true,
port: 3000,
headers: {
'access-control-allow-origin': '*',
'access-control-allow-methods': 'GET'
},
allowedHosts: ['helia-sw-gateway.localhost', 'localhost']
},

Expand Down Expand Up @@ -170,7 +174,7 @@ const common = {
// Generates an HTML file from a template
// Generates deprecation warning: https://github.com/jantimon/html-webpack-plugin/issues/1501
new HtmlWebpackPlugin({
title: 'Helia bundle by Webpack',
title: 'Helia service worker gateway',
favicon: paths.public + '/favicon.ico',
template: paths.public + '/index.html', // template file
filename: 'index.html', // output file,
Expand Down