@furystack/rest-service 14.1.2 → 14.1.4

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.
Files changed (71) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +26 -6
  3. package/esm/add-cors-header.d.ts.map +1 -1
  4. package/esm/add-cors-header.js +11 -0
  5. package/esm/add-cors-header.js.map +1 -1
  6. package/esm/add-cors-header.spec.js +25 -4
  7. package/esm/add-cors-header.spec.js.map +1 -1
  8. package/esm/endpoint-generators/create-patch-endpoint.d.ts.map +1 -1
  9. package/esm/endpoint-generators/create-patch-endpoint.js +2 -3
  10. package/esm/endpoint-generators/create-patch-endpoint.js.map +1 -1
  11. package/esm/endpoint-generators/create-post-endpoint.d.ts.map +1 -1
  12. package/esm/endpoint-generators/create-post-endpoint.js +2 -3
  13. package/esm/endpoint-generators/create-post-endpoint.js.map +1 -1
  14. package/esm/helpers.d.ts +18 -4
  15. package/esm/helpers.d.ts.map +1 -1
  16. package/esm/helpers.js +12 -6
  17. package/esm/helpers.js.map +1 -1
  18. package/esm/http-authentication-settings.d.ts +14 -0
  19. package/esm/http-authentication-settings.d.ts.map +1 -1
  20. package/esm/http-authentication-settings.js +2 -0
  21. package/esm/http-authentication-settings.js.map +1 -1
  22. package/esm/http-server-pool.d.ts +6 -0
  23. package/esm/http-server-pool.d.ts.map +1 -1
  24. package/esm/http-server-pool.js +19 -1
  25. package/esm/http-server-pool.js.map +1 -1
  26. package/esm/http-server-pool.spec.js +25 -0
  27. package/esm/http-server-pool.spec.js.map +1 -1
  28. package/esm/http-user-context.d.ts.map +1 -1
  29. package/esm/http-user-context.js +14 -2
  30. package/esm/http-user-context.js.map +1 -1
  31. package/esm/http-user-context.spec.js +40 -1
  32. package/esm/http-user-context.spec.js.map +1 -1
  33. package/esm/read-post-body.d.ts +14 -3
  34. package/esm/read-post-body.d.ts.map +1 -1
  35. package/esm/read-post-body.js +40 -15
  36. package/esm/read-post-body.js.map +1 -1
  37. package/esm/read-post-body.spec.d.ts +2 -0
  38. package/esm/read-post-body.spec.d.ts.map +1 -0
  39. package/esm/read-post-body.spec.js +80 -0
  40. package/esm/read-post-body.spec.js.map +1 -0
  41. package/esm/rest-api-runtime.d.ts +4 -0
  42. package/esm/rest-api-runtime.d.ts.map +1 -1
  43. package/esm/rest-api-runtime.js +55 -15
  44. package/esm/rest-api-runtime.js.map +1 -1
  45. package/esm/rest-service.integration.spec.js +74 -0
  46. package/esm/rest-service.integration.spec.js.map +1 -1
  47. package/esm/static-server-manager.spec.js +33 -0
  48. package/esm/static-server-manager.spec.js.map +1 -1
  49. package/esm/static-site-runtime.d.ts.map +1 -1
  50. package/esm/static-site-runtime.js +39 -6
  51. package/esm/static-site-runtime.js.map +1 -1
  52. package/esm/static-site-runtime.spec.js +36 -0
  53. package/esm/static-site-runtime.spec.js.map +1 -1
  54. package/package.json +15 -15
  55. package/src/add-cors-header.spec.ts +31 -4
  56. package/src/add-cors-header.ts +11 -0
  57. package/src/endpoint-generators/create-patch-endpoint.ts +3 -4
  58. package/src/endpoint-generators/create-post-endpoint.ts +2 -3
  59. package/src/helpers.ts +26 -5
  60. package/src/http-authentication-settings.ts +16 -0
  61. package/src/http-server-pool.spec.ts +27 -0
  62. package/src/http-server-pool.ts +20 -1
  63. package/src/http-user-context.spec.ts +55 -1
  64. package/src/http-user-context.ts +15 -2
  65. package/src/read-post-body.spec.ts +94 -0
  66. package/src/read-post-body.ts +52 -15
  67. package/src/rest-api-runtime.ts +60 -13
  68. package/src/rest-service.integration.spec.ts +76 -0
  69. package/src/static-server-manager.spec.ts +39 -0
  70. package/src/static-site-runtime.spec.ts +37 -0
  71. package/src/static-site-runtime.ts +41 -8
