From 933b2a1e0af8d485f6bbe16e95d93ed402bb4533 Mon Sep 17 00:00:00 2001 From: Zeyu Zhang <39144422+zeyu2001@users.noreply.github.com> Date: Tue, 3 Sep 2024 14:04:20 +0800 Subject: [PATCH] feat: path validator --- packages/safe-fs/src/index.ts | 4 +- .../validators/src/__tests__/path.test.ts | 88 +++++++++++++++++++ packages/validators/src/index.ts | 2 + packages/validators/src/path/index.ts | 25 ++++++ packages/validators/src/path/options.ts | 35 ++++++++ packages/validators/src/path/schema.ts | 18 ++++ packages/validators/src/path/utils.ts | 29 ++++++ 7 files changed, 199 insertions(+), 2 deletions(-) create mode 100644 packages/validators/src/__tests__/path.test.ts create mode 100644 packages/validators/src/path/index.ts create mode 100644 packages/validators/src/path/options.ts create mode 100644 packages/validators/src/path/schema.ts create mode 100644 packages/validators/src/path/utils.ts diff --git a/packages/safe-fs/src/index.ts b/packages/safe-fs/src/index.ts index 53cb65f..c046bbb 100644 --- a/packages/safe-fs/src/index.ts +++ b/packages/safe-fs/src/index.ts @@ -2,10 +2,10 @@ import * as fs from 'node:fs' import { createGetter } from './getter' -const createFs = (basePath: string = process.cwd()) => { +const safeFs = (basePath: string = process.cwd()) => { return new Proxy(fs, { get: createGetter(basePath), }) } -export default createFs +export default safeFs diff --git a/packages/validators/src/__tests__/path.test.ts b/packages/validators/src/__tests__/path.test.ts new file mode 100644 index 0000000..e66bff4 --- /dev/null +++ b/packages/validators/src/__tests__/path.test.ts @@ -0,0 +1,88 @@ +import path from 'node:path' + +import { describe, expect, it } from 'vitest' +import { ZodError } from 'zod' + +import { OptionsError } from '@/common/errors' +import { createPathSchema } from '@/index' + +describe('Path validator with default options', () => { + const schema = createPathSchema() + + it('should allow a valid path', () => { + expect(() => schema.parse('valid/path')).not.toThrow() + expect(() => schema.parse('valid/nested/path')).not.toThrow() + expect(() => schema.parse('.')).not.toThrow(ZodError) + }) + + it('should trim the path', () => { + expect(schema.parse(' valid/path ')).toBe( + path.join(process.cwd(), 'valid/path'), + ) + }) + + it('should not allow directory traversal', () => { + expect(() => schema.parse('../etc/passwd')).toThrow(ZodError) + expect(() => schema.parse('..')).toThrow(ZodError) + expect(() => schema.parse('../')).toThrow(ZodError) + expect(() => schema.parse('..\\')).toThrow(ZodError) + expect(() => schema.parse('..././')).toThrow(ZodError) + }) + + it('should handle paths with special characters', () => { + expect(() => schema.parse('path with spaces')).not.toThrow() + expect(() => schema.parse('path_with_underscores')).not.toThrow() + expect(() => schema.parse('path-with-hyphens')).not.toThrow() + expect(() => schema.parse('path.with.dots')).not.toThrow() + }) + + it('should handle paths with non-ASCII characters', () => { + expect(() => schema.parse('パス')).not.toThrow() + expect(() => schema.parse('путь')).not.toThrow() + expect(() => schema.parse('路径')).not.toThrow() + }) + + it('should handle absolute paths', () => { + const absolutePath = path.resolve('/absolute/path') + expect(() => schema.parse(absolutePath)).toThrow(ZodError) + + const cwd = process.cwd() + expect(path.isAbsolute(cwd)).toBe(true) + expect(() => schema.parse(cwd)).not.toThrow(ZodError) + }) +}) + +describe('Path validator with custom options', () => { + const schema = createPathSchema({ basePath: '/var/www' }) + + it('should allow a valid path within the base path', () => { + expect(() => + schema.parse('../'.repeat(process.cwd().split('/').length) + 'var/www'), + ).not.toThrow() + expect(() => schema.parse('/var/www')).not.toThrow() + expect(() => schema.parse('/var/www/valid/path')).not.toThrow() + expect(() => schema.parse('/var/www/valid/nested/path')).not.toThrow() + }) + + it('should not allow paths outside the base path', () => { + expect(() => schema.parse('/etc/passwd')).toThrow(ZodError) + expect(() => schema.parse('/var/log/app.log')).toThrow(ZodError) + expect(() => schema.parse('/var/www/../etc/passwd')).toThrow(ZodError) + }) +}) + +describe('Path validator with invalid options', () => { + it('should throw an error for an invalid base path', () => { + expect(() => createPathSchema({ basePath: 'relative/path' })).toThrow( + OptionsError, + ) + expect(() => createPathSchema({ basePath: '' })).toThrow(OptionsError) + expect(() => createPathSchema({ basePath: '.' })).toThrow(OptionsError) + }) + + it('should throw an error for non-string base paths', () => { + expect(() => createPathSchema({ basePath: 123 })).toThrow(OptionsError) + expect(() => createPathSchema({ basePath: null })).toThrow(OptionsError) + expect(() => createPathSchema({ basePath: {} })).toThrow(OptionsError) + }) +}) diff --git a/packages/validators/src/index.ts b/packages/validators/src/index.ts index e04e6cb..0bf0db0 100644 --- a/packages/validators/src/index.ts +++ b/packages/validators/src/index.ts @@ -7,6 +7,8 @@ export type * from '@/common/errors' export * from '@/email' export type { EmailValidatorOptions } from '@/email/options' +export * from '@/path' +export type { PathValidatorOptions } from '@/path/options' export * from '@/url' export type * from '@/url/errors' export type { UrlValidatorOptions } from '@/url/options' diff --git a/packages/validators/src/path/index.ts b/packages/validators/src/path/index.ts new file mode 100644 index 0000000..62b7197 --- /dev/null +++ b/packages/validators/src/path/index.ts @@ -0,0 +1,25 @@ +import { ZodSchema } from 'zod' +import { fromError } from 'zod-validation-error' + +import { OptionsError } from '@/common/errors' +import { optionsSchema, PathValidatorOptions } from '@/path/options' +import { toSchema } from '@/path/schema' + +/** + * Create a schema that validates user-supplied pathnames for filesystem operations. + * + * @param options - The options to use for validation + * @throws {@link OptionsError} If the options are invalid + * @returns A Zod schema that validates paths. + * + * @public + */ +export const createPathSchema = ( + options: PathValidatorOptions = {}, +): ZodSchema => { + const result = optionsSchema.safeParse(options) + if (result.success) { + return toSchema(result.data) + } + throw new OptionsError(fromError(result.error).toString()) +} diff --git a/packages/validators/src/path/options.ts b/packages/validators/src/path/options.ts new file mode 100644 index 0000000..1e979a8 --- /dev/null +++ b/packages/validators/src/path/options.ts @@ -0,0 +1,35 @@ +import path from 'node:path' + +import { z } from 'zod' + +/** + * The options to use for path validation. + * + * @public + */ +export interface PathValidatorOptions { + /** + * The base path to use for validation. Defaults to the current working directory. + * This must be an absolute path. + * + * All provided paths, resolved relative to the working directory of the Node process, + * must be within this directory (or its subdirectories), or they will be considered unsafe. + * You should provide a safe base path that does not contain sensitive files or directories. + * + * @defaultValue `process.cwd()` (the current working directory) + * @example `'/var/www'` + */ + basePath?: string +} + +export const optionsSchema = z.object({ + basePath: z + .string() + .optional() + .default(() => process.cwd()) + .refine((basePath) => { + return basePath === path.resolve(basePath) && path.isAbsolute(basePath) + }), +}) + +export type ParsedPathValidatorOptions = z.infer diff --git a/packages/validators/src/path/schema.ts b/packages/validators/src/path/schema.ts new file mode 100644 index 0000000..c02e1c8 --- /dev/null +++ b/packages/validators/src/path/schema.ts @@ -0,0 +1,18 @@ +import path from 'node:path' + +import { z } from 'zod' + +import { ParsedPathValidatorOptions } from '@/path/options' +import { isSafePath } from '@/path/utils' + +const createValidationSchema = (options: ParsedPathValidatorOptions) => + z + .string() + .transform((untrustedPath) => path.resolve(untrustedPath)) + .refine((resolvedPath) => isSafePath(resolvedPath, options.basePath), { + message: 'The provided path is unsafe.', + }) + +export const toSchema = (options: ParsedPathValidatorOptions) => { + return z.string().trim().pipe(createValidationSchema(options)) +} diff --git a/packages/validators/src/path/utils.ts b/packages/validators/src/path/utils.ts new file mode 100644 index 0000000..a0b3141 --- /dev/null +++ b/packages/validators/src/path/utils.ts @@ -0,0 +1,29 @@ +import path from 'node:path' + +export const isSafePath = ( + untrustedPath: string, + basePath: string, +): boolean => { + // check for poison null bytes + if (untrustedPath.indexOf('\0') !== -1) { + return false + } + // check for backslashes + if (untrustedPath.indexOf('\\') !== -1) { + return false + } + // resolve the path relative to the Node process's current working directory + // since that's what fs operations will be relative to + const normalizedPath = path.resolve(untrustedPath) // normalizedPath is now an absolute path + + // check for dot segments, even if they don't normalize to anything + if (normalizedPath.includes('..')) { + return false + } + + // check if the normalized path is within the provided 'safe' base path + if (normalizedPath.indexOf(basePath) !== 0) { + return false + } + return true +}