Skip to content

Commit cd2528a

Browse files
committed
fix: improve Blueprint asset handling
1 parent 1655232 commit cd2528a

4 files changed

Lines changed: 33 additions & 15 deletions

File tree

meteor/server/api/blueprints/api.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -112,23 +112,32 @@ 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})`)
122124

123-
await fsp.mkdir(path.join(storePath, parsedPath.dir), { recursive: true })
124-
await fsp.writeFile(path.join(storePath, fileId), data)
125+
const assetDirPath = path.dirname(assetPath)
126+
127+
await fsp.mkdir(assetDirPath, { recursive: true })
128+
await fsp.writeFile(assetPath, data)
125129
}
126130
export function retrieveBlueprintAsset(_cred: RequestCredentials, fileId: string): 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+
return createReadStream(assetPath)
132141
}
133142
/** Only to be called from internal functions */
134143
export async function internalUploadBlueprint(

meteor/server/api/blueprints/http.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -179,12 +179,12 @@ 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 {
190190
const dataStream = retrieveBlueprintAsset(ctx, filePath)
@@ -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

packages/webui/src/client/lib/Components/BlueprintAssetIcon.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ const GLOBAL_BLUEPRINT_ASSET_CACHE: Record<string, string> = {}
66
export function BlueprintAssetIcon({ src, className }: { src: string; className?: string }): JSX.Element | null {
77
const url = useMemo(() => {
88
if (src.startsWith('data:')) return new URL(src)
9-
return new URL(createPrivateApiPath('/blueprints/assets/' + src), location.href)
9+
return new URL(createPrivateApiPath('blueprints/assets/' + src), location.href)
1010
}, [src])
1111
const [svgAsset, setSvgAsset] = useState<string | null>(GLOBAL_BLUEPRINT_ASSET_CACHE[url.href] ?? null)
1212

packages/webui/src/client/ui/PreviewPopUp/PreviewPopUpContext.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export function convertSourceLayerItemToPreview(
5050
case PreviewType.BlueprintImage:
5151
contents.push({
5252
type: 'image',
53-
src: createPrivateApiPath('/blueprints/assets/' + popupPreview.preview.image),
53+
src: createPrivateApiPath('blueprints/assets/' + popupPreview.preview.image),
5454
})
5555
break
5656
case PreviewType.HTML:
@@ -82,7 +82,7 @@ export function convertSourceLayerItemToPreview(
8282
contents.push({
8383
type: 'boxLayout',
8484
boxSourceConfiguration: popupPreview.preview.boxes,
85-
backgroundArtSrc: createPrivateApiPath('/blueprints/assets/' + popupPreview.preview.background),
85+
backgroundArtSrc: createPrivateApiPath('blueprints/assets/' + popupPreview.preview.background),
8686
})
8787
break
8888
case PreviewType.Table:
@@ -288,7 +288,7 @@ export function convertSourceLayerItemToPreview(
288288
const content = item.content as TransitionContent
289289
if (content.preview)
290290
return {
291-
contents: [{ type: 'image', src: createPrivateApiPath('/blueprints/assets/' + content.preview) }],
291+
contents: [{ type: 'image', src: createPrivateApiPath('blueprints/assets/' + content.preview) }],
292292
options: {},
293293
}
294294
}

0 commit comments

Comments
 (0)