Skip to content

Commit d62632e

Browse files
authored
fix: support base16 CIDs and IPNS names (#913)
Adds support for decoding base16 keys from path gateway requests and normalizng them to base32 (for CIDs) or base36 (for IPNS names). Also fixes a bug whereby we'd treat anything that didn't parse as a CID/PeerId as a DNSLink name, even `/ipfs/random-garbage`. Fixes #745
1 parent d80d104 commit d62632e

File tree

6 files changed

+151
-44
lines changed

6 files changed

+151
-44
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
}
5353
},
5454
"dependencies": {
55+
"@chainsafe/is-ip": "^2.1.0",
5556
"@chainsafe/libp2p-noise": "^17.0.0",
5657
"@chainsafe/libp2p-yamux": "^8.0.1",
5758
"@helia/block-brokers": "^5.0.7",

src/lib/path-or-subdomain.ts

Lines changed: 87 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
1+
import { isIP } from '@chainsafe/is-ip'
2+
import { InvalidParametersError } from '@libp2p/interface'
13
import { peerIdFromString } from '@libp2p/peer-id'
4+
import { base16, base16upper } from 'multiformats/bases/base16'
25
import { base32 } from 'multiformats/bases/base32'
36
import { base36 } from 'multiformats/bases/base36'
7+
import { base58btc } from 'multiformats/bases/base58'
48
import { CID } from 'multiformats/cid'
59
import { dnsLinkLabelEncoder } from './dns-link-labels.js'
610
import { getHeliaSwRedirectUrl } from './first-hit-helpers.js'
711
import { pathRegex, subdomainRegex } from './regex.js'
812
import type { Logger } from '@libp2p/interface'
13+
import type { MultibaseDecoder } from 'multiformats/cid'
914

1015
export const isPathOrSubdomainRequest = (location: Pick<Location, 'host' | 'pathname'>): boolean => {
1116
return isPathGatewayRequest(location) || isSubdomainGatewayRequest(location)
@@ -28,25 +33,40 @@ export const isPathGatewayRequest = (location: Pick<Location, 'host' | 'pathname
2833
export const findOriginIsolationRedirect = (location: Pick<Location, 'protocol' | 'host' | 'pathname' | 'search' | 'hash' | 'href' | 'origin'>, logger: Logger, supportsSubdomains: boolean | null): string | null => {
2934
const log = logger?.newScope('find-origin-isolation-redirect')
3035

31-
if (isSubdomainGatewayRequest(location)) {
32-
// already on an isolated origin
33-
return null
34-
}
36+
try {
37+
if (isSubdomainGatewayRequest(location)) {
38+
// already on an isolated origin
39+
return null
40+
}
3541

36-
if (isPathGatewayRequest(location)) {
37-
log?.trace('checking for subdomain support')
42+
if (isPathGatewayRequest(location)) {
43+
log?.trace('checking for subdomain support')
3844

39-
if (supportsSubdomains === true) {
40-
log?.trace('subdomain support is enabled')
41-
return toSubdomainRequest(location)
45+
if (supportsSubdomains === true) {
46+
log?.trace('subdomain support is enabled')
47+
return toSubdomainRequest(location)
48+
}
4249
}
50+
} catch (err) {
51+
log.error('could not parse gateway request from URL - %e', err)
4352
}
4453

4554
// not path/subdomain request - we are showing UI
4655
return null
4756
}
4857

58+
/**
59+
* Turns a path gateway url into a subdomain gateway url.
60+
*
61+
* Throws an `InvalidParametersError` if the user supplied a bad CID or path
62+
* or `Error` if subdomains are unsupported because (for example) the gateway
63+
* is accessed via an IP address instead of a domain.
64+
*/
4965
export const toSubdomainRequest = (location: Pick<Location, 'protocol' | 'host' | 'pathname' | 'search' | 'hash' | 'href' | 'origin'>): string => {
66+
if (isIP(location.host)) {
67+
throw new Error('Host was an IP address so subdomains are unsupported')
68+
}
69+
5070
const segments = location.pathname
5171
.split('/')
5272
.filter(segment => segment !== '')
@@ -57,7 +77,7 @@ export const toSubdomainRequest = (location: Pick<Location, 'protocol' | 'host'
5777
}
5878

5979
if (segments.length < 2) {
60-
throw new Error(`Invalid location ${location}`)
80+
throw new InvalidParametersError(`Invalid location ${location}`)
6181
}
6282

6383
const ns = segments[0]
@@ -66,40 +86,38 @@ export const toSubdomainRequest = (location: Pick<Location, 'protocol' | 'host'
6686
// DNS labels are case-insensitive, and the length limit is 63.
6787
// We ensure base32 if CID, base36 if ipns,
6888
// or inlined according to https://specs.ipfs.tech/http-gateways/subdomain-gateway/#host-request-header if DNSLink name
69-
try {
70-
switch (ns) {
71-
case 'ipfs':
72-
// Base32 is case-insensitive and allows CID with popular hashes like
73-
// sha2-256 to fit in a single DNS label
74-
id = CID.parse(id).toV1().toString(base32)
75-
break
76-
case 'ipns':
77-
if (id.startsWith('Q') || id.startsWith('1')) {
78-
// possibly a PeerId - non-standard but try converting to a CID
79-
try {
80-
const peerId = peerIdFromString(id)
81-
id = peerId.toCID().toString()
82-
} catch {}
83-
}
84-
89+
switch (ns) {
90+
case 'ipfs':
91+
// Base32 is case-insensitive and allows CID with popular hashes like
92+
// sha2-256 to fit in a single DNS label
93+
id = parseCid(id).toString(base32)
94+
break
95+
case 'ipns':
96+
if (id.startsWith('Q') || id.startsWith('1')) {
97+
// possibly a PeerId - non-standard but try converting to a CID
98+
try {
99+
const peerId = peerIdFromString(id)
100+
id = peerId.toCID().toString()
101+
} catch {}
102+
}
103+
104+
try {
85105
// IPNS Names are represented as Base36 CIDv1 with libp2p-key codec
86106
// https://specs.ipfs.tech/ipns/ipns-record/#ipns-name
87-
// eslint-disable-next-line no-case-declarations
88-
const ipnsName = CID.parse(id).toV1()
107+
89108
// /ipns/ namespace uses Base36 instead of 32 because ED25519 keys need
90109
// to fit in DNS label of max length 63
91-
id = ipnsName.toString(base36)
92-
break
93-
default:
94-
throw new Error('Unknown namespace: ' + ns)
95-
}
96-
} catch {
97-
// not a CID, so we assume a DNSLink name and inline it according to
98-
// https://specs.ipfs.tech/http-gateways/subdomain-gateway/#host-request-header
99-
if (id?.includes('.') === true) {
100-
id = dnsLinkLabelEncoder(id)
101-
}
110+
id = parseCid(id).toString(base36)
111+
} catch {
112+
// not a CID, so we assume a DNSLink name and inline it according to
113+
// https://specs.ipfs.tech/http-gateways/subdomain-gateway/#host-request-header
114+
id = dnsLinkLabelEncoder(id)
115+
}
116+
break
117+
default:
118+
throw new InvalidParametersError('Unknown namespace: "' + ns + '"')
102119
}
120+
103121
const remainingPath = `/${segments.slice(2).join('/')}`
104122

105123
// create new URL with the subdomain but without the path
@@ -108,10 +126,40 @@ export const toSubdomainRequest = (location: Pick<Location, 'protocol' | 'host'
108126
const modifiedOriginalUrl = new URL(location.href)
109127
modifiedOriginalUrl.pathname = remainingPath
110128
modifiedOriginalUrl.hash = location.hash
129+
111130
const originalSearchParams = new URLSearchParams(location.search)
112131
originalSearchParams.forEach((value, key) => {
113132
modifiedOriginalUrl.searchParams.set(key, value)
114133
})
115134

116135
return getHeliaSwRedirectUrl(modifiedOriginalUrl, newLocation).href
117136
}
137+
138+
/**
139+
* Parses the string as a CID and converts it to v1. Translates any thrown error
140+
* to an `InvalidParametersError` so we can show a 400 screen to the user.
141+
*/
142+
function parseCid (str: string): CID<any, any, any, 1> {
143+
try {
144+
return CID.parse(str, findMultibaseDecoder(str)).toV1()
145+
} catch (err: any) {
146+
throw new InvalidParametersError(`Could not parse CID from string "${str}" - ${err.message}`)
147+
}
148+
}
149+
150+
function findMultibaseDecoder (str: string): MultibaseDecoder<string> | undefined {
151+
const prefix = str.substring(0, 1)
152+
153+
switch (prefix) {
154+
case 'b':
155+
return base32
156+
case 'f':
157+
return base16
158+
case 'F':
159+
return base16upper
160+
case 'z':
161+
return base58btc
162+
default:
163+
// do nothing
164+
}
165+
}

src/sw/handlers/content-request-handler.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { getInstallTime } from '../lib/install-time.js'
1313
import { getVerifiedFetch } from '../lib/verified-fetch.js'
1414
import { fetchErrorPageResponse } from '../pages/fetch-error-page.js'
1515
import { originIsolationWarningPageResponse } from '../pages/origin-isolation-warning-page.js'
16+
import { serverErrorPageResponse } from '../pages/server-error-page.js'
1617
import type { Handler } from './index.js'
1718
import type { UrlParts } from '../../lib/get-subdomain-parts.js'
1819
import type { Providers } from '../index.js'
@@ -169,7 +170,15 @@ async function fetchHandler ({ url, event, logs, subdomainGatewayRequest, pathGa
169170
}
170171
})
171172
}
172-
} catch {
173+
} catch (err: any) {
174+
// the user supplied an unparseable/incorrect path or key
175+
if (err.name === 'InvalidParametersError') {
176+
return serverErrorPageResponse(url, err, logs, {
177+
title: '400 Bad Request',
178+
status: 400
179+
})
180+
}
181+
173182
// URL was invalid (may have been an IP address which can't be translated
174183
// to a subdomain gateway URL)
175184
}

src/sw/pages/server-error-page.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,26 @@ function toErrorObject (error: any): any {
1717
}
1818
}
1919

20+
export interface ServerErrorPageResponseOptions {
21+
/**
22+
* HTTP status code
23+
*
24+
* @default 500
25+
*/
26+
status?: number
27+
28+
/**
29+
* Error title to show on page
30+
*
31+
* @default '500 Internal Server Error'
32+
*/
33+
title?: string
34+
}
35+
2036
/**
2137
* Shows an error page to the user
2238
*/
23-
export function serverErrorPageResponse (url: URL, error: Error, logs: string[]): Response {
39+
export function serverErrorPageResponse (url: URL, error: Error, logs: string[], opts?: ServerErrorPageResponseOptions): Response {
2440
const headers = new Headers()
2541
headers.set('content-type', 'text/html')
2642
headers.set('x-debug-request-uri', url.toString())
@@ -29,14 +45,14 @@ export function serverErrorPageResponse (url: URL, error: Error, logs: string[])
2945
const props = {
3046
url: url.toString(),
3147
error: toErrorObject(error),
32-
title: '500 Internal Server Error',
48+
title: opts?.title ?? '500 Internal Server Error',
3349
logs
3450
}
3551

3652
const page = htmlPage(props.title, 'serverError', props)
3753

3854
return new Response(page, {
39-
status: 500,
55+
status: opts?.status ?? 500,
4056
headers
4157
})
4258
}

test-e2e/fixtures/load-with-service-worker.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export async function loadWithServiceWorker (page: Page, resource: string, optio
4646
}
4747

4848
// ignore redirects by status
49-
if (response.status() > 299 || response.status() < 200) {
49+
if (response.status() > 299 && response.status() < 399) {
5050
return false
5151
}
5252

test-e2e/smoke-test.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import { peerIdFromString } from '@libp2p/peer-id'
2+
import { base16 } from 'multiformats/bases/base16'
3+
import { CID } from 'multiformats/cid'
14
import { CURRENT_CACHES } from '../src/constants.js'
25
import { HASH_FRAGMENTS } from '../src/lib/constants.js'
36
import { testSubdomainRouting as test, expect } from './fixtures/config-test-fixtures.js'
@@ -166,4 +169,34 @@ test.describe('smoke test', () => {
166169

167170
expect(noServiceWorkerRegistration).toBe(true)
168171
})
172+
173+
test('normalizes base16 CIDs to base32', async ({ page, protocol, rootDomain }) => {
174+
const cid = 'bafkqablimvwgy3y'
175+
const asBase16 = CID.parse(cid).toString(base16)
176+
177+
const response = await loadWithServiceWorker(page, `${protocol}//${rootDomain}/ipfs/${asBase16}`, {
178+
redirect: `${protocol}//${cid}.ipfs.${rootDomain}/`
179+
})
180+
expect(response.status()).toBe(200)
181+
expect(await response.text()).toContain('hello')
182+
})
183+
184+
test('normalizes base16 IPNS names to base36', async ({ page, protocol, rootDomain }) => {
185+
const key = 'k51qzi5uqu5dk3v4rmjber23h16xnr23bsggmqqil9z2gduiis5se8dht36dam'
186+
const asBase16 = peerIdFromString(key).toCID().toString(base16)
187+
188+
const response = await loadWithServiceWorker(page, `${protocol}//${rootDomain}/ipns/${asBase16}`, {
189+
redirect: `${protocol}//${key}.ipns.${rootDomain}/`
190+
})
191+
expect(response.status()).toBe(200)
192+
expect(await response.text()).toContain('hello')
193+
})
194+
195+
test('errors on invalid CIDs', async ({ page, protocol, rootDomain }) => {
196+
const cid = '!bafkqablimvwgy3y'
197+
198+
const response = await loadWithServiceWorker(page, `${protocol}//${rootDomain}/ipfs/${cid}`)
199+
expect(response.status()).toBe(400)
200+
expect(await response.text()).toContain('Could not parse CID')
201+
})
169202
})

0 commit comments

Comments
 (0)