Skip to content

Commit

Permalink
fix: enforce origin isolation on subdomain gws
Browse files Browse the repository at this point in the history
Towards #30
  • Loading branch information
lidel committed Feb 28, 2024
1 parent 1ef0094 commit 700ddbd
Show file tree
Hide file tree
Showing 7 changed files with 118 additions and 14 deletions.
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])
const config = await getConfig()

Expand Down
1 change: 1 addition & 0 deletions src/lib/heliaFetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ export async function heliaFetch ({ path, helia, signal, headers, id, protocol }
const verifiedFetch = await createVerifiedFetch({
gateways: [...config.gateways, 'https://trustless-gateway.link'],
routers: [...config.routers, 'https://delegated-ipfs.dev'],
// @ts-expect-error dnsResolvers' does not exist in type 'Helia | CreateVerifiedFetchInit'
dnsResolvers: ['https://delegated-ipfs.dev/dns-query'].map(dnsJsonOverHttps)
}, {
contentTypeParser
Expand Down
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> => {
// 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
}
}

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()
// /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/redirectPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { HeliaServiceWorkerCommsChannel } from './lib/channel.ts'
import { setConfig, type ConfigDb } from './lib/config-db.ts'
import { getSubdomainParts } from './lib/get-subdomain-parts'
import { error } from './lib/logger.ts'
import { findOriginIsolationRedirect } from './lib/path-or-subdomain.ts'

const ConfigIframe = (): JSX.Element => {
const { parentDomain } = getSubdomainParts(window.location.href)
Expand All @@ -22,6 +23,19 @@ export default function RedirectPage (): JSX.Element {
const [isAutoReloadEnabled, setIsAutoReloadEnabled] = useState(false)
const { isServiceWorkerRegistered } = useContext(ServiceWorkerContext)

/* TODO:: we can enable this after we have means of caching the check result
useEffect(() => {
async function originEnforcement (): Promise<void> {
// enforce early when loaded before SW was registered
const originRedirect = await findOriginIsolationRedirect(window.location)
if (originRedirect !== null) {
window.location.replace(originRedirect)
}
}
void originEnforcement()
}, [])
*/

useEffect(() => {
async function doWork (config: ConfigDb): Promise<void> {
try {
Expand Down
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
4 changes: 4 additions & 0 deletions 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

0 comments on commit 700ddbd

Please sign in to comment.