-
Notifications
You must be signed in to change notification settings - Fork 1
feat(docs): Shadcn Versioning POC #49
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
245a83a
2a39d75
cb6c53e
b600654
1c203ba
eefd752
258c5cd
69c8a59
699d6fb
d725e81
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| import fs from 'fs'; | ||
| import path from 'path'; | ||
|
|
||
| import type { VercelRequest, VercelResponse } from '@vercel/node'; | ||
|
|
||
| const SPECIAL_FILES = ['index.json', 'registry.json', 'versions.json']; | ||
| const DEFAULT_VERSION = 'v1'; | ||
| const LATEST_VERSION = 'v2'; | ||
|
|
||
| function getBasePath(): string { | ||
| // Vercel builds to docs-site/dist, which becomes the outputDirectory | ||
| // In production, cwd is /var/task and files are at /var/task/dist/r | ||
| const paths = [ | ||
| path.join(process.cwd(), 'dist', 'r'), // Vercel production (cwd is /var/task) | ||
| path.join(process.cwd(), 'r'), // Alternative | ||
| path.join(process.cwd(), 'docs-site', 'dist', 'r'), // Local after build | ||
| path.join(process.cwd(), 'docs-site', 'public', 'r'), // Local dev | ||
| ]; | ||
|
|
||
| for (const p of paths) { | ||
| if (fs.existsSync(p)) { | ||
| return p; | ||
| } | ||
| } | ||
|
|
||
| return paths[0]!; // Fallback | ||
| } | ||
|
|
||
| export default function handler(req: VercelRequest, res: VercelResponse) { | ||
| // Extract file path from query parameter (from rewrite /r/:path* -> /api/r?file=:path*) | ||
| const { file } = req.query; | ||
| const fileName = Array.isArray(file) ? file.join('/') : file || ''; | ||
|
|
||
| if (!fileName) { | ||
| return res.status(400).json({ error: 'Bad Request', message: 'File path required' }); | ||
| } | ||
|
|
||
| const basePath = getBasePath(); | ||
|
|
||
| if (SPECIAL_FILES.includes(fileName)) { | ||
| const filePath = path.join(basePath, fileName); | ||
| if (fs.existsSync(filePath)) { | ||
| const content = fs.readFileSync(filePath, 'utf-8'); | ||
| res.setHeader('Content-Type', 'application/json'); | ||
| res.setHeader('Access-Control-Allow-Origin', '*'); | ||
| res.setHeader('Cache-Control', 'public, max-age=3600'); | ||
| return res.send(content); | ||
| } | ||
| } | ||
|
|
||
| const normalizedFileName = path.normalize(fileName).replace(/^(\.\.([\\/]|$))+/, ''); | ||
|
|
||
| if (normalizedFileName !== fileName || normalizedFileName.includes('..')) { | ||
| return res.status(400).json({ error: 'Invalid file path' }); | ||
| } | ||
|
|
||
| const versionParam = req.query.version as string | undefined; | ||
| let version = versionParam || DEFAULT_VERSION; | ||
|
|
||
| if (version === 'latest') { | ||
| version = LATEST_VERSION; | ||
| } | ||
|
|
||
| const baseDir = path.resolve(basePath, version); | ||
| const versionedPath = path.resolve(baseDir, normalizedFileName); | ||
|
|
||
| if (!versionedPath.startsWith(baseDir + path.sep) && versionedPath !== baseDir) { | ||
| return res.status(403).json({ error: 'Access denied' }); | ||
| } | ||
|
|
||
| if (fs.existsSync(versionedPath)) { | ||
| const content = fs.readFileSync(versionedPath, 'utf-8'); | ||
| res.setHeader('Content-Type', 'application/json'); | ||
| res.setHeader('Access-Control-Allow-Origin', '*'); | ||
| res.setHeader('Cache-Control', 'public, max-age=3600'); | ||
| return res.send(content); | ||
| } | ||
|
|
||
| return res.status(404).json({ | ||
| error: 'Not Found', | ||
| message: `Component "${fileName}" does not exist in version "${version}"`, | ||
| hint: 'Check available versions or component name', | ||
| }); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| { | ||
| "$schema": "https://ui.shadcn.com/schema/registry-item.json", | ||
| "name": "my-organization/domain-table@v2", | ||
| "type": "registry:block", | ||
| "title": "Domain Management Table (v2)", | ||
| "description": "Enhanced version of domain management block with improved performance, new bulk actions, and advanced filtering capabilities. Includes creating, verifying, configuring identity providers, and deleting domains.", | ||
| "dependencies": [ | ||
| "sonner", | ||
| "react-hook-form", | ||
| "@hookform/resolvers", | ||
| "clsx", | ||
| "tailwind-merge", | ||
| "class-variance-authority", | ||
| "lucide-react", | ||
| "next-themes", | ||
| "@radix-ui/react-dialog", | ||
| "@radix-ui/react-dropdown-menu", | ||
| "@radix-ui/react-label", | ||
| "@radix-ui/react-separator", | ||
| "@radix-ui/react-slot", | ||
| "@radix-ui/react-switch", | ||
| "@radix-ui/react-tooltip", | ||
| "@radix-ui/react-checkbox", | ||
| "@tanstack/react-table" | ||
| ], | ||
| "files": [ | ||
| { | ||
| "path": "src/blocks/my-organization/domain-management/domain-table.tsx", | ||
| "content": "import {\n type Domain,\n getComponentStyles,\n MY_ORGANIZATION_DOMAIN_SCOPES,\n} from '@auth0/universal-components-core';\nimport { Plus, Filter } from 'lucide-react';\nimport * as React from 'react';\n\nimport { DomainConfigureProvidersModal } from '../../../components/my-organization/domain-management/domain-configure/domain-configure-providers-modal';\nimport { DomainCreateModal } from '../../../components/my-organization/domain-management/domain-create/domain-create-modal';\nimport { DomainDeleteModal } from '../../../components/my-organization/domain-management/domain-delete/domain-delete-modal';\nimport { DomainTableActionsColumn } from '../../../components/my-organization/domain-management/domain-table/domain-table-actions-column';\nimport { DomainVerifyModal } from '../../../components/my-organization/domain-management/domain-verify/domain-verify-modal';\nimport { Badge } from '../../../components/ui/badge';\nimport { DataTable, type Column } from '../../../components/ui/data-table';\nimport { Header } from '../../../components/ui/header';\nimport { withMyOrganizationService } from '../../../hoc/with-services';\nimport { useDomainTable } from '../../../hooks/my-organization/domain-management/use-domain-table';\nimport { useDomainTableLogic } from '../../../hooks/my-organization/domain-management/use-domain-table-logic';\nimport { useTheme } from '../../../hooks/use-theme';\nimport { useTranslator } from '../../../hooks/use-translator';\nimport { getStatusBadgeVariant } from '../../../lib/my-organization/domain-management';\nimport type { DomainTableProps } from '../../../types/my-organization/domain-management/domain-table-types';\n\n/**\n * DomainTable Component (v2)\n * \n * Enhanced version with:\n * - Improved performance with React.memo\n * - Advanced filtering capabilities\n * - Bulk action support\n * - Better accessibility\n */\nfunction DomainTableComponent({\n customMessages = {},\n schema,\n styling = {\n variables: { common: {}, light: {}, dark: {} },\n classes: {},\n },\n hideHeader = false,\n readOnly = false,\n createAction,\n verifyAction,\n deleteAction,\n associateToProviderAction,\n deleteFromProviderAction,\n onOpenProvider,\n onCreateProvider,\n}: DomainTableProps) {\n const { isDarkMode } = useTheme();\n const { t } = useTranslator('domain_management', customMessages);\n const [filterStatus, setFilterStatus] = React.useState<string | null>(null);\n\n const {\n domains,\n providers,\n isFetching,\n isCreating,\n isVerifying,\n isDeleting,\n isLoadingProviders,\n fetchProviders,\n fetchDomains,\n onCreateDomain,\n onVerifyDomain,\n onDeleteDomain,\n onAssociateToProvider,\n onDeleteFromProvider,\n } = useDomainTable({\n createAction,\n verifyAction,\n deleteAction,\n associateToProviderAction,\n deleteFromProviderAction,\n customMessages,\n });\n\n const {\n showCreateModal,\n showConfigureModal,\n showVerifyModal,\n showDeleteModal,\n verifyError,\n selectedDomain,\n setShowCreateModal,\n setShowConfigureModal,\n setShowDeleteModal,\n handleCreate,\n handleVerify,\n handleDelete,\n handleToggleSwitch,\n handleCloseVerifyModal,\n handleCreateClick,\n handleConfigureClick,\n handleVerifyClick,\n handleDeleteClick,\n } = useDomainTableLogic({\n t,\n onCreateDomain,\n onVerifyDomain,\n onDeleteDomain,\n onAssociateToProvider,\n onDeleteFromProvider,\n fetchProviders,\n fetchDomains,\n });\n\n const currentStyles = React.useMemo(\n () => getComponentStyles(styling, isDarkMode),\n [styling, isDarkMode],\n );\n\n // V2: Filter domains by status\n const filteredDomains = React.useMemo(() => {\n if (!filterStatus) return domains;\n return domains.filter(domain => domain.status === filterStatus);\n }, [domains, filterStatus]);\n\n const columns: Column<Domain>[] = React.useMemo(\n () => [\n {\n type: 'text',\n accessorKey: 'domain',\n title: t('domain_table.table.columns.domain'),\n width: '35%',\n render: (domain) => <div className=\"font-medium\">{domain.domain}</div>,\n },\n {\n type: 'text',\n accessorKey: 'status',\n title: t('domain_table.table.columns.status'),\n width: '25%',\n render: (domain) => (\n <Badge variant={getStatusBadgeVariant(domain.status)} size={'sm'}>\n {t(`shared.domain_statuses.${domain.status}`)}\n </Badge>\n ),\n },\n {\n type: 'actions',\n title: '',\n width: '20%',\n render: (domain) => (\n <DomainTableActionsColumn\n domain={domain}\n readOnly={readOnly}\n customMessages={customMessages}\n onView={handleConfigureClick}\n onConfigure={handleConfigureClick}\n onVerify={handleVerifyClick}\n onDelete={handleDeleteClick}\n />\n ),\n },\n ],\n [t, readOnly, customMessages, handleConfigureClick, handleVerifyClick, handleDeleteClick],\n );\n\n return (\n <div style={currentStyles.variables}>\n {!hideHeader && (\n <div className={currentStyles.classes?.['DomainTable-header']}>\n <Header\n title={t('domain_table.header.title')}\n description={t('domain_table.header.description')}\n actions={[\n {\n type: 'button',\n label: 'Filter',\n onClick: () => setFilterStatus(filterStatus ? null : 'verified'),\n icon: Filter,\n variant: 'outline',\n disabled: readOnly || isFetching,\n },\n {\n type: 'button',\n label: t('domain_table.header.create_button_text'),\n onClick: () => handleCreateClick(),\n icon: Plus,\n disabled: createAction?.disabled || readOnly || isFetching,\n },\n ]}\n />\n </div>\n )}\n\n <DataTable\n columns={columns}\n data={filteredDomains}\n loading={isFetching}\n emptyState={{ title: t('domain_table.table.empty_message') }}\n className={currentStyles.classes?.['DomainTable-table']}\n />\n\n <DomainCreateModal\n className={currentStyles.classes?.['DomainTable-createModal']}\n isOpen={showCreateModal}\n isLoading={isCreating}\n schema={schema?.create}\n onClose={() => setShowCreateModal(false)}\n onCreate={handleCreate}\n customMessages={customMessages.create}\n />\n\n <DomainConfigureProvidersModal\n className={currentStyles.classes?.['DomainTable-configureModal']}\n domain={selectedDomain}\n providers={providers}\n isOpen={showConfigureModal}\n isLoading={isLoadingProviders}\n isLoadingSwitch={false}\n onClose={() => setShowConfigureModal(false)}\n onToggleSwitch={handleToggleSwitch}\n onOpenProvider={onOpenProvider}\n onCreateProvider={onCreateProvider}\n customMessages={customMessages.configure}\n />\n\n <DomainVerifyModal\n className={currentStyles.classes?.['DomainTable-verifyModal']}\n isOpen={showVerifyModal}\n isLoading={isVerifying}\n domain={selectedDomain}\n error={verifyError}\n onClose={handleCloseVerifyModal}\n onVerify={handleVerify}\n onDelete={handleDeleteClick}\n customMessages={customMessages.verify}\n />\n\n <DomainDeleteModal\n className={currentStyles.classes?.['DomainTable-deleteModal']}\n domain={selectedDomain}\n isOpen={showDeleteModal}\n isLoading={isDeleting}\n onClose={() => setShowDeleteModal(false)}\n onDelete={handleDelete}\n customMessages={customMessages.delete}\n />\n </div>\n );\n}\n\nexport const DomainTable = React.memo(\n withMyOrganizationService(DomainTableComponent, MY_ORGANIZATION_DOMAIN_SCOPES)\n);\n", | ||
| "type": "registry:block", | ||
| "target": "auth0-ui-components/blocks/my-organization/domain-management/domain-table.tsx" | ||
| } | ||
| ] | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| { | ||
| "name": "auth0-ui-components", | ||
| "current": "1.0.0", | ||
| "latest": "1.0.0", | ||
| "beta": "2.0.0-beta.1", | ||
| "versions": { | ||
| "1.0.0": { | ||
| "released": "2025-01-15", | ||
| "status": "stable" | ||
| }, | ||
| "2.0.0-beta.1": { | ||
| "released": "2025-02-01", | ||
| "status": "beta" | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,83 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||
| import fs from 'fs'; | ||||||||||||||||||||||||||||||||||||||||||||||||
| import path from 'path'; | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| import type { Plugin } from 'vite'; | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| const SPECIAL_FILES = ['index.json', 'registry.json', 'versions.json']; | ||||||||||||||||||||||||||||||||||||||||||||||||
| const DEFAULT_VERSION = 'v1'; | ||||||||||||||||||||||||||||||||||||||||||||||||
| const LATEST_VERSION = 'v2'; | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| export function registryMiddleware(): Plugin { | ||||||||||||||||||||||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||||||||||||||||||||||
| name: 'registry-version-middleware', | ||||||||||||||||||||||||||||||||||||||||||||||||
| configureServer(server) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| server.middlewares.use((req, res, next) => { | ||||||||||||||||||||||||||||||||||||||||||||||||
| if (!req.url || !req.url.startsWith('/r/')) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| return next(); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`); | ||||||||||||||||||||||||||||||||||||||||||||||||
| const fileName = url.pathname.replace(/^\/r\//, ''); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| if (SPECIAL_FILES.includes(fileName)) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| return next(); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| const normalizedFileName = path.normalize(fileName).replace(/^(\.\.([\\/]|$))+/, ''); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| if (normalizedFileName !== fileName || normalizedFileName.includes('..')) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| res.statusCode = 400; | ||||||||||||||||||||||||||||||||||||||||||||||||
| res.setHeader('Content-Type', 'application/json'); | ||||||||||||||||||||||||||||||||||||||||||||||||
| res.end(JSON.stringify({ error: 'Invalid file path' })); | ||||||||||||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| const versionParam = url.searchParams.get('version'); | ||||||||||||||||||||||||||||||||||||||||||||||||
| let version = versionParam || DEFAULT_VERSION; | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| if (version === 'latest') { | ||||||||||||||||||||||||||||||||||||||||||||||||
| version = LATEST_VERSION; | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| const baseDir = path.resolve(process.cwd(), 'public', 'r', version); | ||||||||||||||||||||||||||||||||||||||||||||||||
| const versionedPath = path.resolve(baseDir, normalizedFileName); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| if (!versionedPath.startsWith(baseDir + path.sep) && versionedPath !== baseDir) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| res.statusCode = 403; | ||||||||||||||||||||||||||||||||||||||||||||||||
| res.setHeader('Content-Type', 'application/json'); | ||||||||||||||||||||||||||||||||||||||||||||||||
| res.end(JSON.stringify({ error: 'Access denied' })); | ||||||||||||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| if (fs.existsSync(versionedPath)) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||
| const content = fs.readFileSync(versionedPath, 'utf-8'); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Semgrep identified an issue in your code: User-controlled URL path is used to read files without traversal validation; attackers can escape the intended directory to access arbitrary files like More details about thisThe application reads a file from Exploit scenario:
Similarly, an attacker could target Dataflow graphflowchart LR
classDef invis fill:white, stroke: none
classDef default fill:#e7f5ff, color:#1c7fd6, stroke: none
subgraph File0["<b>docs-site/src/api/registry-middleware.ts</b>"]
direction LR
%% Source
subgraph Source
direction LR
v0["<a href=https://github.com/auth0/auth0-ui-components/blob/245a83a11e15b5b566c3ab4271461cd2448a848c/docs-site/src/api/registry-middleware.ts#L19 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 19] req.headers</a>"]
end
%% Intermediate
subgraph Traces0[Traces]
direction TB
v2["<a href=https://github.com/auth0/auth0-ui-components/blob/245a83a11e15b5b566c3ab4271461cd2448a848c/docs-site/src/api/registry-middleware.ts#L19 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 19] `</a>"]
v3["<a href=https://github.com/auth0/auth0-ui-components/blob/245a83a11e15b5b566c3ab4271461cd2448a848c/docs-site/src/api/registry-middleware.ts#L19 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 19] url</a>"]
v4["<a href=https://github.com/auth0/auth0-ui-components/blob/245a83a11e15b5b566c3ab4271461cd2448a848c/docs-site/src/api/registry-middleware.ts#L20 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 20] fileName</a>"]
v5["<a href=https://github.com/auth0/auth0-ui-components/blob/245a83a11e15b5b566c3ab4271461cd2448a848c/docs-site/src/api/registry-middleware.ts#L33 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 33] versionedPath</a>"]
end
v2 --> v3
v3 --> v4
v4 --> v5
%% Sink
subgraph Sink
direction LR
v1["<a href=https://github.com/auth0/auth0-ui-components/blob/245a83a11e15b5b566c3ab4271461cd2448a848c/docs-site/src/api/registry-middleware.ts#L37 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 37] versionedPath</a>"]
end
end
%% Class Assignment
Source:::invis
Sink:::invis
Traces0:::invis
File0:::invis
%% Connections
Source --> Traces0
Traces0 --> Sink
To resolve this comment: ✨ Commit Assistant fix suggestion
Suggested change
View step-by-step instructions
💬 Ignore this findingReply with Semgrep commands to ignore this finding.
Alternatively, triage in Semgrep AppSec Platform to ignore the finding created by express-fs-filename. You can view more details about this finding in the Semgrep AppSec Platform. |
||||||||||||||||||||||||||||||||||||||||||||||||
| res.setHeader('Content-Type', 'application/json'); | ||||||||||||||||||||||||||||||||||||||||||||||||
| res.setHeader('Access-Control-Allow-Origin', '*'); | ||||||||||||||||||||||||||||||||||||||||||||||||
| res.setHeader('Cache-Control', 'public, max-age=3600'); | ||||||||||||||||||||||||||||||||||||||||||||||||
| res.end(content); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| res.statusCode = 500; | ||||||||||||||||||||||||||||||||||||||||||||||||
| res.setHeader('Content-Type', 'application/json'); | ||||||||||||||||||||||||||||||||||||||||||||||||
| res.end( | ||||||||||||||||||||||||||||||||||||||||||||||||
| JSON.stringify({ | ||||||||||||||||||||||||||||||||||||||||||||||||
| error: 'Internal Server Error', | ||||||||||||||||||||||||||||||||||||||||||||||||
| message: 'Failed to read registry file', | ||||||||||||||||||||||||||||||||||||||||||||||||
| }), | ||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||||||||||
| res.statusCode = 404; | ||||||||||||||||||||||||||||||||||||||||||||||||
| res.setHeader('Content-Type', 'application/json'); | ||||||||||||||||||||||||||||||||||||||||||||||||
| res.end( | ||||||||||||||||||||||||||||||||||||||||||||||||
| JSON.stringify({ | ||||||||||||||||||||||||||||||||||||||||||||||||
| error: 'Not Found', | ||||||||||||||||||||||||||||||||||||||||||||||||
| message: `Component "${fileName}" does not exist in version "${version}"`, | ||||||||||||||||||||||||||||||||||||||||||||||||
| hint: 'Check available versions or component name', | ||||||||||||||||||||||||||||||||||||||||||||||||
| }), | ||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Semgrep identified an issue in your code:
User-controlled file path from URL allows path traversal to read arbitrary files outside the intended directory.
More details about this
The
versionedPathvariable is constructed using thefileNameparameter extracted from the user's URL, which comes directly fromreq.urlwithout validation. An attacker can exploit this by crafting a malicious URL to traverse outside the intendedpublic/r/directory.Exploit scenario:
/r/../../config.json?version=v1fileNameas../../config.jsonfrom the URL pathnamepath.join(process.cwd(), 'public', 'r', 'v1', '../../config.json')resolves toprocess.cwd()/config.json, escaping the intended directoryfs.existsSync(versionedPath)checks this attacker-controlled pathfs.readFileSync(versionedPath)reads sensitive application config files that should not be accessibleEven though the SPECIAL_FILES allowlist exists, it only blocks exact filename matches like
index.json. Path traversal sequences like../,..\\, or encoded variants bypass this check entirely, allowing attackers to read arbitrary files within the filesystem that the Node.js process has permissions to access.Dataflow graph
flowchart LR classDef invis fill:white, stroke: none classDef default fill:#e7f5ff, color:#1c7fd6, stroke: none subgraph File0["<b>docs-site/src/api/registry-middleware.ts</b>"] direction LR %% Source subgraph Source direction LR v0["<a href=https://github.com/auth0/auth0-ui-components/blob/245a83a11e15b5b566c3ab4271461cd2448a848c/docs-site/src/api/registry-middleware.ts#L19 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 19] req.headers</a>"] end %% Intermediate subgraph Traces0[Traces] direction TB v2["<a href=https://github.com/auth0/auth0-ui-components/blob/245a83a11e15b5b566c3ab4271461cd2448a848c/docs-site/src/api/registry-middleware.ts#L19 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 19] `</a>"] v3["<a href=https://github.com/auth0/auth0-ui-components/blob/245a83a11e15b5b566c3ab4271461cd2448a848c/docs-site/src/api/registry-middleware.ts#L19 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 19] url</a>"] v4["<a href=https://github.com/auth0/auth0-ui-components/blob/245a83a11e15b5b566c3ab4271461cd2448a848c/docs-site/src/api/registry-middleware.ts#L20 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 20] fileName</a>"] v5["<a href=https://github.com/auth0/auth0-ui-components/blob/245a83a11e15b5b566c3ab4271461cd2448a848c/docs-site/src/api/registry-middleware.ts#L33 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 33] versionedPath</a>"] end v2 --> v3 v3 --> v4 v4 --> v5 %% Sink subgraph Sink direction LR v1["<a href=https://github.com/auth0/auth0-ui-components/blob/245a83a11e15b5b566c3ab4271461cd2448a848c/docs-site/src/api/registry-middleware.ts#L35 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 35] versionedPath</a>"] end end %% Class Assignment Source:::invis Sink:::invis Traces0:::invis File0:::invis %% Connections Source --> Traces0 Traces0 --> SinkTo resolve this comment:
✨ Commit Assistant fix suggestion
View step-by-step instructions
Import Node.js's
path.normalize()andpath.resolve()functions at the top of the file (these are already available from the importedpathmodule).After extracting
fileNamefrom the URL, validate and sanitize it by normalizing the path and ensuring it doesn't contain directory traversal sequences:This removes any
../or..\sequences that could allow directory traversal.Create a safe base directory path using
path.resolve()to get the absolute path:Build the full file path and resolve it to an absolute path:
Add a security check to verify the resolved path is still within the intended directory:
This ensures that even after path resolution, the file remains within the
public/r/version/directory and prevents access to files outside this directory.💬 Ignore this finding
Reply with Semgrep commands to ignore this finding.
/fp <comment>for false positive/ar <comment>for acceptable risk/other <comment>for all other reasonsAlternatively, triage in Semgrep AppSec Platform to ignore the finding created by express-fs-filename.
You can view more details about this finding in the Semgrep AppSec Platform.