@@ -1,6 +1,10 @@
1
1
  import { getPort } from '@furystack/core/port-generator'
2
2
  import { createInjector } from '@furystack/inject'
3
3
  import { sleepAsync, usingAsync } from '@furystack/utils'
4
+ import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'
5
+ import { request } from 'http'
6
+ import { tmpdir } from 'os'
7
+ import { join } from 'path'
4
8
  import { describe, expect, it, vi } from 'vitest'
5
9
  import { useStaticFiles } from './helpers.js'
6
10
  import { HttpServerPoolToken } from './http-server-pool.js'
@@ -74,6 +78,41 @@ describe('useStaticFiles / buildStaticSiteServerApi', () => {
74
78
  expect(result.headers.get('content-type')).toBe('text/markdown')
75
79
  })
76
80
  })
81
+
82
+ it('rejects path traversal outside the static root', async () => {
83
+ const root = await mkdtemp(join(tmpdir(), 'furystack-static-'))
84
+ try {
85
+ const publicDir = join(root, 'public')
86
+ await mkdir(publicDir)
87
+ await writeFile(join(publicDir, 'ok.txt'), 'ok', 'utf-8')
88
+ await writeFile(join(root, 'secret.txt'), 'secret', 'utf-8')
89
+
90
+ await usingAsync(createInjector(), async (i) => {
91
+ const port = getPort()
92
+ await useStaticFiles({ injector: i, baseUrl: '/static', path: publicDir, port })
93
+
94
+ const { statusCode, body } = await new Promise<{ statusCode: number; body: string }>((fulfill, reject) => {
95
+ const req = request(
96
+ { hostname: '127.0.0.1', port, path: '/static/../secret.txt', method: 'GET' },
97
+ (res) => {
98
+ let data = ''
99
+ res.on('data', (chunk) => {
100
+ data += String(chunk)
101
+ })
102
+ res.on('end', () => fulfill({ statusCode: res.statusCode ?? 0, body: data }))
103
+ },
104
+ )
105
+ req.on('error', reject)
106
+ req.end()
107
+ })
108
+
109
+ expect(statusCode).toBe(404)
110
+ expect(body).not.toBe('secret')
111
+ })
112
+ } finally {
113
+ await rm(root, { recursive: true, force: true })
114
+ }
115
+ })
77
116
  })
78
117
 
79
118
  describe('Non-top level routing', () => {
@@ -1,4 +1,9 @@
1
+ import { getPort } from '@furystack/core/port-generator'
1
2
  import type { IncomingMessage, ServerResponse } from 'http'
3
+ import { createServer, request } from 'http'
4
+ import { mkdtemp, rm, writeFile } from 'fs/promises'
5
+ import { tmpdir } from 'os'
6
+ import { join } from 'path'
2
7
  import { describe, expect, it } from 'vitest'
3
8
  import { buildStaticSiteServerApi } from './static-site-runtime.js'
4
9
 
@@ -37,3 +42,35 @@ describe('buildStaticSiteServerApi / shouldExec', () => {
37
42
  expect(api.onUpgrade).toBeUndefined()
38
43
  })
39
44
  })
45
+
46
+ describe('buildStaticSiteServerApi / onRequest stream lifecycle', () => {
47
+ it('resolves when the client disconnects mid-stream', async () => {
48
+ const dir = await mkdtemp(join(tmpdir(), 'furystack-static-'))
49
+ const file = join(dir, 'big.txt')
50
+ await writeFile(file, 'x'.repeat(1024 * 1024))
51
+ const port = getPort()
52
+ const api = buildStaticSiteServerApi({ baseUrl: '/', path: dir, port })
53
+ const server = createServer((req, res) => {
54
+ void api.onRequest({ req, res }).catch(() => undefined)
55
+ })
56
+
57
+ try {
58
+ await new Promise<void>((resolve) => server.listen(port, '127.0.0.1', resolve))
59
+ const settled = await new Promise<string>((resolve, reject) => {
60
+ const req = request({ hostname: '127.0.0.1', port, path: '/big.txt', method: 'GET' }, (res) => {
61
+ res.once('data', () => {
62
+ req.destroy()
63
+ setTimeout(() => resolve('ok'), 100)
64
+ })
65
+ })
66
+ req.on('error', () => undefined)
67
+ req.end()
68
+ setTimeout(() => reject(new Error('timeout')), 1000)
69
+ })
70
+ expect(settled).toBe('ok')
71
+ } finally {
72
+ await new Promise<void>((resolve, reject) => server.close((err) => (err ? reject(err) : resolve())))
73
+ await rm(dir, { recursive: true, force: true })
74
+ }
75
+ })
76
+ })
@@ -2,7 +2,7 @@ import { PathHelper } from '@furystack/utils'
2
2
  import { createReadStream } from 'fs'
