Skip to content

Commit 9c828a6

Browse files
committed
Merge branch 'fix-memory-leaks' into bbc-main-autonext
2 parents f5493c7 + 346d314 commit 9c828a6

37 files changed

Lines changed: 1360 additions & 153 deletions

meteor/server/api/blueprints/__tests__/api.test.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,21 @@
11
import _ from 'underscore'
2+
import path from 'path'
3+
import os from 'os'
4+
import { promises as fsp } from 'fs'
25
import { setupDefaultStudioEnvironment, packageBlueprint } from '../../../../__mocks__/helpers/database'
36
import { literal, getRandomId } from '@sofie-automation/corelib/dist/lib'
47
import { protectString } from '@sofie-automation/corelib/dist/protectedString'
58
import { Blueprint } from '@sofie-automation/corelib/dist/dataModel/Blueprint'
69
import { BlueprintManifestType } from '@sofie-automation/blueprints-integration'
710
import { SYSTEM_ID, ICoreSystem } from '@sofie-automation/meteor-lib/dist/collections/CoreSystem'
8-
import { insertBlueprint, uploadBlueprint } from '../api'
11+
import { insertBlueprint, uploadBlueprint, uploadBlueprintAsset } from '../api'
912
import { MeteorCall } from '../../methods'
1013
import '../../../../__mocks__/_extendJest'
1114
import { Blueprints, CoreSystem } from '../../../collections'
1215
import { SupressLogMessages } from '../../../../__mocks__/suppressLogging'
1316
import { JSONBlobStringify } from '@sofie-automation/shared-lib/dist/lib/JSONBlob'
1417
import { Meteor } from 'meteor/meteor'
18+
import * as CoreSystemAPI from '../../../coreSystem'
1519

1620
// we don't want the deviceTriggers observer to start up at this time
1721
jest.mock('../../deviceTriggers/observer')
@@ -549,4 +553,34 @@ describe('Test blueprint management api', () => {
549553
)
550554
})
551555
})
556+
describe('uploadBlueprintAsset', () => {
557+
let storePath: string
558+
559+
beforeEach(async () => {
560+
storePath = await fsp.mkdtemp(path.join(os.tmpdir(), 'sofie-blueprint-assets-'))
561+
jest.spyOn(CoreSystemAPI, 'getSystemStorePath').mockReturnValue(storePath)
562+
})
563+
564+
afterEach(async () => {
565+
jest.restoreAllMocks()
566+
await fsp.rm(storePath, { recursive: true, force: true })
567+
})
568+
569+
test('writes decoded base64 payload to file', async () => {
570+
const payload = Buffer.from('some fake binary data \u0000\u0001', 'utf8')
571+
const fileId = 'myBlueprint/logo.bin'
572+
573+
await uploadBlueprintAsset(DEFAULT_CONNECTION, fileId, payload.toString('base64'))
574+
575+
const expectedFilePath = path.join(storePath, 'assets', fileId)
576+
const writtenBuffer = await fsp.readFile(expectedFilePath)
577+
expect(writtenBuffer.equals(payload)).toBeTruthy()
578+
})
579+
580+
test('rejects path traversal attempts', async () => {
581+
await expect(
582+
uploadBlueprintAsset(DEFAULT_CONNECTION, '../outside.bin', Buffer.from('x').toString('base64'))
583+
).rejects.toThrow('Asset name outside of asset storage path')
584+
})
585+
})
552586
})

