From 0b94510632aefa1a92595773a4c9e97712acfef4 Mon Sep 17 00:00:00 2001 From: gonzaloriestra <14979109+gonzaloriestra@users.noreply.github.com> Date: Mon, 25 May 2026 00:33:48 +0000 Subject: [PATCH] [Security] Harden devConsoleAssetsMiddleware against path traversal This PR hardens the `devConsoleAssetsMiddleware` against path traversal attacks by ensuring that any requested asset is contained within the intended root directory. We now resolve the candidate path and use the `isSubpath` utility to verify it remains within the `rootDirectory`. Regression tests have been added to verify that valid assets are served correctly and that traversal attempts (e.g., using `..`) result in a 404 error. --- .../dev/extension/server/middlewares.test.ts | 48 ++++++++++++++++++- .../dev/extension/server/middlewares.ts | 17 ++++++- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/packages/app/src/cli/services/dev/extension/server/middlewares.test.ts b/packages/app/src/cli/services/dev/extension/server/middlewares.test.ts index 206d63ed97..b857834c88 100644 --- a/packages/app/src/cli/services/dev/extension/server/middlewares.test.ts +++ b/packages/app/src/cli/services/dev/extension/server/middlewares.test.ts @@ -6,15 +6,17 @@ import { noCacheMiddleware, redirectToDevConsoleMiddleware, getExtensionPointMiddleware, + devConsoleAssetsMiddleware, } from './middlewares.js' import * as utilities from './utilities.js' import {GetExtensionsMiddlewareOptions} from './models.js' +import {copyConfigKeyEntry} from '../../../build/steps/include-assets/copy-config-key-entry.js' import * as templates from '../templates.js' import * as payload from '../payload.js' import {UIExtensionPayload} from '../payload/models.js' import {testUIExtension} from '../../../../models/app/app.test-data.js' import {AppEventWatcher} from '../../app-events/app-event-watcher.js' -import {copyConfigKeyEntry} from '../../../build/steps/include-assets/copy-config-key-entry.js' +import * as path from '@shopify/cli-kit/node/path' import {describe, expect, vi, test} from 'vitest' import {inTemporaryDirectory, mkdir, touchFile, writeFile} from '@shopify/cli-kit/node/fs' import * as h3 from 'h3' @@ -846,3 +848,47 @@ describe('getExtensionPointMiddleware()', () => { expect(h3.sendRedirect).toHaveBeenCalledWith(event, 'http://www.mock.com/redirect/url', 307) }) }) + +describe('devConsoleAssetsMiddleware()', () => { + test('returns the file content when the asset is within the root directory', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const assetsDir = joinPath(tmpDir, 'assets/dev-console/extensions/dev-console/assets') + await mkdir(assetsDir) + const assetPath = 'style.css' + const filePath = joinPath(assetsDir, assetPath) + const content = 'body { color: red; }' + await writeFile(filePath, content) + + vi.spyOn(path, 'moduleDirectory').mockReturnValue(tmpDir) + vi.spyOn(h3, 'getRouterParams').mockReturnValue({assetPath}) + + const event = getMockEvent() + const result = await devConsoleAssetsMiddleware(event) + + expect(event.node.res.setHeader).toHaveBeenCalledWith('Content-Type', 'text/css') + expect(String(result)).toBe(content) + }) + }) + + test('returns 404 when the asset is outside the root directory', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const assetsDir = joinPath(tmpDir, 'assets/dev-console/extensions/dev-console/assets') + await mkdir(assetsDir) + + const secretPath = joinPath(tmpDir, 'secret.txt') + await writeFile(secretPath, 'secret content') + + vi.spyOn(path, 'moduleDirectory').mockReturnValue(tmpDir) + vi.spyOn(h3, 'getRouterParams').mockReturnValue({assetPath: '../../../../secret.txt'}) + vi.spyOn(utilities, 'sendError').mockImplementation(() => {}) + + const event = getMockEvent() + await devConsoleAssetsMiddleware(event) + + expect(utilities.sendError).toHaveBeenCalledWith(event, { + statusCode: 404, + statusMessage: 'Not Found', + }) + }) + }) +}) diff --git a/packages/app/src/cli/services/dev/extension/server/middlewares.ts b/packages/app/src/cli/services/dev/extension/server/middlewares.ts index a0a673a807..42ddf548db 100644 --- a/packages/app/src/cli/services/dev/extension/server/middlewares.ts +++ b/packages/app/src/cli/services/dev/extension/server/middlewares.ts @@ -6,7 +6,15 @@ import {getWebSocketUrl} from '../../extension.js' import {resolveOutputDir} from '../../../build/steps/include-assets/generate-manifest.js' import {fileExists, isDirectory, readFile, findPathUp} from '@shopify/cli-kit/node/fs' import {sendRedirect, defineEventHandler, getRequestHeader, getRouterParams, setResponseHeader} from 'h3' -import {joinPath, resolvePath, relativePath, isAbsolutePath, extname, moduleDirectory} from '@shopify/cli-kit/node/path' +import { + joinPath, + resolvePath, + relativePath, + isAbsolutePath, + extname, + moduleDirectory, + isSubpath, +} from '@shopify/cli-kit/node/path' import {outputDebug} from '@shopify/cli-kit/node/output' import type {H3Event} from 'h3' @@ -147,8 +155,13 @@ export const devConsoleAssetsMiddleware = defineEventHandler(async (event) => { }) } + const candidate = resolvePath(joinPath(rootDirectory, assetPath)) + if (!isSubpath(rootDirectory, candidate)) { + return sendError(event, {statusCode: 404, statusMessage: 'Not Found'}) + } + return fileServerMiddleware(event, { - filePath: joinPath(rootDirectory, assetPath), + filePath: candidate, }) })