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

helpers for rkey and tid syntax; validate rkey at record creation time #1738

Merged
merged 5 commits into from
Dec 1, 2023
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions interop-test-files/syntax/tid_syntax_invalid.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@

# not base32
3jzfcijpj2z21
0000000000000

# too long/short
3jzfcijpj2z2aa
3jzfcijpj2z2

# old dashes syntax not actually supported (TTTT-TTT-TTTT-CC)
3jzf-cij-pj2z-2a

# high bit can't be high
zzzzzzzzzzzzz
kjzfcijpj2z2a
6 changes: 6 additions & 0 deletions interop-test-files/syntax/tid_syntax_valid.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# 13 digits
# 234567abcdefghijklmnopqrstuvwxyz

3jzfcijpj2z2a
7777777777777
3zzzzzzzzzzzz
4 changes: 4 additions & 0 deletions packages/pds/src/api/com/atproto/repo/createRecord.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { CID } from 'multiformats/cid'
import { InvalidRequestError, AuthRequiredError } from '@atproto/xrpc-server'
import { InvalidRecordKeyError } from '@atproto/syntax'
import { prepareCreate } from '../../../../repo'
import { Server } from '../../../../lexicon'
import {
Expand Down Expand Up @@ -59,6 +60,9 @@ export default function (server: Server, ctx: AppContext) {
if (err instanceof InvalidRecordError) {
throw new InvalidRequestError(err.message)
}
if (err instanceof InvalidRecordKeyError) {
throw new InvalidRequestError(err.message)
}
throw err
}

Expand Down
4 changes: 3 additions & 1 deletion packages/pds/src/repo/prepare.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { CID } from 'multiformats/cid'
import { AtUri } from '@atproto/syntax'
import { AtUri, ensureValidRecordKey } from '@atproto/syntax'
import { MINUTE, TID, dataToCborBlock } from '@atproto/common'
import {
LexiconDefNotFoundError,
Expand Down Expand Up @@ -170,6 +170,8 @@ export const prepareCreate = async (opts: {
}

const rkey = opts.rkey || nextRkey.toString()
// @TODO: validate against Lexicon record 'key' type, not just overall recordkey syntax
ensureValidRecordKey(rkey)
Comment on lines +195 to +196
Copy link
Collaborator

Choose a reason for hiding this comment

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

Since this is coarse and lexicon-unaware, I wonder if we should push this check down into the repo code. If the PDS is lexicon-aware, it could enforce the stricter key type check as a part of record validation.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

That makes sense to me in terms of correctness.

repo: I'm not super confident what the blast radius of impact would be of verifying NSID+RecordKey would be in, eg, repo/src/util.ts parseDataKey(). That seems to run both on writes and reads? So could break things like the existing with-: record. We could initially check only in the create/write path, maybe creating a separate parseDataKeyPermissive as a migration/transition thing.

PDS: I expect PDS to be lexicon-aware enough to do generic Lexicon validation, possibly by fetching/resolving (and caching) new Lexicon files in the future. For that, passing rkey to the "validate record" function would make sense to me.

Copy link
Collaborator

Choose a reason for hiding this comment

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

@dholms any feels on this?

Copy link
Collaborator

Choose a reason for hiding this comment

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

I think it's a little bit more comfortable living here for the time being.

We don't do NSID validation on collection names in the repo code & that strikes me as similar to this.

So far the repo/MST is pretty unaware of these syntatic things & is primarily concerned with the tree structure & cbor encoding. Though I think there's a good argument for pushing some more lexicon/syntax awareness into it.

In short, I'd leave it here for now but consider adjusting where we draw the abstraction boundary

assertNoExplicitSlurs(rkey, record)
return {
action: WriteOpAction.Create,
Expand Down
16 changes: 16 additions & 0 deletions packages/pds/tests/crud.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,22 @@ describe('crud operations', () => {
)
})

it('requires valid rkey', async () => {
await expect(
aliceAgent.api.com.atproto.repo.createRecord({
repo: alice.did,
collection: 'app.bsky.feed.generator',
record: {
$type: 'app.bsky.feed.generator',
did: 'did:web:dummy.example.com',
displayName: 'dummy',
createdAt: new Date().toISOString(),
},
rkey: '..',
}),
).rejects.toThrow('record key can not be "." or ".."')
})

it('validates the record on write', async () => {
await expect(
aliceAgent.api.com.atproto.repo.createRecord({
Expand Down
2 changes: 1 addition & 1 deletion packages/syntax/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@ const base = require('../../jest.config.base.js')

module.exports = {
...base,
displayName: 'Identifier',
displayName: 'Syntax',
}
2 changes: 2 additions & 0 deletions packages/syntax/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ export * from './handle'
export * from './did'
export * from './nsid'
export * from './aturi'
export * from './tid'
export * from './recordkey'
26 changes: 26 additions & 0 deletions packages/syntax/src/recordkey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
export const ensureValidRecordKey = (rkey: string): void => {
if (rkey.length > 512 || rkey.length < 1) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

i'm pretty tempted to limit this to less than 512

I think we talked about 16 at one point (tho i think that's too low). but maybe 64?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I'm not totally against that, but there are a couple things to discuss. I opened an issue in the specs repo: bluesky-social/atproto-website#250

Copy link
Collaborator

Choose a reason for hiding this comment

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

cool cool - that doesn't have to hold this up 👍

throw new InvalidRecordKeyError('record key must be 1 to 512 characters')
}
// simple regex to enforce most constraints via just regex and length.
if (!/^[a-zA-Z0-9_~.-]{1,512}$/.test(rkey)) {
throw new InvalidRecordKeyError('record key syntax not valid (regex)')
}
if (rkey == '.' || rkey == '..')
throw new InvalidRecordKeyError('record key can not be "." or ".."')
}

export const isValidRecordKey = (rkey: string): boolean => {
try {
ensureValidRecordKey(rkey)
} catch (err) {
if (err instanceof InvalidRecordKeyError) {
return false
}
throw err
}

return true
}

export class InvalidRecordKeyError extends Error {}
24 changes: 24 additions & 0 deletions packages/syntax/src/tid.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
export const ensureValidTid = (tid: string): void => {
if (tid.length != 13) {
throw new InvalidTidError('TID must be 13 characters')
}
// simple regex to enforce most constraints via just regex and length.
if (!/^[234567abcdefghij][234567abcdefghijklmnopqrstuvwxyz]{12}$/.test(tid)) {
throw new InvalidTidError('TID syntax not valid (regex)')
}
}

export const isValidTid = (tid: string): boolean => {
try {
ensureValidTid(tid)
} catch (err) {
if (err instanceof InvalidTidError) {
return false
}
throw err
}

return true
}

export class InvalidTidError extends Error {}
1 change: 1 addition & 0 deletions packages/syntax/tests/interop-files/tid_syntax_invalid.txt
1 change: 1 addition & 0 deletions packages/syntax/tests/interop-files/tid_syntax_valid.txt
42 changes: 42 additions & 0 deletions packages/syntax/tests/recordkey.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { ensureValidRecordKey, InvalidRecordKeyError } from '../src'
import * as readline from 'readline'
import * as fs from 'fs'

describe('recordkey validation', () => {
const expectValid = (r: string) => {
ensureValidRecordKey(r)
}
const expectInvalid = (r: string) => {
expect(() => ensureValidRecordKey(r)).toThrow(InvalidRecordKeyError)
}

it('conforms to interop valid recordkey', () => {
const lineReader = readline.createInterface({
input: fs.createReadStream(
`${__dirname}/interop-files/recordkey_syntax_valid.txt`,
),
terminal: false,
})
lineReader.on('line', (line) => {
if (line.startsWith('#') || line.length == 0) {
return
}
expectValid(line)
})
})

it('conforms to interop invalid recordkeys', () => {
const lineReader = readline.createInterface({
input: fs.createReadStream(
`${__dirname}/interop-files/recordkey_syntax_invalid.txt`,
),
terminal: false,
})
lineReader.on('line', (line) => {
if (line.startsWith('#') || line.length == 0) {
return
}
expectInvalid(line)
})
})
})
42 changes: 42 additions & 0 deletions packages/syntax/tests/tid.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { ensureValidTid, InvalidTidError } from '../src'
import * as readline from 'readline'
import * as fs from 'fs'

describe('tid validation', () => {
const expectValid = (t: string) => {
ensureValidTid(t)
}
const expectInvalid = (t: string) => {
expect(() => ensureValidTid(t)).toThrow(InvalidTidError)
}

it('conforms to interop valid tid', () => {
const lineReader = readline.createInterface({
input: fs.createReadStream(
`${__dirname}/interop-files/tid_syntax_valid.txt`,
),
terminal: false,
})
lineReader.on('line', (line) => {
if (line.startsWith('#') || line.length == 0) {
return
}
expectValid(line)
})
})

it('conforms to interop invalid tids', () => {
const lineReader = readline.createInterface({
input: fs.createReadStream(
`${__dirname}/interop-files/tid_syntax_invalid.txt`,
),
terminal: false,
})
lineReader.on('line', (line) => {
if (line.startsWith('#') || line.length == 0) {
return
}
expectInvalid(line)
})
})
})