3
3
  import { access, stat } from 'fs/promises'
4
4
  import type { IncomingMessage, OutgoingHttpHeaders, ServerResponse } from 'http'
5
- import { join, normalize, sep } from 'path'
5
+ import { resolve, sep } from 'path'
6
6
  import type { ServerApi } from './http-server-pool.js'
7
7
  import { getMimeForFile } from './mime-types.js'
8
8
 
@@ -35,10 +35,26 @@ const sendFile = async ({
35
35
  'Content-Length': size,
36
36
  'Content-Type': getMimeForFile(fullPath),
37
37
  }
38
- Object.entries(head).forEach(([key, value]) => res.setHeader(key, value))
39
- await new Promise<void>((resolve, reject) =>
40
- createReadStream(fullPath, { autoClose: true }).once('finish', resolve).once('error', reject).pipe(res),
41
- )
38
+ Object.entries(head).forEach(([key, value]) => {
39
+ if (value !== undefined) {
40
+ res.setHeader(key, value)
41
+ }
42
+ })
43
+ await new Promise<void>((done, reject) => {
44
+ const stream = createReadStream(fullPath, { autoClose: true })
45
+ const onError = (error: Error) => {
46
+ stream.destroy()
47
+ reject(error)
48
+ }
49
+ stream.once('error', onError)
50
+ res.once('error', onError)
51
+ res.once('close', () => {
52
+ stream.destroy()
53
+ done()
54
+ })
55
+ res.once('finish', done)
56
+ stream.pipe(res)
57
+ })
42
58
  }
43
59
 
44
60
  /**
@@ -47,16 +63,33 @@ const sendFile = async ({
47
63
  * `options.fallback` (if set) for missing files, otherwise responds with 404.
48
64
  */
49
65
  export const buildStaticSiteServerApi = (options: StaticServerOptions): ServerApi => {
66
+ const rootPath = resolve(options.path)
67
+ const isInsideRoot = (filePath: string) => filePath === rootPath || filePath.startsWith(`${rootPath}${sep}`)
68
+
50
69
  const onRequest = async ({ req, res }: { req: IncomingMessage; res: ServerResponse }): Promise<void> => {
51
70
  const extractedPath = PathHelper.extractPath(req.url as string, options.baseUrl)
52
71
  const filePath = (extractedPath || '/').replaceAll('/', sep)
53
- const fullPath = normalize(join(options.path, filePath))
72
+ const fullPath = resolve(rootPath, `.${sep}${filePath}`)
73
+
74
+ if (!isInsideRoot(fullPath)) {
75
+ res.writeHead(404, { 'Content-Type': 'text/plain' })
76
+ res.end('Not found')
77
+ return
78
+ }
79
+
54
80
  try {
55
81
  await sendFile({ fullPath, res, headers: options.headers })
56
82
  } catch {
57
83
  if (options.fallback) {
58
- await sendFile({ fullPath: normalize(join(options.path, options.fallback)), res, headers: options.headers })
59
- return
84
+ const fallbackPath = resolve(rootPath, options.fallback)
85
+ if (isInsideRoot(fallbackPath)) {
86
+ try {
87
+ await sendFile({ fullPath: fallbackPath, res, headers: options.headers })
88
+ return
89
+ } catch {
90
+ // fall through to 404
91
+ }
92
+ }
60
93
  }
61
94
  res.writeHead(404, { 'Content-Type': 'text/plain' })
62
95
  res.end('Not found')