Skip to content

Commit

Permalink
Properly validate atproto did:web (bluesky-social#2776)
Browse files Browse the repository at this point in the history
* Properly validate atproto did:web

* explicit why there is no protection against localhost fetches in did:web resolver
  • Loading branch information
matthieusieben authored Sep 5, 2024
1 parent f7cbfa4 commit cb4abbb
Show file tree
Hide file tree
Showing 10 changed files with 101 additions and 73 deletions.
5 changes: 5 additions & 0 deletions .changeset/breezy-ducks-allow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@atproto/did": patch
---

Disallow path component in Web DID's (as per spec)
5 changes: 5 additions & 0 deletions .changeset/hip-beds-flow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@atproto/did": patch
---

Properly parse localhost did:web
5 changes: 5 additions & 0 deletions .changeset/olive-rabbits-occur.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@atproto/did": patch
---

Code optimizations and documentation. Rename `check*` utility function to `assert*`.
49 changes: 29 additions & 20 deletions packages/did/src/atproto.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { InvalidDidError } from './did-error.js'
import { Did } from './did.js'
import {
checkDidPlc,
checkDidWeb,
assertDidPlc,
assertDidWeb,
DID_PLC_PREFIX,
DID_WEB_PREFIX,
isDidPlc,
isDidWeb,
} from './methods.js'

// This file contains atproto-specific DID validation utilities.
Expand All @@ -30,17 +29,17 @@ export function isAtprotoDid(input: unknown): input is AtprotoDid {
}

export function asAtprotoDid(input: unknown): AtprotoDid {
checkAtprotoDid(input)
assertAtprotoDid(input)
return input
}

export function checkAtprotoDid(input: unknown): asserts input is AtprotoDid {
export function assertAtprotoDid(input: unknown): asserts input is AtprotoDid {
if (typeof input !== 'string') {
throw new InvalidDidError(typeof input, `DID must be a string`)
} else if (input.startsWith(DID_PLC_PREFIX)) {
checkDidPlc(input)
assertDidPlc(input)
} else if (input.startsWith(DID_WEB_PREFIX)) {
checkDidWeb(input)
assertAtprotoDidWeb(input)
} else {
throw new InvalidDidError(
input,
Expand All @@ -49,27 +48,37 @@ export function checkAtprotoDid(input: unknown): asserts input is AtprotoDid {
}
}

/**
* @see {@link https://atproto.com/specs/did#blessed-did-methods}
*/
export function isAtprotoDidWeb(input: unknown): input is Did<'web'> {
// Optimization: make cheap checks first
if (typeof input !== 'string') {
return false
}
export function assertAtprotoDidWeb(
input: unknown,
): asserts input is Did<'web'> {
assertDidWeb(input)

// Path are not allowed
if (input.includes(':', DID_WEB_PREFIX.length)) {
return false
throw new InvalidDidError(
input,
`Atproto does not allow path components in Web DIDs`,
)
}

// Port numbers are not allowed, except for localhost
if (
input.includes('%3A', DID_WEB_PREFIX.length) &&
!input.startsWith('did:web:localhost%3A')
) {
return false
throw new InvalidDidError(
input,
`Atproto does not allow port numbers in Web DIDs, except for localhost`,
)
}
}

return isDidWeb(input)
/**
* @see {@link https://atproto.com/specs/did#blessed-did-methods}
*/
export function isAtprotoDidWeb(input: unknown): input is Did<'web'> {
try {
assertAtprotoDidWeb(input)
return true
} catch {
return false
}
}
40 changes: 16 additions & 24 deletions packages/did/src/did-document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,35 +117,27 @@ export type DidDocument<Method extends string = string> = z.infer<

// @TODO: add other refinements ?
export const didDocumentValidator = didDocumentSchema
.superRefine((data, ctx) => {
if (data.service) {
for (let i = 0; i < data.service.length; i++) {
if (data.service[i].id === data.id) {
// Ensure that every service id is unique
.superRefine(({ id: did, service }, ctx) => {
if (service) {
const visited = new Set()

for (let i = 0; i < service.length; i++) {
const current = service[i]

const serviceId = current.id.startsWith('#')
? `${did}${current.id}`
: current.id

if (!visited.has(serviceId)) {
visited.add(serviceId)
} else {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Service id must be different from the document id`,
message: `Duplicate service id (${current.id}) found in the document`,
path: ['service', i, 'id'],
})
}
}
}
})
.superRefine((data, ctx) => {
if (data.service) {
const normalizedIds = data.service.map((s) =>
s.id?.startsWith('#') ? `${data.id}${s.id}` : s.id,
)

for (let i = 0; i < normalizedIds.length; i++) {
for (let j = i + 1; j < normalizedIds.length; j++) {
if (normalizedIds[i] === normalizedIds[j]) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Duplicate service id (${normalizedIds[j]}) found in the document`,
path: ['service', j, 'id'],
})
}
}
}
}
})
16 changes: 8 additions & 8 deletions packages/did/src/did.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ type AsDidMethodInternal<
* Check if the input is a valid DID method name, at the position between
* `start` (inclusive) and `end` (exclusive).
*/
export function checkDidMethod(
export function assertDidMethod(
input: string,
start = 0,
end = input.length,
Expand Down Expand Up @@ -130,7 +130,7 @@ export function extractDidMethod<D extends Did>(did: D) {
* Check if the input is a valid DID method-specific identifier, at the position
* between `start` (inclusive) and `end` (exclusive).
*/
export function checkDidMsid(
export function assertDidMsid(
input: string,
start = 0,
end = input.length,
Expand Down Expand Up @@ -207,7 +207,7 @@ export function checkDidMsid(
}
}

export function checkDid(input: unknown): asserts input is Did {
export function assertDid(input: unknown): asserts input is Did {
if (typeof input !== 'string') {
throw new InvalidDidError(typeof input, `DID must be a string`)
}
Expand All @@ -226,13 +226,13 @@ export function checkDid(input: unknown): asserts input is Did {
throw new InvalidDidError(input, `Missing colon after method name`)
}

checkDidMethod(input, DID_PREFIX_LENGTH, idSep)
checkDidMsid(input, idSep + 1, length)
assertDidMethod(input, DID_PREFIX_LENGTH, idSep)
assertDidMsid(input, idSep + 1, length)
}

export function isDid(input: unknown): input is Did {
try {
checkDid(input)
assertDid(input)
return true
} catch (err) {
if (err instanceof DidError) {
Expand All @@ -245,15 +245,15 @@ export function isDid(input: unknown): input is Did {
}

export function asDid(input: unknown): Did {
checkDid(input)
assertDid(input)
return input
}

export const didSchema = z
.string()
.superRefine((value: string, ctx: z.RefinementCtx): value is Did => {
try {
checkDid(value)
assertDid(value)
return true
} catch (err) {
ctx.addIssue({
Expand Down
29 changes: 16 additions & 13 deletions packages/did/src/methods/plc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,22 @@ const DID_PLC_LENGTH = 32
export { DID_PLC_PREFIX }

export function isDidPlc(input: unknown): input is Did<'plc'> {
// Optimization: make cheap checks first
// Optimization: equivalent to try/catch around "assertDidPlc"
if (typeof input !== 'string') return false

try {
checkDidPlc(input)
return true
} catch {
return false
if (input.length !== DID_PLC_LENGTH) return false
if (!input.startsWith(DID_PLC_PREFIX)) return false
for (let i = DID_PLC_PREFIX_LENGTH; i < DID_PLC_LENGTH; i++) {
if (!isBase32Char(input.charCodeAt(i))) return false
}
return true
}

export function asDidPlc(input: unknown): Did<'plc'> {
checkDidPlc(input)
assertDidPlc(input)
return input
}

export function checkDidPlc(input: unknown): asserts input is Did<'plc'> {
export function assertDidPlc(input: unknown): asserts input is Did<'plc'> {
if (typeof input !== 'string') {
throw new InvalidDidError(typeof input, `DID must be a string`)
}
Expand All @@ -40,12 +39,16 @@ export function checkDidPlc(input: unknown): asserts input is Did<'plc'> {
throw new InvalidDidError(input, `Invalid did:plc prefix`)
}

let c: number
// The following check is not necessary, as the check below is more strict:

// assertDidMsid(input, DID_PLC_PREFIX.length)

for (let i = DID_PLC_PREFIX_LENGTH; i < DID_PLC_LENGTH; i++) {
c = input.charCodeAt(i)
// Base32 encoding ([a-z2-7])
if ((c < 0x61 || c > 0x7a) && (c < 0x32 || c > 0x37)) {
if (!isBase32Char(input.charCodeAt(i))) {
throw new InvalidDidError(input, `Invalid character at position ${i}`)
}
}
}

const isBase32Char = (c: number): boolean =>
(c >= 0x61 && c <= 0x7a) || (c >= 0x32 && c <= 0x37) // [a-z2-7]
16 changes: 10 additions & 6 deletions packages/did/src/methods/web.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { InvalidDidError } from '../did-error.js'
import { Did, checkDidMsid } from '../did.js'
import { Did, assertDidMsid } from '../did.js'

export const DID_WEB_PREFIX = `did:web:` satisfies Did<'web'>

Expand All @@ -11,19 +11,19 @@ export function isDidWeb(input: unknown): input is Did<'web'> {
if (typeof input !== 'string') return false

try {
checkDidWeb(input)
assertDidWeb(input)
return true
} catch {
return false
}
}

export function asDidWeb(input: unknown): Did<'web'> {
checkDidWeb(input)
assertDidWeb(input)
return input
}

export function checkDidWeb(input: unknown): asserts input is Did<'web'> {
export function assertDidWeb(input: unknown): asserts input is Did<'web'> {
if (typeof input !== 'string') {
throw new InvalidDidError(typeof input, `DID must be a string`)
}
Expand All @@ -41,12 +41,16 @@ export function didWebToUrl(did: string): URL {
}

// Make sure every char is valid (per DID spec)
checkDidMsid(did, DID_WEB_PREFIX.length)
assertDidMsid(did, DID_WEB_PREFIX.length)

try {
const msid = did.slice(DID_WEB_PREFIX.length)
const parts = msid.split(':').map(decodeURIComponent)
return new URL(`https://${parts.join('/')}`)
const url = new URL(`https://${parts.join('/')}`)
if (url.hostname === 'localhost') {
url.protocol = 'http:'
}
return url
} catch (cause) {
throw new InvalidDidError(did, 'Invalid Web DID', cause)
}
Expand Down
4 changes: 2 additions & 2 deletions packages/internal/did-resolver/src/methods/plc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
fetchOkProcessor,
} from '@atproto-labs/fetch'
import { pipe } from '@atproto-labs/pipe'
import { Did, checkDidPlc, didDocumentValidator } from '@atproto/did'
import { Did, assertDidPlc, didDocumentValidator } from '@atproto/did'

import { DidMethod, ResolveDidOptions } from '../did-method.js'

Expand Down Expand Up @@ -43,7 +43,7 @@ export class DidPlcMethod implements DidMethod<'plc'> {
async resolve(did: Did<'plc'>, options?: ResolveDidOptions) {
// Although the did should start with `did:plc:` (thanks to typings), we
// should still check if the msid is valid.
checkDidPlc(did)
assertDidPlc(did)

const url = new URL(`/${did}`, this.plcDirectoryUrl)

Expand Down
5 changes: 5 additions & 0 deletions packages/internal/did-resolver/src/methods/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ export class DidWebMethod implements DidMethod<'web'> {
async resolve(did: Did<'web'>, options?: ResolveDidOptions) {
const didDocumentUrl = buildDidWebDocumentUrl(did)

// Note we do not explicitly check for "localhost" here. Instead, we rely on
// the injected 'fetch' function to handle the URL. If the URL is
// "localhost", or resolves to a private IP address, the fetch function is
// responsible for handling it.

return this.fetch(didDocumentUrl, {
redirect: 'error',
headers: { accept: 'application/did+ld+json,application/json' },
Expand Down

0 comments on commit cb4abbb

Please sign in to comment.