From 5f6714b77094d394b4eaf43cb8727f87fd046c5e Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Sun, 15 Feb 2026 11:25:53 +1100 Subject: [PATCH 1/3] Add more validations --- agent/internal/agent/backup.go | 17 +++++++++++++++++ web/actions/projects.ts | 15 ++++++++++++--- web/app/api/github/manifest/callback/route.ts | 2 +- web/lib/github.ts | 8 ++++++++ 4 files changed, 38 insertions(+), 4 deletions(-) diff --git a/agent/internal/agent/backup.go b/agent/internal/agent/backup.go index 509b350..7469298 100644 --- a/agent/internal/agent/backup.go +++ b/agent/internal/agent/backup.go @@ -957,6 +957,14 @@ func extractTarGz(archivePath, destPath string) error { return fmt.Errorf("invalid tar entry: %s", header.Name) } + parentDir := filepath.Dir(targetPath) + if resolvedParent, err := filepath.EvalSymlinks(parentDir); err == nil { + if !strings.HasPrefix(resolvedParent, filepath.Clean(destPath)+string(os.PathSeparator)) && + resolvedParent != filepath.Clean(destPath) { + return fmt.Errorf("invalid tar entry (symlink traversal): %s", header.Name) + } + } + switch header.Typeflag { case tar.TypeDir: if err := os.MkdirAll(targetPath, os.FileMode(header.Mode)); err != nil { @@ -976,6 +984,15 @@ func extractTarGz(archivePath, destPath string) error { } outFile.Close() case tar.TypeSymlink: + linkTarget := header.Linkname + if !filepath.IsAbs(linkTarget) { + linkTarget = filepath.Join(filepath.Dir(targetPath), linkTarget) + } + resolvedLink := filepath.Clean(linkTarget) + if !strings.HasPrefix(resolvedLink, filepath.Clean(destPath)+string(os.PathSeparator)) && + resolvedLink != filepath.Clean(destPath) { + return fmt.Errorf("invalid symlink target: %s -> %s", header.Name, header.Linkname) + } if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil { return fmt.Errorf("failed to create parent directory: %w", err) } diff --git a/web/actions/projects.ts b/web/actions/projects.ts index 59fc8c1..862f069 100644 --- a/web/actions/projects.ts +++ b/web/actions/projects.ts @@ -41,9 +41,6 @@ import { startMigration } from "./migrations"; import { inngest } from "@/lib/inngest/client"; function isValidImageReferencePart(reference: string): boolean { - // Allow only characters that are valid in Docker tags/digests and avoid path traversal. - // Tags: letters, digits, underscores, periods and dashes; Digests: "algorithm:hex". - // This regex is intentionally conservative. const tagPattern = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/; const digestPattern = /^[A-Za-z0-9_+.-]+:[0-9a-fA-F]{32,256}$/; @@ -54,6 +51,11 @@ function isValidImageReferencePart(reference: string): boolean { ); } +function isValidImageNamePart(part: string): boolean { + const segmentPattern = /^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$/; + return part.split("/").every((segment) => segmentPattern.test(segment)); +} + function parseImageReference(image: string): { registry: string; namespace: string; @@ -117,6 +119,13 @@ export async function validateDockerImage( }; } + if (!isValidImageNamePart(namespace) || !isValidImageNamePart(repository)) { + return { + valid: false, + error: "Invalid image name", + }; + } + if (registry === "docker.io") { const repoPath = namespace === "library" ? repository : `${namespace}/${repository}`; diff --git a/web/app/api/github/manifest/callback/route.ts b/web/app/api/github/manifest/callback/route.ts index 84fae82..b3f4afb 100644 --- a/web/app/api/github/manifest/callback/route.ts +++ b/web/app/api/github/manifest/callback/route.ts @@ -4,7 +4,7 @@ export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams; const code = searchParams.get("code"); - if (!code) { + if (!code || !/^[a-zA-Z0-9]+$/.test(code)) { return NextResponse.redirect( new URL("/dashboard/settings?github_error=missing_code", request.url), ); diff --git a/web/lib/github.ts b/web/lib/github.ts index e6b2e07..5b07b7b 100644 --- a/web/lib/github.ts +++ b/web/lib/github.ts @@ -122,6 +122,12 @@ export function buildCloneUrl(token: string, repoFullName: string): string { return `https://x-access-token:${token}@github.com/${repoFullName}.git`; } +function validateRepoFullName(repoFullName: string): void { + if (!/^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/.test(repoFullName)) { + throw new Error("Invalid repository name"); + } +} + type DeploymentState = | "pending" | "in_progress" @@ -136,6 +142,7 @@ export async function createGitHubDeployment( environment: string, description: string, ): Promise { + validateRepoFullName(repoFullName); const token = await getInstallationToken(installationId); const response = await fetch( @@ -178,6 +185,7 @@ export async function updateGitHubDeploymentStatus( environmentUrl?: string; }, ): Promise { + validateRepoFullName(repoFullName); const token = await getInstallationToken(installationId); const body: Record = { From 2ceed6968ad4e6ffd3bb3ff8a1f6321c6a6b5fa5 Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Tue, 24 Feb 2026 23:44:49 +1100 Subject: [PATCH 2/3] UI clean up --- .../import-compose/import-compose-form.tsx | 16 +- web/components/core/action-button.tsx | 69 ------- web/components/core/editable-text.tsx | 2 +- web/components/logs/log-viewer.tsx | 29 +-- .../details/pending-changes-banner.tsx | 2 +- .../service/details/secrets-section.tsx | 2 +- web/components/ui/command.tsx | 190 ------------------ web/components/ui/input-group.tsx | 158 --------------- web/lib/email/templates/alert.tsx | 4 +- web/lib/email/templates/test-email.tsx | 36 ---- 10 files changed, 29 insertions(+), 479 deletions(-) delete mode 100644 web/components/core/action-button.tsx delete mode 100644 web/components/ui/command.tsx delete mode 100644 web/components/ui/input-group.tsx delete mode 100644 web/lib/email/templates/test-email.tsx diff --git a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/import-compose/import-compose-form.tsx b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/import-compose/import-compose-form.tsx index c583a7d..54d25af 100644 --- a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/import-compose/import-compose-form.tsx +++ b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/import-compose/import-compose-form.tsx @@ -243,8 +243,8 @@ services: Error - {errors.map((err, i) => ( -
{err.message}
+ {errors.map((err) => ( +
{err.message}
))}
@@ -335,8 +335,8 @@ services:
    - {warnings.map((w, i) => ( -
  • + {warnings.map((w) => ( +
  • {w.service ? {w.service}: : null}{" "} {w.message}
  • @@ -352,8 +352,8 @@ services: Errors ({errors.length})
      - {errors.map((e, i) => ( -
    • + {errors.map((e) => ( +
    • {e.service ? {e.service}: : null}{" "} {e.message}
    • @@ -482,8 +482,8 @@ services:
        - {importResult.warnings.map((w, i) => ( -
      • + {importResult.warnings.map((w) => ( +
      • {w.service ? {w.service}: : null}{" "} {w.message}
      • diff --git a/web/components/core/action-button.tsx b/web/components/core/action-button.tsx deleted file mode 100644 index 7aec496..0000000 --- a/web/components/core/action-button.tsx +++ /dev/null @@ -1,69 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { toast } from "sonner"; -import { Button } from "@/components/ui/button"; - -interface ActionButtonProps { - action: () => Promise; - label: string; - loadingLabel: string; - variant?: - | "default" - | "positive" - | "destructive" - | "outline" - | "secondary" - | "ghost" - | "link" - | "warning"; - size?: "default" | "sm" | "lg" | "icon"; - onComplete?: () => void; -} - -export function ActionButton({ - action, - label, - loadingLabel, - variant = "default", - size = "sm", - onComplete, -}: ActionButtonProps) { - const [isLoading, setIsLoading] = useState(false); - - const handleClick = async () => { - setIsLoading(true); - try { - await action(); - onComplete?.(); - } catch (error) { - console.error("Action failed:", error); - // Handle all error types: Error instances, objects with message, strings, or unknown - const errorMessage = - error instanceof Error - ? error.message - : typeof error === "string" - ? error - : error && - typeof error === "object" && - "message" in error && - typeof error.message === "string" - ? error.message - : "An error occurred"; - toast.error(errorMessage); - } finally { - setIsLoading(false); - } - }; - - return ( - - ); -} diff --git a/web/components/core/editable-text.tsx b/web/components/core/editable-text.tsx index 0088665..e9ead32 100644 --- a/web/components/core/editable-text.tsx +++ b/web/components/core/editable-text.tsx @@ -30,7 +30,7 @@ export function EditableText({ }) { const [isOpen, setIsOpen] = useState(false); const [isSaving, setIsSaving] = useState(false); - const [inputValue, setInputValue] = useState(value); + const [inputValue, setInputValue] = useState(""); const inputRef = useRef(null); const handleFocus = () => { diff --git a/web/components/logs/log-viewer.tsx b/web/components/logs/log-viewer.tsx index 7519a80..6a7d05b 100644 --- a/web/components/logs/log-viewer.tsx +++ b/web/components/logs/log-viewer.tsx @@ -13,7 +13,7 @@ import { parseAsStringLiteral, useQueryState, } from "nuqs"; -import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useLayoutEffect, useMemo, useRef, useState } from "react"; import useSWR from "swr"; import { Button } from "@/components/ui/button"; import { @@ -116,7 +116,7 @@ function highlightMatches(text: string, search: string): React.ReactNode { return parts.map((part, i) => regex.test(part) ? ( {part} @@ -648,6 +648,14 @@ export function LogViewer(props: LogViewerProps) { const [olderLogs, setOlderLogs] = useState([]); const [isLoadingOlder, setIsLoadingOlder] = useState(false); const [olderHasMore, setOlderHasMore] = useState(true); + const [prevSelectedServerId, setPrevSelectedServerId] = + useState(selectedServerId); + + if (selectedServerId !== prevSelectedServerId) { + setPrevSelectedServerId(selectedServerId); + setOlderLogs([]); + setOlderHasMore(true); + } const servers = props.variant === "service-logs" ? props.servers : undefined; @@ -688,11 +696,6 @@ export function LogViewer(props: LogViewerProps) { const hasMore = olderLogs.length === 0 ? data?.hasMore || false : olderHasMore; - useEffect(() => { - setOlderLogs([]); - setOlderHasMore(true); - }, [selectedServerId]); - const loadOlderLogs = async () => { if (isLoadingOlder || !hasMore) return; @@ -974,12 +977,12 @@ export function LogViewer(props: LogViewerProps) { ) : (
        - {filteredLogs.map((entry, idx) => { + {filteredLogs.map((entry) => { if (props.variant === "service-logs") { const e = entry as ServiceLogEntry; return ( @@ -989,7 +992,7 @@ export function LogViewer(props: LogViewerProps) { const e = entry as RequestEntry; return ( @@ -999,7 +1002,7 @@ export function LogViewer(props: LogViewerProps) { const e = entry as ServerLogEntry; return ( @@ -1009,7 +1012,7 @@ export function LogViewer(props: LogViewerProps) { const e = entry as BuildLogEntry; return ( @@ -1018,7 +1021,7 @@ export function LogViewer(props: LogViewerProps) { const e = entry as BuildLogEntry; return ( diff --git a/web/components/service/details/pending-changes-banner.tsx b/web/components/service/details/pending-changes-banner.tsx index f584ba3..3cfd102 100644 --- a/web/components/service/details/pending-changes-banner.tsx +++ b/web/components/service/details/pending-changes-banner.tsx @@ -85,7 +85,7 @@ export const PendingChangesBanner = memo(function PendingChangesBanner({
        {changes.map((change, i) => (
        diff --git a/web/components/service/details/secrets-section.tsx b/web/components/service/details/secrets-section.tsx index 2d0a51e..bb829df 100644 --- a/web/components/service/details/secrets-section.tsx +++ b/web/components/service/details/secrets-section.tsx @@ -278,7 +278,7 @@ export const SecretsSection = memo(function SecretsSection({ )} {pendingVars.map((variable, index) => (
        diff --git a/web/components/ui/command.tsx b/web/components/ui/command.tsx deleted file mode 100644 index 9f7152b..0000000 --- a/web/components/ui/command.tsx +++ /dev/null @@ -1,190 +0,0 @@ -"use client"; - -import * as React from "react"; -import { Command as CommandPrimitive } from "cmdk"; - -import { cn } from "@/lib/utils"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { InputGroup, InputGroupAddon } from "@/components/ui/input-group"; -import { SearchIcon, CheckIcon } from "lucide-react"; - -function Command({ - className, - ...props -}: React.ComponentProps) { - return ( - - ); -} - -function CommandDialog({ - title = "Command Palette", - description = "Search for a command to run...", - children, - className, - showCloseButton = false, - ...props -}: Omit, "children"> & { - title?: string; - description?: string; - className?: string; - showCloseButton?: boolean; - children: React.ReactNode; -}) { - return ( - - - {title} - {description} - - - {children} - - - ); -} - -function CommandInput({ - className, - ...props -}: React.ComponentProps) { - return ( -
        - - - - - - -
        - ); -} - -function CommandList({ - className, - ...props -}: React.ComponentProps) { - return ( - - ); -} - -function CommandEmpty({ - className, - ...props -}: React.ComponentProps) { - return ( - - ); -} - -function CommandGroup({ - className, - ...props -}: React.ComponentProps) { - return ( - - ); -} - -function CommandSeparator({ - className, - ...props -}: React.ComponentProps) { - return ( - - ); -} - -function CommandItem({ - className, - children, - ...props -}: React.ComponentProps) { - return ( - - {children} - - - ); -} - -function CommandShortcut({ - className, - ...props -}: React.ComponentProps<"span">) { - return ( - - ); -} - -export { - Command, - CommandDialog, - CommandInput, - CommandList, - CommandEmpty, - CommandGroup, - CommandItem, - CommandShortcut, - CommandSeparator, -}; diff --git a/web/components/ui/input-group.tsx b/web/components/ui/input-group.tsx deleted file mode 100644 index b74357c..0000000 --- a/web/components/ui/input-group.tsx +++ /dev/null @@ -1,158 +0,0 @@ -"use client"; - -import * as React from "react"; -import { cva, type VariantProps } from "class-variance-authority"; - -import { cn } from "@/lib/utils"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Textarea } from "@/components/ui/textarea"; - -function InputGroup({ className, ...props }: React.ComponentProps<"div">) { - return ( -
        [data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5 [[data-slot=combobox-content]_&]:focus-within:border-inherit [[data-slot=combobox-content]_&]:focus-within:ring-0 group/input-group relative flex w-full min-w-0 items-center outline-none has-[>textarea]:h-auto", - className, - )} - {...props} - /> - ); -} - -const inputGroupAddonVariants = cva( - "text-muted-foreground h-auto gap-2 py-1.5 text-sm font-medium group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4 flex cursor-text items-center justify-center select-none", - { - variants: { - align: { - "inline-start": - "pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem] order-first", - "inline-end": - "pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem] order-last", - "block-start": - "px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2 order-first w-full justify-start", - "block-end": - "px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2 order-last w-full justify-start", - }, - }, - defaultVariants: { - align: "inline-start", - }, - }, -); - -function InputGroupAddon({ - className, - align = "inline-start", - ...props -}: React.ComponentProps<"div"> & VariantProps) { - return ( -
        { - if ((e.target as HTMLElement).closest("button")) { - return; - } - e.currentTarget.parentElement?.querySelector("input")?.focus(); - }} - {...props} - /> - ); -} - -const inputGroupButtonVariants = cva( - "gap-2 text-sm shadow-none flex items-center", - { - variants: { - size: { - xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5", - sm: "", - "icon-xs": - "size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0", - "icon-sm": "size-8 p-0 has-[>svg]:p-0", - }, - }, - defaultVariants: { - size: "xs", - }, - }, -); - -function InputGroupButton({ - className, - type = "button", - variant = "ghost", - size = "xs", - ...props -}: Omit, "size" | "type"> & - VariantProps & { - type?: "button" | "submit" | "reset"; - }) { - return ( -