mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-06-29 15:44:22 +00:00
feat(shadcn): implement registry safe path (#7757)
* feat(shadcn): implement registry safe path * chore: changeset * style(shadcn): formatting * fix
This commit is contained in:
5
.changeset/kind-pugs-hide.md
Normal file
5
.changeset/kind-pugs-hide.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"shadcn": patch
|
||||
---
|
||||
|
||||
implement registry path validation
|
||||
@@ -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>
|
||||
|
||||
@@ -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.`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
131
packages/shadcn/src/utils/is-safe-target.test.ts
Normal file
131
packages/shadcn/src/utils/is-safe-target.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
86
packages/shadcn/src/utils/is-safe-target.ts
Normal file
86
packages/shadcn/src/utils/is-safe-target.ts
Normal 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
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user