From a953cc0018d51e736667784549948042074d00e0 Mon Sep 17 00:00:00 2001 From: sapphi-red <49056869+sapphi-red@users.noreply.github.com> Date: Thu, 21 Aug 2025 12:14:03 +0900 Subject: [PATCH] fix: only serve files under `src` --- .changeset/long-pillows-arrive.md | 5 +++++ .gitignore | 3 --- src/middleware.ts | 17 +++++++++++++-- test/fixtures/.env | 1 + test/tests.test.ts | 24 ++++++++++++++++++--- test/utils.ts | 36 +++++++++++++++++++++++++++++++ 6 files changed, 78 insertions(+), 8 deletions(-) create mode 100644 .changeset/long-pillows-arrive.md create mode 100644 test/fixtures/.env diff --git a/.changeset/long-pillows-arrive.md b/.changeset/long-pillows-arrive.md new file mode 100644 index 0000000..4eb39d0 --- /dev/null +++ b/.changeset/long-pillows-arrive.md @@ -0,0 +1,5 @@ +--- +'vite-plugin-static-copy': patch +--- + +Files not included in `src` was possible to acess with a crafted request. See [GHSA-pp7p-q8fx-2968](https://github.com/sapphi-red/vite-plugin-static-copy/security/advisories/GHSA-pp7p-q8fx-2968) for more details. diff --git a/.gitignore b/.gitignore index 95c6115..71fb501 100644 --- a/.gitignore +++ b/.gitignore @@ -54,9 +54,6 @@ typings/ # Yarn Integrity file .yarn-integrity -# dotenv environment variables file -.env - # parcel-bundler cache (https://parceljs.org/) .cache diff --git a/src/middleware.ts b/src/middleware.ts index c91042a..59bb31b 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -18,7 +18,7 @@ import type { OutgoingHttpHeaders, ServerResponse, } from 'node:http' -import { join, resolve } from 'node:path' +import { join, resolve, sep } from 'node:path' import type { FileMap } from './serve' import type { TransformOptionObject } from './options' import { @@ -54,6 +54,13 @@ function shouldServeOverwriteCheck( return true } +function isFileInside(filepath: string, srcBase: string) { + const srcBaseWithTrailingSlash = srcBase.endsWith(sep) + ? srcBase + : `${srcBase}${sep}` + return filepath.startsWith(srcBaseWithTrailingSlash) +} + function viaLocal( root: string, publicDir: string, @@ -87,7 +94,13 @@ function viaLocal( if (!uri.startsWith(dir)) continue for (const val of vals) { - const filepath = resolve(root, val.src, uri.slice(dir.length)) + const srcBase = resolve(root, val.src) + const filepath = resolve(srcBase, uri.slice(dir.length)) + if (!isFileInside(filepath, srcBase)) { + // uri includes non-normalized `../` + return undefined + } + const overwriteCheck = shouldServeOverwriteCheck( val.overwrite, filepath, diff --git a/test/fixtures/.env b/test/fixtures/.env new file mode 100644 index 0000000..c244444 --- /dev/null +++ b/test/fixtures/.env @@ -0,0 +1 @@ +SHOULD_BE_HIDDEN=PRIVATE diff --git a/test/tests.test.ts b/test/tests.test.ts index 65681d9..e64a888 100644 --- a/test/tests.test.ts +++ b/test/tests.test.ts @@ -2,15 +2,24 @@ import { describe, test, beforeAll, afterAll, expect } from 'vitest' import type { PreviewServer, ViteDevServer } from 'vite' import { build, createServer, preview } from 'vite' import { testcases } from './testcases' -import { getConfig, loadFileContent, normalizeLineBreak } from './utils' +import { + getConfig, + loadFileContent, + normalizeLineBreak, + sendRawRequest, +} from './utils' import type { AddressInfo } from 'node:net' +const constructUrl = (server: ViteDevServer | PreviewServer, path: string) => { + const port = (server.httpServer!.address() as AddressInfo).port + return `http://localhost:${port}${path}` +} + const fetchFromServer = async ( server: ViteDevServer | PreviewServer, path: string, ) => { - const port = (server.httpServer!.address() as AddressInfo).port - const url = `http://localhost:${port}${path}` + const url = constructUrl(server, path) const res = await fetch(url) return res } @@ -94,6 +103,15 @@ describe('serve', () => { ) expect(res.headers.get('Cross-Origin-Opener-Policy')).toBe('same-origin') }) + + test.concurrent('disallow path traversal with ../', async () => { + const res = await sendRawRequest( + constructUrl(server, '/'), + '/fixture1/foo.txt/../.env', + ) + expect(res).not.toContain('SHOULD_BE_HIDDEN') + expect(res).toContain('HTTP/1.1 404 Not Found') + }) }) }) diff --git a/test/utils.ts b/test/utils.ts index 06e90b8..f7939e1 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import type { InlineConfig } from 'vite' import { normalizePath } from 'vite' +import net from 'node:net' export const root = new URL('./fixtures/', import.meta.url) @@ -31,3 +32,38 @@ export const loadFileContent = async ( export const normalizeLineBreak = (input: string) => input.replace(/\r\n/g, '\n') + +export const sendRawRequest = async ( + baseUrl: string, + requestTarget: string, +) => { + return new Promise((resolve, reject) => { + const parsedUrl = new URL(baseUrl) + + const buf: Buffer[] = [] + const client = net.createConnection( + { port: +parsedUrl.port, host: parsedUrl.hostname }, + () => { + client.write( + [ + `GET ${encodeURI(requestTarget)} HTTP/1.1`, + `Host: ${parsedUrl.host}`, + 'Connection: Close', + '\r\n', + ].join('\r\n'), + ) + }, + ) + client.on('data', (data) => { + buf.push(data) + }) + client.on('end', (hadError: unknown) => { + if (!hadError) { + resolve(Buffer.concat(buf).toString()) + } + }) + client.on('error', (err) => { + reject(err) + }) + }) +}