Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
36 changes: 35 additions & 1 deletion meteor/server/api/blueprints/__tests__/api.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
import _ from 'underscore'
import path from 'path'
import os from 'os'
import { promises as fsp } from 'fs'
import { setupDefaultStudioEnvironment, packageBlueprint } from '../../../../__mocks__/helpers/database'
import { literal, getRandomId } from '@sofie-automation/corelib/dist/lib'
import { protectString } from '@sofie-automation/corelib/dist/protectedString'
import { Blueprint } from '@sofie-automation/corelib/dist/dataModel/Blueprint'
import { BlueprintManifestType } from '@sofie-automation/blueprints-integration'
import { SYSTEM_ID, ICoreSystem } from '@sofie-automation/meteor-lib/dist/collections/CoreSystem'
import { insertBlueprint, uploadBlueprint } from '../api'
import { insertBlueprint, uploadBlueprint, uploadBlueprintAsset } from '../api'
import { MeteorCall } from '../../methods'
import '../../../../__mocks__/_extendJest'
import { Blueprints, CoreSystem } from '../../../collections'
import { SupressLogMessages } from '../../../../__mocks__/suppressLogging'
import { JSONBlobStringify } from '@sofie-automation/shared-lib/dist/lib/JSONBlob'
import { Meteor } from 'meteor/meteor'
import * as CoreSystemAPI from '../../../coreSystem'

// we don't want the deviceTriggers observer to start up at this time
jest.mock('../../deviceTriggers/observer')
Expand Down Expand Up @@ -153,7 +157,7 @@
'Blueprint id "" was not found'
)
})
test('missing id', async () => {

Check warning on line 160 in meteor/server/api/blueprints/__tests__/api.test.ts

View workflow job for this annotation

GitHub Actions / Typecheck and Lint Core

Test has no assertions
// Should not error
await MeteorCall.blueprint.removeBlueprint(protectString('not_a_real_blueprint'))
})
Expand Down Expand Up @@ -549,4 +553,34 @@
)
})
})
describe('uploadBlueprintAsset', () => {
let storePath: string

beforeEach(async () => {
storePath = await fsp.mkdtemp(path.join(os.tmpdir(), 'sofie-blueprint-assets-'))
jest.spyOn(CoreSystemAPI, 'getSystemStorePath').mockReturnValue(storePath)
})

afterEach(async () => {
jest.restoreAllMocks()
await fsp.rm(storePath, { recursive: true, force: true })
})

test('writes decoded base64 payload to file', async () => {
const payload = Buffer.from('some fake binary data \u0000\u0001', 'utf8')
const fileId = 'myBlueprint/logo.bin'

await uploadBlueprintAsset(DEFAULT_CONNECTION, fileId, payload.toString('base64'))

const expectedFilePath = path.join(storePath, 'assets', fileId)
const writtenBuffer = await fsp.readFile(expectedFilePath)
expect(writtenBuffer.equals(payload)).toBeTruthy()
})

test('rejects path traversal attempts', async () => {
await expect(
uploadBlueprintAsset(DEFAULT_CONNECTION, '../outside.bin', Buffer.from('x').toString('base64'))
).rejects.toThrow('Asset name outside of asset storage path')
})
})
})
244 changes: 244 additions & 0 deletions meteor/server/api/blueprints/__tests__/http.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import _ from 'underscore'
import { Meteor } from 'meteor/meteor'
import { PassThrough } from 'stream'
import { SupressLogMessages } from '../../../../__mocks__/suppressLogging'
import { callKoaRoute } from '../../../../__mocks__/koa-util'
import { blueprintsRouter } from '../http'
Expand Down Expand Up @@ -357,4 +358,247 @@ describe('Test blueprint http api', () => {
}
})
})

