@atproto/xrpc-server 0.6.4 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/util.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import assert from 'node:assert'
2
- import { Duplex, PassThrough, pipeline, Readable } from 'node:stream'
2
+ import { Duplex, pipeline, Readable } from 'node:stream'
3
3
  import { IncomingMessage } from 'node:http'
4
- import * as zlib from 'node:zlib'
5
4
  import express from 'express'
6
5
  import mime from 'mime-types'
7
6
  import {
@@ -11,7 +10,7 @@ import {
11
10
  LexXrpcQuery,
12
11
  LexXrpcSubscription,
13
12
  } from '@atproto/lexicon'
14
- import { MaxSizeChecker } from '@atproto/common'
13
+ import { createDecoders, MaxSizeChecker } from '@atproto/common'
15
14
  import { ResponseType } from '@atproto/xrpc'
16
15
 
17
16
  import {
@@ -157,7 +156,7 @@ export function validateOutput(
157
156
  def: LexXrpcProcedure | LexXrpcQuery,
158
157
  output: HandlerSuccess | undefined,
159
158
  lexicons: Lexicons,
160
- ): HandlerSuccess | undefined {
159
+ ): void {
161
160
  // initial validation
162
161
  if (output) {
163
162
  handlerSuccess.parse(output)
@@ -197,8 +196,6 @@ export function validateOutput(
197
196
  throw new InternalServerError(e instanceof Error ? e.message : String(e))
198
197
  }
199
198
  }
200
-
201
- return output
202
199
  }
203
200
 
204
201
  export function normalizeMime(v: string) {
@@ -261,7 +258,17 @@ function decodeBodyStream(
261
258
  )
262
259
  }
263
260
 
264
- const transforms: Duplex[] = createDecoders(contentEncoding)
261
+ let transforms: Duplex[]
262
+ try {
263
+ transforms = createDecoders(contentEncoding)
264
+ } catch (cause) {
265
+ throw new XRPCError(
266
+ ResponseType.UnsupportedMediaType,
267
+ 'unsupported content-encoding',
268
+ undefined,
269
+ { cause },
270
+ )
271
+ }
265
272
 
266
273
  if (maxSize !== undefined) {
267
274
  const maxSizeChecker = new MaxSizeChecker(
@@ -277,54 +284,6 @@ function decodeBodyStream(
277
284
  : req
278
285
  }
279
286
 
280
- export function createDecoders(contentEncoding?: string | string[]): Duplex[] {
281
- return parseContentEncoding(contentEncoding).reverse().map(createDecoder)
282
- }
283
-
284
- export function parseContentEncoding(
285
- contentEncoding?: string | string[],
286
- ): string[] {
287
- // undefined, empty string, and empty array
288
- if (!contentEncoding?.length) return []
289
-
290
- // Non empty string
291
- if (typeof contentEncoding === 'string') {
292
- return contentEncoding
293
- .split(',')
294
- .map((x) => x.trim().toLowerCase())
295
- .filter((x) => x && x !== 'identity')
296
- }
297
-
298
- // content-encoding should never be an array
299
- return contentEncoding.flatMap(parseContentEncoding)
300
- }
301
-
302
- export function createDecoder(encoding: string): Duplex {
303
- // https://www.rfc-editor.org/rfc/rfc7231#section-3.1.2.1
304
- // > All content-coding values are case-insensitive...
305
- switch (encoding.trim().toLowerCase()) {
306
- // https://www.rfc-editor.org/rfc/rfc9112.html#section-7.2
307
- case 'gzip':
308
- case 'x-gzip':
309
- return zlib.createGunzip({
310
- // using Z_SYNC_FLUSH (cURL default) to be less strict when decoding
311
- flush: zlib.constants.Z_SYNC_FLUSH,
312
- finishFlush: zlib.constants.Z_SYNC_FLUSH,
313
- })
314
- case 'deflate':
315
- return zlib.createInflate()
316
- case 'br':
317
- return zlib.createBrotliDecompress()
318
- case 'identity':
319
- return new PassThrough()
320
- default:
321
- throw new XRPCError(
322
- ResponseType.UnsupportedMediaType,
323
- 'unsupported content-encoding',
324
- )
325
- }
326
- }
327
-
328
287
  export function serverTimingHeader(timings: ServerTiming[]) {
329
288
  return timings
330
289
  .map((timing) => {
@@ -360,11 +319,70 @@ export interface ServerTiming {
360
319
  description?: string
361
320
  }
362
321
 
363
- export const parseReqNsid = (
364
- req: express.Request | IncomingMessage,
365
- ): string => {
366
- const originalUrl =
367
- ('originalUrl' in req && req.originalUrl) || req.url || '/'
368
- const nsid = originalUrl.split('?')[0].replace('/xrpc/', '')
369
- return nsid.endsWith('/') ? nsid.slice(0, -1) : nsid // trim trailing slash
322
+ export const parseReqNsid = (req: express.Request | IncomingMessage) =>
323
+ parseUrlNsid('originalUrl' in req ? req.originalUrl : req.url || '/')
324
+
325
+ /**
326
+ * Validates and extracts the nsid from an xrpc path
327
+ */
328
+ export const parseUrlNsid = (url: string): string => {
329
+ // /!\ Hot path
330
+
331
+ if (
332
+ // Ordered by likelihood of failure
333
+ url.length <= 6 ||
334
+ url[5] !== '/' ||
335
+ url[4] !== 'c' ||
336
+ url[3] !== 'p' ||
337
+ url[2] !== 'r' ||
338
+ url[1] !== 'x' ||
339
+ url[0] !== '/'
340
+ ) {
341
+ throw new InvalidRequestError('invalid xrpc path')
342
+ }
343
+
344
+ const startOfNsid = 6
345
+
346
+ let curr = startOfNsid
347
+ let char: number
348
+ let alphaNumRequired = true
349
+ for (; curr < url.length; curr++) {
350
+ char = url.charCodeAt(curr)
351
+ if (
352
+ (char >= 48 && char <= 57) || // 0-9
353
+ (char >= 65 && char <= 90) || // A-Z
354
+ (char >= 97 && char <= 122) // a-z
355
+ ) {
356
+ alphaNumRequired = false
357
+ } else if (char === 45 /* "-" */ || char === 46 /* "." */) {
358
+ if (alphaNumRequired) {
359
+ throw new InvalidRequestError('invalid xrpc path')
360
+ }
361
+ alphaNumRequired = true
362
+ } else if (char === 47 /* "/" */) {
363
+ // Allow trailing slash (next char is either EOS or "?")
364
+ if (curr === url.length - 1 || url.charCodeAt(curr + 1) === 63) {
365
+ break
366
+ }
367
+ throw new InvalidRequestError('invalid xrpc path')
368
+ } else if (char === 63 /* "?"" */) {
369
+ break
370
+ } else {
371
+ throw new InvalidRequestError('invalid xrpc path')
372
+ }
373
+ }
374
+
375
+ // last char was one of: '-', '.', '/'
376
+ if (alphaNumRequired) {
377
+ throw new InvalidRequestError('invalid xrpc path')
378
+ }
379
+
380
+ // A domain name consists of minimum two characters
381
+ if (curr - startOfNsid < 2) {
382
+ throw new InvalidRequestError('invalid xrpc path')
383
+ }
384
+
385
+ // @TODO is there a max ?
386
+
387
+ return url.slice(startOfNsid, curr)
370
388
  }
@@ -0,0 +1,89 @@
1
+ import { parseUrlNsid } from '../src/util'
2
+
3
+ const testValid = (url: string, expected: string) => {
4
+ expect(parseUrlNsid(url)).toBe(expected)
5
+ }
6
+
7
+ const testInvalid = (url: string, errorMessage = 'invalid xrpc path') => {
8
+ expect(() => parseUrlNsid(url)).toThrow(errorMessage)
9
+ }
10
+
11
+ describe('parseUrlNsid', () => {
12
+ it('should extract the NSID from the URL', () => {
13
+ testValid('/xrpc/blee.blah.bloo', 'blee.blah.bloo')
14
+ testValid('/xrpc/blee.blah.bloo?foo[]', 'blee.blah.bloo')
15
+ testValid('/xrpc/blee.blah.bloo?foo=bar', 'blee.blah.bloo')
16
+ testValid('/xrpc/com.example.nsid', 'com.example.nsid')
17
+ testValid('/xrpc/com.example.nsid?foo=bar', 'com.example.nsid')
18
+ testValid('/xrpc/com.example-domain.nsid', 'com.example-domain.nsid')
19
+ })
20
+
21
+ it('should allow a trailing slash', () => {
22
+ testValid('/xrpc/blee.blah.bloo/?', 'blee.blah.bloo')
23
+ testValid('/xrpc/blee.blah.bloo/?foo=', 'blee.blah.bloo')
24
+ testValid('/xrpc/blee.blah.bloo/?bool', 'blee.blah.bloo')
25
+ testValid('/xrpc/com.example.nsid/', 'com.example.nsid')
26
+ })
27
+
28
+ it('should throw an error if the URL is too short', () => {
29
+ testInvalid('/xrpc/a')
30
+ })
31
+
32
+ it('should throw an error if the URL is empty', () => {
33
+ testInvalid('')
34
+ })
35
+
36
+ it('should throw an error if the URL is missing the NSID', () => {
37
+ testInvalid('/xrpc/')
38
+ testInvalid('/xrpc/?')
39
+ testInvalid('/xrpc/?foo=bar')
40
+ })
41
+
42
+ it('should throw an error if the URL contains extra path segments', () => {
43
+ testInvalid('/xrpc/123/extra')
44
+ testInvalid('/xrpc/123/extra?foo=bar')
45
+ })
46
+
47
+ it('should throw an error if the URL is missing the XRPC path prefix', () => {
48
+ testInvalid('/foo/123')
49
+ testInvalid('/foo/com.example.nsid')
50
+ })
51
+
52
+ it('should throw an error if the NSID starts with a dot', () => {
53
+ testInvalid('/xrpc/.')
54
+ testInvalid('/xrpc/..')
55
+ testInvalid('/xrpc/....')
56
+ testInvalid('/xrpc/.com.example.nsid')
57
+ testInvalid('/xrpc/com..example.nsid')
58
+ testInvalid('/xrpc/com.example..nsid')
59
+ testInvalid('/xrpc/com.example.nsid.')
60
+ testInvalid('/xrpc/com.example.nsid./')
61
+ testInvalid('/xrpc/com.example.nsid.?foo=bar')
62
+ testInvalid('/xrpc/com.example.nsid./?foo=bar')
63
+ })
64
+
65
+ it('should throw an error if the NSID contains a misplaced dash', () => {
66
+ testInvalid('/xrpc/-')
67
+ testInvalid('/xrpc/com.example.-nsid')
68
+ testInvalid('/xrpc/com.example-.nsid')
69
+ testInvalid('/xrpc/com.-example.nsid')
70
+ testInvalid('/xrpc/com.-example-.nsid')
71
+ testInvalid('/xrpc/com.example.nsid-')
72
+ testInvalid('/xrpc/-com.example.nsid')
73
+ testInvalid('/xrpc/com.example--domain.nsid')
74
+ })
75
+
76
+ it('should throw an error if the URL starts with a space', () => {
77
+ testInvalid(' /xrpc/com.example.nsid')
78
+ })
79
+
80
+ it('should throw an error if the NSID contains invalid characters', () => {
81
+ testInvalid('/xrpc/com.example.nsid#')
82
+ testInvalid('/xrpc/com.example.nsid!')
83
+ testInvalid('/xrpc/com.example#?nsid')
84
+ testInvalid('/xrpc/!com.example.nsid')
85
+ testInvalid('/xrpc/com.example.nsid ')
86
+ testInvalid('/xrpc/ com.example.nsid')
87
+ testInvalid('/xrpc/com. example.nsid')
88
+ })
89
+ })