Skip to content

Commit

Permalink
feat: path validator
Browse files Browse the repository at this point in the history
  • Loading branch information
zeyu2001 committed Sep 3, 2024
1 parent 391c79c commit 933b2a1
Show file tree
Hide file tree
Showing 7 changed files with 199 additions and 2 deletions.
4 changes: 2 additions & 2 deletions packages/safe-fs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof fs>(fs, {
get: createGetter(basePath),
})
}

export default createFs
export default safeFs
88 changes: 88 additions & 0 deletions packages/validators/src/__tests__/path.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
2 changes: 2 additions & 0 deletions packages/validators/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
25 changes: 25 additions & 0 deletions packages/validators/src/path/index.ts
Original file line number Diff line number Diff line change
@@ -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<string> => {
const result = optionsSchema.safeParse(options)
if (result.success) {
return toSchema(result.data)
}
throw new OptionsError(fromError(result.error).toString())
}
35 changes: 35 additions & 0 deletions packages/validators/src/path/options.ts
Original file line number Diff line number Diff line change
@@ -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<typeof optionsSchema>
18 changes: 18 additions & 0 deletions packages/validators/src/path/schema.ts
Original file line number Diff line number Diff line change
@@ -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))
}
29 changes: 29 additions & 0 deletions packages/validators/src/path/utils.ts
Original file line number Diff line number Diff line change
@@ -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
}

0 comments on commit 933b2a1

Please sign in to comment.