describe('router upload assets', () => {
describe('POST /assets', () => {
async function callRoute(body: any) {
const ctx = await callKoaRoute(blueprintsRouter, {
method: 'POST',
url: '/assets',

requestBody: body,
})

expect(ctx.response.type).toBe('text/plain')
return ctx
}

function resetUploadAssetMock() {
const uploadBlueprintAsset = api.uploadBlueprintAsset as any as jest.MockInstance<any, any>
uploadBlueprintAsset.mockClear()
return uploadBlueprintAsset
}

beforeEach(() => {
resetUploadAssetMock()
})

test('missing body', async () => {
SupressLogMessages.suppressLogMessage(/Invalid request body/i)
const res = await callRoute(undefined)
expect(res.response.status).toEqual(500)

expect(api.uploadBlueprintAsset).toHaveBeenCalledTimes(0)
})

test('empty body', async () => {
SupressLogMessages.suppressLogMessage(/Missing request body/i)
const res = await callRoute('')
expect(res.response.status).toEqual(500)

expect(api.uploadBlueprintAsset).toHaveBeenCalledTimes(0)
})

test('non-object body', async () => {
SupressLogMessages.suppressLogMessage(/Invalid request body/i)
const res = await callRoute(99)
expect(res.response.status).toEqual(500)

expect(api.uploadBlueprintAsset).toHaveBeenCalledTimes(0)
})

test('empty object body', async () => {
SupressLogMessages.suppressLogMessage(/Invalid request body/i)
const res = await callRoute({})
expect(res.response.status).toEqual(500)

expect(api.uploadBlueprintAsset).toHaveBeenCalledTimes(0)
})

test('with json body', async () => {
const fileId = 'folder/asset.png'
const payload = {
[fileId]: 'Ym9keQ==',
}

const res = await callRoute(payload)
expect(res.response.status).toEqual(200)
expect(res.body).toEqual('')

expect(api.uploadBlueprintAsset).toHaveBeenCalledTimes(1)
expect(api.uploadBlueprintAsset).toHaveBeenCalledWith(DEFAULT_CONTEXT, fileId, payload[fileId])
})

test('with json body - multiple', async () => {
const count = 10
const payload: Record<string, string> = {}
for (let i = 0; i < count; i++) {
payload[`id${i}.png`] = `body${i}`
}

const res = await callRoute(payload)
expect(res.response.status).toEqual(200)
expect(res.body).toEqual('')

expect(api.uploadBlueprintAsset).toHaveBeenCalledTimes(count)
for (let i = 0; i < count; i++) {
expect(api.uploadBlueprintAsset).toHaveBeenCalledWith(DEFAULT_CONTEXT, `id${i}.png`, `body${i}`)
}
})

test('with errors', async () => {
const count = 10
const payload: Record<string, string> = {}
for (let i = 0; i < count; i++) {
payload[`id${i}.png`] = `body${i}`
}

const uploadBlueprintAsset = resetUploadAssetMock()
let called = 0
uploadBlueprintAsset.mockImplementation(() => {
called++
if (called === 3 || called === 7) {
throw new Meteor.Error(505, 'Some thrown error')
}
})

try {
SupressLogMessages.suppressLogMessage(/Some thrown error/i)
SupressLogMessages.suppressLogMessage(/Some thrown error/i)
const res = await callRoute(payload)
expect(res.response.status).toEqual(500)
expect(res.body).toEqual(
'Errors were encountered: \n[505] Some thrown error\n[505] Some thrown error\n'
)

expect(api.uploadBlueprintAsset).toHaveBeenCalledTimes(count)
for (let i = 0; i < count; i++) {
expect(api.uploadBlueprintAsset).toHaveBeenCalledWith(DEFAULT_CONTEXT, `id${i}.png`, `body${i}`)
}
} finally {
uploadBlueprintAsset.mockRestore()
}
})
})

describe('GET /assets/*fileId', () => {
function createDataStream() {
const stream = new PassThrough()
stream.end('asset')
return stream
}

async function callRoute(fileId: string) {
const ctx = await callKoaRoute(blueprintsRouter, {
method: 'GET',
url: `/assets/${fileId}`,
})

return ctx
}

function resetRetrieveAssetMock() {
const retrieveBlueprintAsset = api.retrieveBlueprintAsset as any as jest.MockInstance<any, any>
retrieveBlueprintAsset.mockClear()
return retrieveBlueprintAsset
}

beforeEach(() => {
resetRetrieveAssetMock()
})

test('png asset', async () => {
const fileId = 'folder/file.png'
const dataStream = createDataStream()

const retrieveBlueprintAsset = resetRetrieveAssetMock()
retrieveBlueprintAsset.mockReturnValue(dataStream)

const res = await callRoute(fileId)

expect(res.statusCode).toEqual(200)
expect(res.response.type).toEqual('image/png')
expect(res.body).toBe(dataStream)
expect(res.response.get('Cache-Control')).toEqual('public, max-age=1296000, immutable')

expect(api.retrieveBlueprintAsset).toHaveBeenCalledTimes(1)
expect(api.retrieveBlueprintAsset).toHaveBeenCalledWith(DEFAULT_CONTEXT, fileId)
})

test('svg asset', async () => {
const fileId = 'folder/file.svg'
const dataStream = createDataStream()

const retrieveBlueprintAsset = resetRetrieveAssetMock()
retrieveBlueprintAsset.mockReturnValue(dataStream)

const res = await callRoute(fileId)

expect(res.statusCode).toEqual(200)
expect(res.response.type).toEqual('image/svg+xml')
expect(res.body).toBe(dataStream)

expect(api.retrieveBlueprintAsset).toHaveBeenCalledTimes(1)
expect(api.retrieveBlueprintAsset).toHaveBeenCalledWith(DEFAULT_CONTEXT, fileId)
})

test('gif asset', async () => {
const fileId = 'folder/file.gif'
const dataStream = createDataStream()

const retrieveBlueprintAsset = resetRetrieveAssetMock()
retrieveBlueprintAsset.mockReturnValue(dataStream)

const res = await callRoute(fileId)

expect(res.statusCode).toEqual(200)
expect(res.response.type).toEqual('image/gif')
expect(res.body).toBe(dataStream)

expect(api.retrieveBlueprintAsset).toHaveBeenCalledTimes(1)
expect(api.retrieveBlueprintAsset).toHaveBeenCalledWith(DEFAULT_CONTEXT, fileId)
})

test('not found', async () => {
const fileId = 'folder/missing.png'

const retrieveBlueprintAsset = resetRetrieveAssetMock()
retrieveBlueprintAsset.mockImplementation(() => {
const err = new Error('No such file') as Error & { code?: string }
err.code = 'ENOENT'
throw err
})

SupressLogMessages.suppressLogMessage(/Blueprint asset not found/i)
const res = await callRoute(fileId)
expect(res.statusCode).toEqual(404)
})

test('path traversal attempt', async () => {
const fileId = 'folder/../escape.png'

const retrieveBlueprintAsset = resetRetrieveAssetMock()
retrieveBlueprintAsset.mockImplementation(() => {
throw new Error('Requested asset outside of asset storage path')
})

SupressLogMessages.suppressLogMessage(/Blueprint asset path traversal attempt/i)
const res = await callRoute(fileId)
expect(res.statusCode).toEqual(400)
})

test('internal error', async () => {
const fileId = 'folder/file.png'

const retrieveBlueprintAsset = resetRetrieveAssetMock()
retrieveBlueprintAsset.mockImplementation(() => {
throw new Error('Some thrown error')
})

SupressLogMessages.suppressLogMessage(/Blueprint asset retrieval failed/i)
const res = await callRoute(fileId)
expect(res.statusCode).toEqual(500)
})
})
})
})
23 changes: 16 additions & 7 deletions meteor/server/api/blueprints/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,23 +112,32 @@ export async function uploadBlueprintAsset(cred: RequestCredentials, fileId: str
assertConnectionHasOneOfPermissions(cred, ...PERMISSIONS_FOR_MANAGE_BLUEPRINTS)

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

// TODO: add access control here
const data = Buffer.from(body, 'base64')
const parsedPath = path.parse(fileId)
logger.info(
`Write ${data.length} bytes to ${path.join(storePath, fileId)} (storePath: ${storePath}, fileId: ${fileId})`
)
logger.info(`Write ${data.length} bytes to ${assetPath} (storePath: ${storePath}, fileId: ${fileId})`)

await fsp.mkdir(path.join(storePath, parsedPath.dir), { recursive: true })
await fsp.writeFile(path.join(storePath, fileId), data)
const assetDirPath = path.dirname(assetPath)

await fsp.mkdir(assetDirPath, { recursive: true })
await fsp.writeFile(assetPath, data)
}
export function retrieveBlueprintAsset(_cred: RequestCredentials, fileId: string): ReadStream {
check(fileId, String)

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

return createReadStream(path.join(storePath, fileId))
return createReadStream(assetPath)
}
/** Only to be called from internal functions */
export async function internalUploadBlueprint(
Expand Down
17 changes: 13 additions & 4 deletions meteor/server/api/blueprints/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,12 +179,12 @@ blueprintsRouter.post(
}
)

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

