diff --git a/.changeset/kind-pugs-hide.md b/.changeset/kind-pugs-hide.md new file mode 100644 index 000000000..00dab81fd --- /dev/null +++ b/.changeset/kind-pugs-hide.md @@ -0,0 +1,5 @@ +--- +"shadcn": patch +--- + +implement registry path validation diff --git a/apps/v4/content/docs/(root)/changelog.mdx b/apps/v4/content/docs/(root)/changelog.mdx index 1a95337c9..0dd7f74cc 100644 --- a/apps/v4/content/docs/(root)/changelog.mdx +++ b/apps/v4/content/docs/(root)/changelog.mdx @@ -854,7 +854,7 @@ const SheetContent = React.forwardRef< {...props} > {children} - + Close diff --git a/packages/shadcn/src/utils/add-components.ts b/packages/shadcn/src/utils/add-components.ts index 444776358..67eb7dc63 100644 --- a/packages/shadcn/src/utils/add-components.ts +++ b/packages/shadcn/src/utils/add-components.ts @@ -6,7 +6,10 @@ import { registryResolveItemsTree, resolveRegistryItems, } from "@/src/registry/api" -import { registryItemSchema } from "@/src/registry/schema" +import { + registryItemFileSchema, + registryItemSchema, +} from "@/src/registry/schema" import { configSchema, findCommonRoot, @@ -17,6 +20,7 @@ import { } from "@/src/utils/get-config" import { getProjectTailwindVersionFromConfig } from "@/src/utils/get-project-info" import { handleError } from "@/src/utils/handle-error" +import { isSafeTarget } from "@/src/utils/is-safe-target" import { logger } from "@/src/utils/logger" import { spinner } from "@/src/utils/spinner" import { updateCss } from "@/src/utils/updaters/update-css" @@ -79,6 +83,14 @@ async function addProjectComponents( registrySpinner?.fail() return handleError(new Error("Failed to fetch components from registry.")) } + + try { + validateFilesTarget(tree.files ?? [], config.resolvedPaths.cwd) + } catch (error) { + registrySpinner?.fail() + return handleError(error) + } + registrySpinner?.succeed() const tailwindVersion = await getProjectTailwindVersionFromConfig(config) @@ -147,6 +159,13 @@ async function addWorkspaceComponents( const filesUpdated: string[] = [] const filesSkipped: string[] = [] + const files = payload.flatMap((item) => item.files ?? []) + try { + validateFilesTarget(files, config.resolvedPaths.cwd) + } catch (error) { + return handleError(error) + } + const rootSpinner = spinner(`Installing components.`)?.start() for (const component of payload) { @@ -317,3 +336,20 @@ async function shouldOverwriteCssVars( component.type === "registry:theme" || component.type === "registry:style" ) } + +function validateFilesTarget( + files: z.infer[], + cwd: string +) { + for (const file of files) { + if (!file?.target) { + continue + } + + if (!isSafeTarget(file.target, cwd)) { + throw new Error( + `We found an unsafe file path "${file.target} in the registry item. Installation aborted.` + ) + } + } +} diff --git a/packages/shadcn/src/utils/is-safe-target.test.ts b/packages/shadcn/src/utils/is-safe-target.test.ts new file mode 100644 index 000000000..490f478f7 --- /dev/null +++ b/packages/shadcn/src/utils/is-safe-target.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, test } from "vitest" + +import { isSafeTarget } from "./is-safe-target" + +describe("isSafeTarget", () => { + const cwd = "/foo/bar" + + describe("should reject path traversal attempts", () => { + test.each([ + { + description: "basic path traversal with ../", + target: "../../etc/passwd", + }, + { + description: "nested path traversal", + target: "ui/../../../etc/hosts", + }, + { + description: "path traversal with ~/../", + target: "~/../../../.ssh/authorized_keys", + }, + { + description: "absolute paths outside project", + target: "/etc/passwd", + }, + { + description: "paths that resolve outside project root", + target: "foo/bar/../../../../etc/passwd", + }, + { + description: "URL-encoded path traversal", + target: "%2e%2e%2f%2e%2e%2fetc%2fpasswd", + }, + { + description: "double URL-encoded sequences", + target: "%252e%252e%252fetc%252fpasswd", + }, + { + description: "mixed encoded/plain traversal", + target: "..%2f..%2fetc%2fpasswd", + }, + { + description: "null byte injection", + target: "valid/path\0../../etc/passwd", + }, + { + description: "Windows-style path traversal", + target: "..\\..\\Windows\\System32\\config", + }, + { + description: "Windows absolute paths", + target: "C:\\Windows\\System32\\drivers\\etc\\hosts", + }, + { + description: "mixed separator traversal", + target: "foo\\..\\../etc/passwd", + }, + { + description: "current directory reference attacks", + target: "foo/./././../../../etc/passwd", + }, + { + description: "control characters in paths", + target: "foo/\x01\x02/../../etc/passwd", + }, + { + description: "Unicode normalization attacks", + target: "foo/../\u2025/etc/passwd", + }, + ])("$description", ({ target }) => { + expect(isSafeTarget(target, cwd)).toBe(false) + }) + }) + + describe("should accept safe paths", () => { + test.each([ + { + description: "simple relative path", + target: "ui/button.tsx", + }, + { + description: "nested relative path", + target: "components/ui/button.tsx", + }, + { + description: "home directory expansion", + target: "~/foo.json", + }, + { + description: "nested home directory path", + target: "~/components/button.tsx", + }, + { + description: "dot in filename", + target: "components/.env.local", + }, + { + description: "path with spaces", + target: "my components/button.tsx", + }, + { + description: "path with special characters", + target: "components/@ui/button.tsx", + }, + ])("$description", ({ target }) => { + expect(isSafeTarget(target, cwd)).toBe(true) + }) + }) + + describe("edge cases", () => { + test("should handle empty string", () => { + expect(isSafeTarget("", cwd)).toBe(true) + }) + + test("should handle single dot", () => { + expect(isSafeTarget(".", cwd)).toBe(true) + }) + + test("should reject malformed URL encoding", () => { + expect(isSafeTarget("%zz%ff%2e%2e%2f", cwd)).toBe(false) + }) + + test("should handle paths at project root", () => { + expect(isSafeTarget("/foo/bar/test.txt", cwd)).toBe(true) + }) + + test("should reject paths just outside project root", () => { + expect(isSafeTarget("/foo/test.txt", cwd)).toBe(false) + }) + }) +}) diff --git a/packages/shadcn/src/utils/is-safe-target.ts b/packages/shadcn/src/utils/is-safe-target.ts new file mode 100644 index 000000000..22886e220 --- /dev/null +++ b/packages/shadcn/src/utils/is-safe-target.ts @@ -0,0 +1,86 @@ +import path from "path" + +export function isSafeTarget(targetPath: string, cwd: string): boolean { + // Check for null bytes which can be used to bypass validations. + if (targetPath.includes("\0")) { + return false + } + + // Decode URL-encoded sequences to catch encoded traversal attempts. + let decodedPath: string + try { + // Decode multiple times to catch double-encoded sequences. + decodedPath = targetPath + let prevPath = "" + while (decodedPath !== prevPath && decodedPath.includes("%")) { + prevPath = decodedPath + decodedPath = decodeURIComponent(decodedPath) + } + } catch { + // If decoding fails, treat as unsafe. + return false + } + + // Normalize both paths to handle different path separators. + // Convert Windows backslashes to forward slashes for consistent handling. + const normalizedTarget = path.normalize(decodedPath.replace(/\\/g, "/")) + const normalizedRoot = path.normalize(cwd) + + // Check for explicit path traversal sequences in both encoded and decoded forms. + if ( + normalizedTarget.includes("..") || + decodedPath.includes("..") || + targetPath.includes("..") + ) { + return false + } + + // Check for current directory references that might be used in traversal. + const suspiciousPatterns = [ + /\.\.[\/\\]/, // ../ or ..\ + /[\/\\]\.\./, // /.. or \.. + /\.\./, // .. anywhere + /\.\.%/, // URL encoded traversal + /\x00/, // null byte + /[\x01-\x1f]/, // control characters + ] + + if ( + suspiciousPatterns.some( + (pattern) => pattern.test(targetPath) || pattern.test(decodedPath) + ) + ) { + return false + } + + // Allow ~/ at the start (home directory expansion within project) but reject ~/../ patterns. + if ( + (targetPath.includes("~") || decodedPath.includes("~")) && + (targetPath.includes("../") || decodedPath.includes("../")) + ) { + return false + } + + // Check for Windows drive letters (even on non-Windows systems for safety). + const driveLetterRegex = /^[a-zA-Z]:[\/\\]/ + if (driveLetterRegex.test(decodedPath)) { + // On Windows, check if it starts with the project root. + if (process.platform === "win32") { + return decodedPath.toLowerCase().startsWith(cwd.toLowerCase()) + } + // On non-Windows systems, reject all Windows absolute paths. + return false + } + + // If it's an absolute path, ensure it's within the project root. + if (path.isAbsolute(normalizedTarget)) { + return normalizedTarget.startsWith(normalizedRoot + path.sep) + } + + // For relative paths, resolve and check if within project bounds. + const resolvedPath = path.resolve(normalizedRoot, normalizedTarget) + return ( + resolvedPath.startsWith(normalizedRoot + path.sep) || + resolvedPath === normalizedRoot + ) +}