Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions .github/workflows/preview-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,14 @@ jobs:
# Create a completely isolated deployment directory
mkdir -p /tmp/static-deploy

# Copy only the built static files
# Copy the built static files
cp -r docs-site/dist/* /tmp/static-deploy/

# Create a vercel.json that forces static deployment
echo '{"builds":[{"src":"**/*","use":"@vercel/static"}],"rewrites":[{"source":"/(.*)","destination":"/index.html"}]}' > /tmp/static-deploy/vercel.json
# Copy the API serverless functions
cp -r docs-site/api /tmp/static-deploy/

# Create a vercel.json with API functions and rewrites (no build needed - already built)
echo '{"buildCommand":"","installCommand":"","rewrites":[{"source":"/r/:path*","destination":"/api/r?file=:path*"},{"source":"/(.*)","destination":"/index.html"}]}' > /tmp/static-deploy/vercel.json

echo "Contents of deployment directory:"
ls -la /tmp/static-deploy/
Expand Down
84 changes: 84 additions & 0 deletions docs-site/api/r.ts
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',
});
}
1 change: 1 addition & 0 deletions docs-site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/react-syntax-highlighter": "^15.5.13",
"@vercel/node": "^5.5.16",
"globals": "^15.9.0",
"vite": "^7.2.4"
}
Expand Down
34 changes: 34 additions & 0 deletions docs-site/public/r/v2/domain-table.json
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"
}
]
}
16 changes: 16 additions & 0 deletions docs-site/public/r/versions.json
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"
}
}
}
83 changes: 83 additions & 0 deletions docs-site/src/api/registry-middleware.ts
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)) {

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 versionedPath variable is constructed using the fileName parameter extracted from the user's URL, which comes directly from req.url without validation. An attacker can exploit this by crafting a malicious URL to traverse outside the intended public/r/ directory.

Exploit scenario:

  1. Attacker sends a request: /r/../../config.json?version=v1
  2. The code extracts fileName as ../../config.json from the URL pathname
  3. path.join(process.cwd(), 'public', 'r', 'v1', '../../config.json') resolves to process.cwd()/config.json, escaping the intended directory
  4. fs.existsSync(versionedPath) checks this attacker-controlled path
  5. If the file exists, fs.readFileSync(versionedPath) reads sensitive application config files that should not be accessible

Even 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 --> Sink


Loading

To resolve this comment:

✨ Commit Assistant fix suggestion

Suggested change
if (fs.existsSync(versionedPath)) {
// Sanitize and validate the file path to prevent directory traversal attacks
const normalizedFileName = path.normalize(fileName).replace(/^(\.\.(\/|\\|$))+/, '');
const baseDir = path.resolve(process.cwd(), 'public', 'r', version);
const versionedPath = path.resolve(baseDir, normalizedFileName);
// Security check: ensure the resolved path stays within the intended directory
if (!versionedPath.startsWith(baseDir + path.sep) && versionedPath !== baseDir) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ error: 'Invalid file path' }));
return;
}
if (fs.existsSync(versionedPath)) {
View step-by-step instructions
  1. Import Node.js's path.normalize() and path.resolve() functions at the top of the file (these are already available from the imported path module).

  2. After extracting fileName from the URL, validate and sanitize it by normalizing the path and ensuring it doesn't contain directory traversal sequences:

    const fileName = url.pathname.replace(/^\/r\//, '');
    const normalizedFileName = path.normalize(fileName).replace(/^(\.\.(\/|\\|$))+/, '');

    This removes any ../ or ..\ sequences that could allow directory traversal.

  3. Create a safe base directory path using path.resolve() to get the absolute path:

    const baseDir = path.resolve(process.cwd(), 'public', 'r', version);
  4. Build the full file path and resolve it to an absolute path:

    const versionedPath = path.resolve(baseDir, normalizedFileName);
  5. Add a security check to verify the resolved path is still within the intended directory:

    if (!versionedPath.startsWith(baseDir)) {
      res.statusCode = 400;
      res.setHeader('Content-Type', 'application/json');
      res.end(JSON.stringify({ error: 'Invalid file path' }));
      return;
    }

    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 reasons

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.

try {
const content = fs.readFileSync(versionedPath, 'utf-8');

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 URL path is used to read files without traversal validation; attackers can escape the intended directory to access arbitrary files like /etc/passwd or .env.

More details about this

The application reads a file from versionedPath without validating that the path stays within the intended public/r/{version} directory. Since fileName is extracted directly from the user-controlled URL (url.pathname.replace(/^\/r\//, '')), an attacker can use path traversal sequences like ../ to escape the directory and access arbitrary files.

Exploit scenario:

  1. Attacker sends a request: GET /r/../../etc/passwd?version=v1
  2. The fileName variable is set to ../../etc/passwd
  3. path.join(process.cwd(), 'public', 'r', 'v1', '../../etc/passwd') resolves to {cwd}/etc/passwd (traversing up and out of the intended directory)
  4. fs.readFileSync(versionedPath) reads the system's /etc/passwd file, exposing sensitive system information
  5. The attacker receives the file contents in the HTTP response

Similarly, an attacker could target ../../.env to leak environment variables containing database credentials or API keys, or ../../config.json to access application secrets.

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#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


Loading

To resolve this comment:

✨ Commit Assistant fix suggestion

Suggested change
const content = fs.readFileSync(versionedPath, 'utf-8');
// Validate and normalize the file path to prevent path traversal attacks
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 baseDir = path.resolve(process.cwd(), 'public', 'r', version);
const versionedPath = path.join(baseDir, normalizedFileName);
// Verify the resolved path stays within the base directory
const resolvedPath = path.resolve(versionedPath);
if (!resolvedPath.startsWith(baseDir + path.sep) && resolvedPath !== baseDir) {
res.statusCode = 403;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ error: 'Access denied' }));
return;
}
const content = fs.readFileSync(resolvedPath, 'utf-8');
View step-by-step instructions
  1. Import Node.js's path.normalize() and path.resolve() functions, which are already available in your path dependency.
  2. Add validation to ensure fileName doesn't contain path traversal sequences before constructing the file path. Add this check after extracting fileName:
    const normalizedFileName = path.normalize(fileName).replace(/^(\.\.[\/\\])+/, '');
    if (normalizedFileName !== fileName || normalizedFileName.includes('..')) {
      res.statusCode = 400;
      res.end(JSON.stringify({ error: 'Invalid file path' }));
      return;
    }
  3. Use path.resolve() to get the absolute path of your public directory base: const baseDir = path.resolve(process.cwd(), 'public', 'r', version);
  4. Construct the full file path using the normalized fileName: const versionedPath = path.join(baseDir, normalizedFileName);
  5. Add a security check to verify the resolved path stays within the base directory:
    const resolvedPath = path.resolve(versionedPath);
    if (!resolvedPath.startsWith(baseDir)) {
      res.statusCode = 403;
      res.end(JSON.stringify({ error: 'Access denied' }));
      return;
    }
    This prevents attackers from using sequences like ../../../etc/passwd to access files outside your registry 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 reasons

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',
}),
);
}
});
},
};
}
4 changes: 4 additions & 0 deletions docs-site/vercel.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
"framework": "vite",
"outputDirectory": "dist",
"rewrites": [
{
"source": "/r/:path*",
"destination": "/api/r?file=:path*"
},
{
"source": "/(.*)",
"destination": "/index.html"
Expand Down
4 changes: 3 additions & 1 deletion docs-site/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import path from 'path';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';

import { registryMiddleware } from './src/api/registry-middleware';

export default defineConfig({
plugins: [react()],
plugins: [react(), registryMiddleware()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
Expand Down
Loading
Loading