const filePath = ctx.params[0]
const filePath = ctx.params.fileId
if (filePath.match(/\.(png|svg|gif)?$/)) {
Comment on lines +187 to 188
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Require an actual allowed extension here.

/\.(png|svg|gif)?$/ still accepts a bare trailing dot, so /assets/foo. passes the image-only gate. Tighten this to require one of the allowed extensions.

Suggested fix
-	if (filePath.match(/\.(png|svg|gif)?$/)) {
+	if (/\.(png|svg|gif)$/.test(filePath)) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const filePath = ctx.params.fileId
if (filePath.match(/\.(png|svg|gif)?$/)) {
const filePath = ctx.params.fileId
if (/\.(png|svg|gif)$/.test(filePath)) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@meteor/server/api/blueprints/http.ts` around lines 187 - 188, The current
file extension check on variable filePath in the HTTP blueprint accepts a
trailing dot; update the if condition that uses filePath.match(...) so it
requires a full extension (e.g. use a regex that enforces a dot followed by one
of the allowed extensions and anchors the end, and make it case-insensitive).
Locate the check around the image-only gate (the filePath variable and the if
that currently contains /\.(png|svg|gif)?$/) and replace the pattern with a
strict pattern that does not allow a bare trailing dot (for example, use a
non-capturing group like (?:png|svg|gif) with end-of-string anchor and /i flag).

try {
const dataStream = retrieveBlueprintAsset(ctx, filePath)
Expand All @@ -200,8 +200,17 @@ blueprintsRouter.get('/assets/*splat', async (ctx) => {
ctx.set('Cache-Control', `public, max-age=${BLUEPRINT_ASSET_MAX_AGE}, immutable`)
ctx.statusCode = 200
ctx.body = dataStream
} catch {
ctx.statusCode = 404 // Probably
} catch (e) {
if (e instanceof Error && 'code' in e && e.code === 'ENOENT') {
logger.warn('Blueprint asset not found: ' + e)
ctx.statusCode = 404
} else if (e instanceof Error && e.message.includes('outside of asset storage path')) {
logger.warn('Blueprint asset path traversal attempt: ' + e)
ctx.statusCode = 400
} else {
logger.warn('Blueprint asset retrieval failed: ' + e)
ctx.statusCode = 500
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
} else {
ctx.statusCode = 403
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const GLOBAL_BLUEPRINT_ASSET_CACHE: Record<string, string> = {}
export function BlueprintAssetIcon({ src, className }: { src: string; className?: string }): JSX.Element | null {
const url = useMemo(() => {
if (src.startsWith('data:')) return new URL(src)
return new URL(createPrivateApiPath('/blueprints/assets/' + src), location.href)
return new URL(createPrivateApiPath('blueprints/assets/' + src), location.href)
}, [src])
const [svgAsset, setSvgAsset] = useState<string | null>(GLOBAL_BLUEPRINT_ASSET_CACHE[url.href] ?? null)

Expand Down
Loading
Loading