meteor/server/api/blueprints/__tests__/http.test.ts

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import _ from 'underscore'
22
import { Meteor } from 'meteor/meteor'
3+
import { PassThrough } from 'stream'
34
import { SupressLogMessages } from '../../../../__mocks__/suppressLogging'
45
import { callKoaRoute } from '../../../../__mocks__/koa-util'
56
import { blueprintsRouter } from '../http'
@@ -357,4 +358,247 @@ describe('Test blueprint http api', () => {
357358
}
358359
})
359360
})
361+
362+
describe('router upload assets', () => {
363+
describe('POST /assets', () => {
364+
async function callRoute(body: any) {
365+
const ctx = await callKoaRoute(blueprintsRouter, {
366+
method: 'POST',
367+
url: '/assets',
368+
369+
requestBody: body,
370+
})
371+
372+
expect(ctx.response.type).toBe('text/plain')
373+
return ctx
374+
}
375+
376+
function resetUploadAssetMock() {
377+
const uploadBlueprintAsset = api.uploadBlueprintAsset as any as jest.MockInstance<any, any>
378+
uploadBlueprintAsset.mockClear()
379+
return uploadBlueprintAsset
380+
}
381+
382+
beforeEach(() => {
383+
resetUploadAssetMock()
384+
})
385+
386+
test('missing body', async () => {
387+
SupressLogMessages.suppressLogMessage(/Invalid request body/i)
388+
const res = await callRoute(undefined)
389+
expect(res.response.status).toEqual(500)
390+
391+
expect(api.uploadBlueprintAsset).toHaveBeenCalledTimes(0)
392+
})
393+
394+
test('empty body', async () => {
395+
SupressLogMessages.suppressLogMessage(/Missing request body/i)
396+
const res = await callRoute('')
397+
expect(res.response.status).toEqual(500)
398+
399+
expect(api.uploadBlueprintAsset).toHaveBeenCalledTimes(0)
400+
})
401+
402+
test('non-object body', async () => {
403+
SupressLogMessages.suppressLogMessage(/Invalid request body/i)
404+
const res = await callRoute(99)
405+
expect(res.response.status).toEqual(500)
406+
407+
expect(api.uploadBlueprintAsset).toHaveBeenCalledTimes(0)
408+
})
409+
410+
test('empty object body', async () => {
411+
SupressLogMessages.suppressLogMessage(/Invalid request body/i)
412+
const res = await callRoute({})
413+
expect(res.response.status).toEqual(500)
414+
415+
expect(api.uploadBlueprintAsset).toHaveBeenCalledTimes(0)
416+
})
417+
418+
test('with json body', async () => {
419+
const fileId = 'folder/asset.png'
420+
const payload = {
421+
[fileId]: 'Ym9keQ==',
422+
}
423+
424+
const res = await callRoute(payload)
425+
expect(res.response.status).toEqual(200)
426+
expect(res.body).toEqual('')
427+
428+
expect(api.uploadBlueprintAsset).toHaveBeenCalledTimes(1)
429+
expect(api.uploadBlueprintAsset).toHaveBeenCalledWith(DEFAULT_CONTEXT, fileId, payload[fileId])
430+
})
431+
432+
test('with json body - multiple', async () => {
433+
const count = 10
434+
const payload: Record<string, string> = {}
435+
for (let i = 0; i < count; i++) {
436+
payload[`id${i}.png`] = `body${i}`
437+
}
438+
439+
const res = await callRoute(payload)
440+
expect(res.response.status).toEqual(200)
441+
expect(res.body).toEqual('')
442+
443+
expect(api.uploadBlueprintAsset).toHaveBeenCalledTimes(count)
444+
for (let i = 0; i < count; i++) {
445+
expect(api.uploadBlueprintAsset).toHaveBeenCalledWith(DEFAULT_CONTEXT, `id${i}.png`, `body${i}`)
446+
}
447+
})
448+
449+
test('with errors', async () => {
450+
const count = 10
451+
const payload: Record<string, string> = {}
452+
for (let i = 0; i < count; i++) {
453+
payload[`id${i}.png`] = `body${i}`
454+
}
455+
456+
const uploadBlueprintAsset = resetUploadAssetMock()
457+
let called = 0
458+
uploadBlueprintAsset.mockImplementation(() => {
459+
called++
460+
if (called === 3 || called === 7) {
461+
throw new Meteor.Error(505, 'Some thrown error')
462+
}
463+
})
464+
465+
try {
466+
SupressLogMessages.suppressLogMessage(/Some thrown error/i)
467+
SupressLogMessages.suppressLogMessage(/Some thrown error/i)
468+
const res = await callRoute(payload)
469+
expect(res.response.status).toEqual(500)
470+
expect(res.body).toEqual(
471+
'Errors were encountered: \n[505] Some thrown error\n[505] Some thrown error\n'
472+
)
473+
474+
expect(api.uploadBlueprintAsset).toHaveBeenCalledTimes(count)
475+
for (let i = 0; i < count; i++) {
476+
expect(api.uploadBlueprintAsset).toHaveBeenCalledWith(DEFAULT_CONTEXT, `id${i}.png`, `body${i}`)
477+
}
478+
} finally {
479+
uploadBlueprintAsset.mockRestore()
480+
}
481+
})
482+
})
483+
484+
describe('GET /assets/*fileId', () => {
485+
function createDataStream() {
486+
const stream = new PassThrough()
487+
stream.end('asset')
488+
return stream
489+
}
490+
491+
async function callRoute(fileId: string) {
492+
const ctx = await callKoaRoute(blueprintsRouter, {
493+
method: 'GET',
494+
url: `/assets/${fileId}`,
495+
})
496+
497+
return ctx
498+
}
499+
500+
function resetRetrieveAssetMock() {
501+
const retrieveBlueprintAsset = api.retrieveBlueprintAsset as any as jest.MockInstance<any, any>
502+
retrieveBlueprintAsset.mockClear()
503+
return retrieveBlueprintAsset
504+
}
505+
506+
beforeEach(() => {
507+
resetRetrieveAssetMock()
508+
})
509+
510+
test('png asset', async () => {
511+
const fileId = 'folder/file.png'
512+
const dataStream = createDataStream()
513+
514+
const retrieveBlueprintAsset = resetRetrieveAssetMock()
515+
retrieveBlueprintAsset.mockReturnValue(dataStream)
516+
517+
const res = await callRoute(fileId)
518+
519+
expect(res.statusCode).toEqual(200)
520+
expect(res.response.type).toEqual('image/png')
521+
expect(res.body).toBe(dataStream)
522+
expect(res.response.get('Cache-Control')).toEqual('public, max-age=1296000, immutable')
523+
524+
expect(api.retrieveBlueprintAsset).toHaveBeenCalledTimes(1)
525+
expect(api.retrieveBlueprintAsset).toHaveBeenCalledWith(DEFAULT_CONTEXT, fileId)
526+
})
527+
528+
test('svg asset', async () => {
529+
const fileId = 'folder/file.svg'
530+
const dataStream = createDataStream()
531+
532+
const retrieveBlueprintAsset = resetRetrieveAssetMock()
533+
retrieveBlueprintAsset.mockReturnValue(dataStream)
534+
535+
const res = await callRoute(fileId)
536+
537+
expect(res.statusCode).toEqual(200)
538+
expect(res.response.type).toEqual('image/svg+xml')
539+
expect(res.body).toBe(dataStream)
540+
541+
expect(api.retrieveBlueprintAsset).toHaveBeenCalledTimes(1)
542+
expect(api.retrieveBlueprintAsset).toHaveBeenCalledWith(DEFAULT_CONTEXT, fileId)
543+
})
544+
545+
test('gif asset', async () => {
546+
const fileId = 'folder/file.gif'
547+
const dataStream = createDataStream()
548+
549+
const retrieveBlueprintAsset = resetRetrieveAssetMock()
550+
retrieveBlueprintAsset.mockReturnValue(dataStream)
551+
552+
const res = await callRoute(fileId)
553+
554+
expect(res.statusCode).toEqual(200)
555+
expect(res.response.type).toEqual('image/gif')
556+
expect(res.body).toBe(dataStream)
557+
558+
expect(api.retrieveBlueprintAsset).toHaveBeenCalledTimes(1)
559+
expect(api.retrieveBlueprintAsset).toHaveBeenCalledWith(DEFAULT_CONTEXT, fileId)
560+
})
561+
562+
test('not found', async () => {
563+
const fileId = 'folder/missing.png'
564+
565+
const retrieveBlueprintAsset = resetRetrieveAssetMock()
566+
retrieveBlueprintAsset.mockImplementation(() => {
567+
const err = new Error('No such file') as Error & { code?: string }
568+
err.code = 'ENOENT'
569+
throw err
570+
})
571+
572+
SupressLogMessages.suppressLogMessage(/Blueprint asset not found/i)
573+
const res = await callRoute(fileId)
574+
expect(res.statusCode).toEqual(404)
575+
})
576+
577+
test('path traversal attempt', async () => {
578+
const fileId = 'folder/../escape.png'
579+
580+
const retrieveBlueprintAsset = resetRetrieveAssetMock()
581+
retrieveBlueprintAsset.mockImplementation(() => {
582+
throw new Error('Requested asset outside of asset storage path')
583+
})
584+
585+
SupressLogMessages.suppressLogMessage(/Blueprint asset path traversal attempt/i)
586+
const res = await callRoute(fileId)
587+
expect(res.statusCode).toEqual(400)
588+
})
589+
590+
test('internal error', async () => {
591+
const fileId = 'folder/file.png'
592+
593+
const retrieveBlueprintAsset = resetRetrieveAssetMock()
594+
retrieveBlueprintAsset.mockImplementation(() => {
595+
throw new Error('Some thrown error')
596+
})
597+
598+
SupressLogMessages.suppressLogMessage(/Blueprint asset retrieval failed/i)
599+
const res = await callRoute(fileId)
600+
expect(res.statusCode).toEqual(500)
601+
})
602+
})
603+
})
360604
})

