[READ-ONLY] Mirror of https://github.com/flo-bit/contrail. atproto backend in a bottle flo-bit.dev/contrail/
0

Configure Feed

Select the types of activity you want to include in your feed.

fix(spaces): schema-validate PDS-record declarations

The PDS binding resolver trusted body.value.authority on any record
returned for the space's URI, with only a startsWith('did:') sniff. A
malformed or attacker-shaped record at the same NSID could rebind
authority — record content alone, no signature verification.

Validate before trusting:
- $type must equal the URI's type (the space-type lexicon embeds the
declaration fields per tools.atmo.space.declaration's contract)
- createdAt must be a string
- authority must match a strict did:plc | did:web regex

Fail closed on any deviation. Adds three negative tests covering each
deviation path.

Tom Scanlan (May 8, 2026, 10:05 AM EDT) 428feead be622324

+75 -3
+66
packages/contrail/tests/spaces-binding.test.ts
··· 158 158 }); 159 159 expect(await r.resolveAuthority(SPACE_URI)).toBeNull(); 160 160 }); 161 + 162 + it("returns null when the record's $type doesn't match the URI's type", async () => { 163 + const fetch = mockFetch( 164 + new Map([ 165 + [ 166 + "https://pds.test/xrpc/com.atproto.repo.getRecord", 167 + { 168 + value: { 169 + $type: "com.attacker.fake.type", 170 + authority: "did:web:authority.example", 171 + createdAt: "2026-04-30T00:00:00Z", 172 + }, 173 + }, 174 + ], 175 + ]) 176 + ); 177 + const r = createPdsBindingResolver({ 178 + resolver: mockResolver({ pdsEndpoint: "https://pds.test" }), 179 + fetch, 180 + }); 181 + expect(await r.resolveAuthority(SPACE_URI)).toBeNull(); 182 + }); 183 + 184 + it("returns null when createdAt is missing or non-string", async () => { 185 + const fetch = mockFetch( 186 + new Map([ 187 + [ 188 + "https://pds.test/xrpc/com.atproto.repo.getRecord", 189 + { 190 + value: { 191 + $type: "com.example.event.space", 192 + authority: "did:web:authority.example", 193 + // createdAt deliberately omitted 194 + }, 195 + }, 196 + ], 197 + ]) 198 + ); 199 + const r = createPdsBindingResolver({ 200 + resolver: mockResolver({ pdsEndpoint: "https://pds.test" }), 201 + fetch, 202 + }); 203 + expect(await r.resolveAuthority(SPACE_URI)).toBeNull(); 204 + }); 205 + 206 + it("returns null when authority isn't a well-formed DID", async () => { 207 + const fetch = mockFetch( 208 + new Map([ 209 + [ 210 + "https://pds.test/xrpc/com.atproto.repo.getRecord", 211 + { 212 + value: { 213 + $type: "com.example.event.space", 214 + authority: "did:fake!!://garbage", 215 + createdAt: "2026-04-30T00:00:00Z", 216 + }, 217 + }, 218 + ], 219 + ]) 220 + ); 221 + const r = createPdsBindingResolver({ 222 + resolver: mockResolver({ pdsEndpoint: "https://pds.test" }), 223 + fetch, 224 + }); 225 + expect(await r.resolveAuthority(SPACE_URI)).toBeNull(); 226 + }); 161 227 }); 162 228 163 229 describe("BindingResolver — DID-doc service entry", () => {
+9 -3
packages/contrail-base/src/spaces/binding.ts
··· 141 141 } 142 142 if (!res.ok) return null; 143 143 const body = (await res.json().catch(() => null)) as 144 - | { value?: { authority?: unknown } } 144 + | { value?: { $type?: unknown; authority?: unknown; createdAt?: unknown } } 145 145 | null; 146 - const authority = body?.value?.authority; 147 - return typeof authority === "string" && authority.startsWith("did:") ? authority : null; 146 + const value = body?.value; 147 + if (!value) return null; 148 + if (value.$type !== parts.type) return null; 149 + if (typeof value.createdAt !== "string") return null; 150 + const authority = value.authority; 151 + if (typeof authority !== "string") return null; 152 + if (!/^did:(plc|web):[a-zA-Z0-9._:%-]+(#[a-zA-Z0-9._-]+)?$/.test(authority)) return null; 153 + return authority; 148 154 }, 149 155 }; 150 156 }