feat(shadcn): implement registry safe path (#7757)

* feat(shadcn): implement registry safe path

* chore: changeset

* style(shadcn): formatting

* fix
This commit is contained in:
shadcn
2025-07-08 20:25:14 +04:00
committed by GitHub
parent 1cdd6c1645
commit db93787712
5 changed files with 260 additions and 2 deletions

View File

@@ -0,0 +1,5 @@
---
"shadcn": patch
---
implement registry path validation

View File

@@ -854,7 +854,7 @@ const SheetContent = React.forwardRef<
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none">
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>

View File

@@ -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<typeof registryItemFileSchema>[],
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.`
)
}
}
}

View File

@@ -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)
})
})
})

View File

@@ -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
)
}