meteor/server/api/blueprints/api.ts

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -112,23 +112,36 @@ export async function uploadBlueprintAsset(cred: RequestCredentials, fileId: str
112112
assertConnectionHasOneOfPermissions(cred, ...PERMISSIONS_FOR_MANAGE_BLUEPRINTS)
113113

114114
const storePath = getSystemStorePath()
115+
const assetsDir = path.resolve(storePath, 'assets') + path.sep
116+
const assetPath = path.resolve(path.join(assetsDir, fileId))
117+
if (!assetPath.startsWith(assetsDir)) {
118+
throw new Error('Asset name outside of asset storage path')
119+
}
115120

116121
// TODO: add access control here
117122
const data = Buffer.from(body, 'base64')
118-
const parsedPath = path.parse(fileId)
119-
logger.info(
120-
`Write ${data.length} bytes to ${path.join(storePath, fileId)} (storePath: ${storePath}, fileId: ${fileId})`
121-
)
123+
logger.info(`Write ${data.length} bytes to ${assetPath} (storePath: ${storePath}, fileId: ${fileId})`)
124+
125+
const assetDirPath = path.dirname(assetPath)
122126

123-
await fsp.mkdir(path.join(storePath, parsedPath.dir), { recursive: true })
124-
await fsp.writeFile(path.join(storePath, fileId), data)
127+
await fsp.mkdir(assetDirPath, { recursive: true })
128+
await fsp.writeFile(assetPath, data)
125129
}
126-
export function retrieveBlueprintAsset(_cred: RequestCredentials, fileId: string): ReadStream {
130+
export async function retrieveBlueprintAsset(_cred: RequestCredentials, fileId: string): Promise<ReadStream> {
127131
check(fileId, String)
128132

129133
const storePath = getSystemStorePath()
134+
const assetsDir = path.resolve(storePath, 'assets') + path.sep
135+
const assetPath = path.resolve(path.join(assetsDir, fileId))
136+
if (!assetPath.startsWith(assetsDir)) {
137+
throw new Error('Requested asset outside of asset storage path')
138+
}
130139

131-
return createReadStream(path.join(storePath, fileId))
140+
const stream = createReadStream(assetPath)
141+
return new Promise((resolve, reject) => {
142+
stream.on('open', () => resolve(stream))
143+
stream.on('error', (err) => reject(err))
144+
})
132145
}
133146
/** Only to be called from internal functions */
134147
export async function internalUploadBlueprint(

meteor/server/api/blueprints/http.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -179,15 +179,15 @@ blueprintsRouter.post(
179179
}
180180
)
181181

182-
blueprintsRouter.get('/assets/*splat', async (ctx) => {
182+
blueprintsRouter.get('/assets/*fileId', async (ctx) => {
183183
logger.debug(`Blueprint Asset: ${ctx.socket.remoteAddress} GET "${ctx.url}"`)
184184
// TODO - some sort of user verification
185185
// for now just check it's a png to prevent snapshots being downloaded
186186

187-
const filePath = ctx.params[0]
187+
const filePath = ctx.params.fileId
188188
if (filePath.match(/\.(png|svg|gif)?$/)) {
189189
try {
190-
const dataStream = retrieveBlueprintAsset(ctx, filePath)
190+
const dataStream = await retrieveBlueprintAsset(ctx, filePath)
191191
const extension = path.extname(filePath)
192192
if (extension === '.svg') {
193193
ctx.response.type = 'image/svg+xml'
@@ -200,8 +200,17 @@ blueprintsRouter.get('/assets/*splat', async (ctx) => {
200200
ctx.set('Cache-Control', `public, max-age=${BLUEPRINT_ASSET_MAX_AGE}, immutable`)
201201
ctx.statusCode = 200
202202
ctx.body = dataStream
203-
} catch {
204-
ctx.statusCode = 404 // Probably
203+
} catch (e) {
204+
if (e instanceof Error && 'code' in e && e.code === 'ENOENT') {
205+
logger.warn('Blueprint asset not found: ' + e)
206+
ctx.statusCode = 404
207+
} else if (e instanceof Error && e.message.includes('outside of asset storage path')) {
208+
logger.warn('Blueprint asset path traversal attempt: ' + e)
209+
ctx.statusCode = 400
210+
} else {
211+
logger.warn('Blueprint asset retrieval failed: ' + e)
212+
ctx.statusCode = 500
213+
}
205214
}
206215
} else {
207216
ctx.statusCode = 403

0 commit comments

Comments
 (0)