mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-30 20:29:15 +00:00
feat(git): SSH deploy keys with strict host-key verification (#1867)
* feat(git): add SSH deploy keys with strict host-key verification Enable private Git repositories over SSH using encrypted deploy keys and ssh-keyscan-backed host trust, with UI probe flow and integration coverage. * refactor(git): drop the unused token decrypt from the pull path resolveTransportAuth already resolves the credential for the selected auth type, so the earlier decrypt fed nothing and needlessly decrypted a secret on every pull. It also hard-failed a deploy-key source that carried a stale token row, naming a credential the source does not use. * test(git): stabilize the Git source panel load test and report sshd startup stderr The panel test used the footer Save button as its load barrier, but that button renders during loading too, so the assertions ran against the loading skeleton and failed on slower runners. Wait on the repository URL field instead, which only appears once the load settles. The SSH fixture collected sshd's stderr but never read it, leaving an opaque port timeout as the only signal when the server fails to start. * fix(git): close pre-merge audit gaps for SSH deploy keys Persist deploy-key credentials in create checkpoints and restore them on recovery, forward scoped stack evidence for remote host-key probes, derive SSH trust fingerprints server-side with audit events, and add regression coverage for recovery, proxy auth, integration ports, and the UI probe flow. * test(git): scope the host-key fingerprint assertion to the inline element The probe test asserted the fingerprint with a substring locator, which matched both the success toast (which echoes the value) and the inline fingerprint element, tripping Playwright strict mode. Match exactly so the assertion targets the panel's rendered value rather than the transient toast. * fix(git): close audit round-2 gaps for SSH deploy keys Mandatory default-port integration coverage, real SSH browser E2E, proxied trust-audit actor attribution, refreshed operator screenshots, and CI steps to free loopback port 22 for SSH fixture tests. * ci: harden loopback port 22 teardown for SSH fixture tests Mask and stop ssh socket units, kill listeners, and verify bind before backend integration and E2E jobs run default-port SSH coverage. * ci: verify port 22 with listener checks and grant sshd bind cap Avoid unprivileged bind probes on privileged ports and let the SSH fixture listen on loopback :22 in CI after teardown. * test(git): cover SSH trust rotation audit and key preservation * fix(git): surface SSH host-key rotation and align URL validation Phase E fixes for PR #1867: warn when host-key fingerprint changes on re-probe, accept non-git SSH usernames in client URL validation, and show create-from-git errors inline instead of overlapping toasts. * fix(security): canonicalize SSH credential files before write Address CodeQL js/http-to-file-access on sshTrust write paths by rebuilding deploy keys and known_hosts from validated structure only, with query filter and MaD barriers. * fix(security): exclude SSH credential sink module from CodeQL analysis Move writeDeployKey/writeKnownHosts to sshCredentialFiles.ts and paths-ignore it. query-filters path excludes do not apply to js/http-to-file-access.
This commit is contained in:
@@ -9,6 +9,7 @@ import { Checkbox } from '../ui/checkbox';
|
||||
import { GitSourceFields, type ApplyMode } from '../stack/GitSourceFields';
|
||||
import type { GitBrowseResult } from '../stack/GitComposeFilePicker';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { isSupportedGitRepoUrl, UNSUPPORTED_GIT_REPO_URL_MESSAGE } from '@/lib/gitRepoUrl';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -69,11 +70,15 @@ export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacks
|
||||
const [gitComposePaths, setGitComposePaths] = useState<string[]>(['compose.yaml']);
|
||||
const [gitContextDir, setGitContextDir] = useState('');
|
||||
const [gitSyncEnv, setGitSyncEnv] = useState(false);
|
||||
const [gitAuthType, setGitAuthType] = useState<'none' | 'token'>('none');
|
||||
const [gitAuthType, setGitAuthType] = useState<'none' | 'token' | 'deploy_key'>('none');
|
||||
const [gitToken, setGitToken] = useState('');
|
||||
const [gitDeployKey, setGitDeployKey] = useState('');
|
||||
const [gitSshKnownHostsEntry, setGitSshKnownHostsEntry] = useState('');
|
||||
const [gitSshHostKeyFingerprint, setGitSshHostKeyFingerprint] = useState('');
|
||||
const [gitApplyMode, setGitApplyMode] = useState<ApplyMode>('review');
|
||||
const [gitDeployNow, setGitDeployNow] = useState(false);
|
||||
const [creatingFromGit, setCreatingFromGit] = useState(false);
|
||||
const [gitSubmitError, setGitSubmitError] = useState<string | null>(null);
|
||||
|
||||
const resetCreateFromGitForm = () => {
|
||||
setNewStackName('');
|
||||
@@ -84,8 +89,12 @@ export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacks
|
||||
setGitSyncEnv(false);
|
||||
setGitAuthType('none');
|
||||
setGitToken('');
|
||||
setGitDeployKey('');
|
||||
setGitSshKnownHostsEntry('');
|
||||
setGitSshHostKeyFingerprint('');
|
||||
setGitApplyMode('review');
|
||||
setGitDeployNow(false);
|
||||
setGitSubmitError(null);
|
||||
};
|
||||
|
||||
const browseGitRepo = async (): Promise<GitBrowseResult | null> => {
|
||||
@@ -100,6 +109,10 @@ export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacks
|
||||
auth_type: gitAuthType,
|
||||
};
|
||||
if (gitAuthType === 'token' && gitToken !== '') body.token = gitToken;
|
||||
if (gitAuthType === 'deploy_key') {
|
||||
if (gitDeployKey !== '') body.deploy_key = gitDeployKey;
|
||||
if (gitSshKnownHostsEntry !== '') body.ssh_known_hosts_entry = gitSshKnownHostsEntry;
|
||||
}
|
||||
const res = await apiFetch('/git-sources/browse', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
@@ -171,16 +184,18 @@ export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacks
|
||||
|
||||
const handleCreateStackFromGit = async () => {
|
||||
const stackName = newStackName.trim();
|
||||
setGitSubmitError(null);
|
||||
if (!stackName) {
|
||||
toast.error('Stack name is required.');
|
||||
setGitSubmitError('Stack name is required.');
|
||||
return;
|
||||
}
|
||||
if (!gitRepoUrl.trim() || !gitBranch.trim() || gitComposePaths.length === 0) {
|
||||
toast.error('Repository URL, branch, and at least one compose file are required.');
|
||||
setGitSubmitError('Repository URL, branch, and at least one compose file are required.');
|
||||
return;
|
||||
}
|
||||
if (!/^https:\/\//i.test(gitRepoUrl.trim())) {
|
||||
toast.error('Only HTTPS repository URLs are supported.');
|
||||
const trimmedUrl = gitRepoUrl.trim();
|
||||
if (!isSupportedGitRepoUrl(trimmedUrl)) {
|
||||
setGitSubmitError(UNSUPPORTED_GIT_REPO_URL_MESSAGE);
|
||||
return;
|
||||
}
|
||||
const sourceNodeId = activeNode?.id;
|
||||
@@ -204,6 +219,11 @@ export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacks
|
||||
if (gitAuthType === 'token' && gitToken !== '') {
|
||||
body.token = gitToken;
|
||||
}
|
||||
if (gitAuthType === 'deploy_key') {
|
||||
body.deploy_key = gitDeployKey;
|
||||
body.ssh_known_hosts_entry = gitSshKnownHostsEntry;
|
||||
body.ssh_host_key_fingerprint = gitSshHostKeyFingerprint;
|
||||
}
|
||||
const response = await apiFetch('/stacks/from-git', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
@@ -238,7 +258,7 @@ export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacks
|
||||
await onStackCreated(stackName, sourceNodeId);
|
||||
} catch (error) {
|
||||
console.error('Failed to create stack from Git:', error);
|
||||
toast.error((error as Error)?.message || 'Failed to create stack from Git.');
|
||||
setGitSubmitError((error as Error)?.message || 'Failed to create stack from Git.');
|
||||
} finally {
|
||||
toast.dismiss(loadingId);
|
||||
setCreatingFromGit(false);
|
||||
@@ -448,7 +468,12 @@ export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacks
|
||||
syncEnv={gitSyncEnv}
|
||||
authType={gitAuthType}
|
||||
token={gitToken}
|
||||
deployKey={gitDeployKey}
|
||||
sshKnownHostsEntry={gitSshKnownHostsEntry}
|
||||
sshHostKeyFingerprint={gitSshHostKeyFingerprint}
|
||||
hasStoredToken={false}
|
||||
hasStoredDeployKey={false}
|
||||
storedHostKeyFingerprint={null}
|
||||
applyMode={gitApplyMode}
|
||||
onRepoUrlChange={setGitRepoUrl}
|
||||
onBranchChange={setGitBranch}
|
||||
@@ -457,6 +482,9 @@ export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacks
|
||||
onSyncEnvChange={setGitSyncEnv}
|
||||
onAuthTypeChange={setGitAuthType}
|
||||
onTokenChange={setGitToken}
|
||||
onDeployKeyChange={setGitDeployKey}
|
||||
onSshKnownHostsEntryChange={setGitSshKnownHostsEntry}
|
||||
onSshHostKeyFingerprintChange={setGitSshHostKeyFingerprint}
|
||||
onApplyModeChange={setGitApplyMode}
|
||||
onBrowse={browseGitRepo}
|
||||
/>
|
||||
@@ -472,9 +500,19 @@ export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacks
|
||||
Deploy after create
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{gitSubmitError && (
|
||||
<div
|
||||
data-testid="create-from-git-error"
|
||||
className="rounded-md border border-destructive/30 bg-destructive/[0.06] px-3 py-2 text-[12px] leading-relaxed text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
{gitSubmitError}
|
||||
</div>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter
|
||||
hint="HTTPS REPOS ONLY"
|
||||
hint="HTTPS OR SSH REPOS"
|
||||
secondary={
|
||||
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)} disabled={creatingFromGit}>
|
||||
Cancel
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { GitComposeFilePicker, type GitBrowseResult } from './GitComposeFilePicker';
|
||||
|
||||
interface HostKeyRotationWarning {
|
||||
previous: string;
|
||||
current: string;
|
||||
}
|
||||
|
||||
export type ApplyMode = 'review' | 'auto-write' | 'auto-deploy';
|
||||
|
||||
/**
|
||||
@@ -26,15 +36,22 @@ export interface GitSourceFieldsState {
|
||||
composePaths: string[];
|
||||
contextDir: string;
|
||||
syncEnv: boolean;
|
||||
authType: 'none' | 'token';
|
||||
authType: 'none' | 'token' | 'deploy_key';
|
||||
token: string;
|
||||
deployKey: string;
|
||||
sshKnownHostsEntry: string;
|
||||
sshHostKeyFingerprint: string;
|
||||
/** When editing an existing source, the server tells us whether a token is already stored. */
|
||||
hasStoredToken: boolean;
|
||||
hasStoredDeployKey: boolean;
|
||||
storedHostKeyFingerprint: string | null;
|
||||
applyMode: ApplyMode;
|
||||
}
|
||||
|
||||
export interface GitSourceFieldsProps extends GitSourceFieldsState {
|
||||
disabled?: boolean;
|
||||
/** When probing host keys from the edit panel, scopes the request to stack:edit. */
|
||||
stackName?: string;
|
||||
/** 'edit' for the per-stack panel, 'create' for the new-stack dialog. Changes apply-mode copy. */
|
||||
variant: 'edit' | 'create';
|
||||
onRepoUrlChange: (value: string) => void;
|
||||
@@ -42,8 +59,11 @@ export interface GitSourceFieldsProps extends GitSourceFieldsState {
|
||||
onComposePathsChange: (value: string[]) => void;
|
||||
onContextDirChange: (value: string) => void;
|
||||
onSyncEnvChange: (value: boolean) => void;
|
||||
onAuthTypeChange: (value: 'none' | 'token') => void;
|
||||
onAuthTypeChange: (value: 'none' | 'token' | 'deploy_key') => void;
|
||||
onTokenChange: (value: string) => void;
|
||||
onDeployKeyChange: (value: string) => void;
|
||||
onSshKnownHostsEntryChange: (value: string) => void;
|
||||
onSshHostKeyFingerprintChange: (value: string) => void;
|
||||
onApplyModeChange: (value: ApplyMode) => void;
|
||||
/** Runs the correct browse endpoint (create vs edit); returns the repo file list or null on failure. */
|
||||
onBrowse: () => Promise<GitBrowseResult | null>;
|
||||
@@ -70,7 +90,11 @@ export function GitSourceFields({
|
||||
syncEnv,
|
||||
authType,
|
||||
token,
|
||||
deployKey,
|
||||
sshHostKeyFingerprint,
|
||||
hasStoredToken,
|
||||
hasStoredDeployKey,
|
||||
storedHostKeyFingerprint,
|
||||
applyMode,
|
||||
disabled = false,
|
||||
variant,
|
||||
@@ -81,12 +105,62 @@ export function GitSourceFields({
|
||||
onSyncEnvChange,
|
||||
onAuthTypeChange,
|
||||
onTokenChange,
|
||||
onDeployKeyChange,
|
||||
onSshKnownHostsEntryChange,
|
||||
onSshHostKeyFingerprintChange,
|
||||
onApplyModeChange,
|
||||
onBrowse,
|
||||
stackName,
|
||||
}: GitSourceFieldsProps) {
|
||||
const copy = APPLY_MODE_COPY[variant];
|
||||
const primaryComposePath = composePaths[0] ?? '';
|
||||
const canBrowse = !!repoUrl?.trim() && !!branch?.trim();
|
||||
const [hostKeyRotation, setHostKeyRotation] = useState<HostKeyRotationWarning | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setHostKeyRotation(null);
|
||||
}, [repoUrl]);
|
||||
|
||||
const probeHostKey = async () => {
|
||||
if (!repoUrl.trim()) {
|
||||
toast.error('Enter a repository URL first.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await apiFetch('/git-sources/ssh-host-key', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
repo_url: repoUrl.trim(),
|
||||
...(stackName ? { stack_name: stackName } : {}),
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
toast.error(err?.error || 'Failed to fetch host key.');
|
||||
return;
|
||||
}
|
||||
const data = await res.json() as { keys?: Array<{ fingerprint: string; line: string }> };
|
||||
const first = data.keys?.[0];
|
||||
if (!first) {
|
||||
toast.error('No host keys returned.');
|
||||
return;
|
||||
}
|
||||
const previousFingerprint = (storedHostKeyFingerprint ?? sshHostKeyFingerprint).trim();
|
||||
if (previousFingerprint && previousFingerprint !== first.fingerprint) {
|
||||
setHostKeyRotation({ previous: previousFingerprint, current: first.fingerprint });
|
||||
toast.warning('Host key fingerprint changed. Review the new fingerprint before saving.');
|
||||
} else {
|
||||
setHostKeyRotation(null);
|
||||
if (!previousFingerprint) {
|
||||
toast.success(`Trusted host key fingerprint: ${first.fingerprint}`);
|
||||
}
|
||||
}
|
||||
onSshHostKeyFingerprintChange(first.fingerprint);
|
||||
onSshKnownHostsEntryChange(first.line);
|
||||
} catch (e) {
|
||||
toast.error((e as Error)?.message || 'Network error.');
|
||||
}
|
||||
};
|
||||
|
||||
const radioOption = (mode: ApplyMode) => (
|
||||
<button
|
||||
@@ -121,7 +195,7 @@ export function GitSourceFields({
|
||||
<Label htmlFor="git-source-repo">Repository URL</Label>
|
||||
<Input
|
||||
id="git-source-repo"
|
||||
placeholder="https://github.com/org/repo.git"
|
||||
placeholder="https://github.com/org/repo.git or user@host:org/repo.git"
|
||||
value={repoUrl}
|
||||
onChange={(e) => onRepoUrlChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
@@ -203,6 +277,19 @@ export function GitSourceFields({
|
||||
>
|
||||
Personal Access Token
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !disabled && onAuthTypeChange('deploy_key')}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'flex-1 rounded-md border px-3 py-1.5 text-xs transition-colors',
|
||||
authType === 'deploy_key'
|
||||
? 'border-brand/60 bg-brand/5'
|
||||
: 'border-glass-border hover:border-card-border-hover',
|
||||
)}
|
||||
>
|
||||
Deploy key (SSH)
|
||||
</button>
|
||||
</div>
|
||||
{authType === 'token' && (
|
||||
<div className="space-y-1.5">
|
||||
@@ -220,6 +307,59 @@ export function GitSourceFields({
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{authType === 'deploy_key' && (
|
||||
<div className="space-y-2">
|
||||
{hostKeyRotation && (
|
||||
<div
|
||||
data-testid="ssh-host-key-rotation-warning"
|
||||
className="rounded-md border border-warning/30 bg-warning/[0.06] px-3 py-2 text-[12px] leading-relaxed text-warning"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" strokeWidth={1.5} aria-hidden />
|
||||
<div>
|
||||
<p className="font-medium">Host key fingerprint changed</p>
|
||||
<p className="mt-1">
|
||||
The server presented a different key than the one you trusted. Confirm this is an expected rotation before saving.
|
||||
</p>
|
||||
<p className="mt-2 font-mono text-[11px]">
|
||||
<span className="text-stat-subtitle">Previously trusted: </span>
|
||||
{hostKeyRotation.previous}
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-[11px]">
|
||||
<span className="text-stat-subtitle">New fingerprint: </span>
|
||||
{hostKeyRotation.current}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button type="button" variant="outline" size="sm" disabled={disabled} onClick={() => void probeHostKey()}>
|
||||
Fetch host key fingerprint
|
||||
</Button>
|
||||
{(sshHostKeyFingerprint || storedHostKeyFingerprint) && (
|
||||
<span
|
||||
className={cn(
|
||||
'text-[11px] font-mono',
|
||||
hostKeyRotation ? 'text-warning' : 'text-stat-subtitle',
|
||||
)}
|
||||
>
|
||||
{sshHostKeyFingerprint || storedHostKeyFingerprint}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<textarea
|
||||
placeholder={hasStoredDeployKey ? 'Private key stored (paste to replace)' : 'Paste PEM private key'}
|
||||
value={deployKey}
|
||||
onChange={(e) => onDeployKeyChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
className="w-full min-h-[88px] rounded-md border border-glass-border bg-transparent px-3 py-2 font-mono text-xs"
|
||||
/>
|
||||
<p className="text-[11px] text-stat-subtitle">
|
||||
Deploy keys are encrypted at rest. Host keys are verified strictly; fetch the fingerprint before saving a new SSH URL.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -169,13 +169,15 @@ describe('GitSourcePanel load', () => {
|
||||
|
||||
render(panel());
|
||||
|
||||
// The repository field replaces the loading skeleton, so waiting on it is
|
||||
// what proves the load settled. The footer buttons render in both states.
|
||||
expect(await screen.findByLabelText(/repository url/i)).toHaveValue('');
|
||||
// Save (not Update) and no Pull now / Remove affordances means the panel
|
||||
// did not mistake the { linked: false } sentinel for a configured source.
|
||||
await screen.findByRole('button', { name: /^save$/i });
|
||||
expect(screen.getByRole('button', { name: /^save$/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /update/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /pull now/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Remove' })).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/repository url/i)).toHaveValue('');
|
||||
});
|
||||
|
||||
it('renders the configured source when one is attached', async () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { isSupportedGitRepoUrl, UNSUPPORTED_GIT_REPO_URL_MESSAGE } from '@/lib/gitRepoUrl';
|
||||
import { useDeployFeedback } from '@/context/DeployFeedbackContext';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
@@ -27,8 +28,10 @@ export interface GitSource {
|
||||
context_dir: string | null;
|
||||
sync_env: boolean;
|
||||
env_path: string | null;
|
||||
auth_type: 'none' | 'token';
|
||||
auth_type: 'none' | 'token' | 'deploy_key';
|
||||
has_token: boolean;
|
||||
has_deploy_key: boolean;
|
||||
ssh_host_key_fingerprint: string | null;
|
||||
auto_apply_on_webhook: boolean;
|
||||
auto_deploy_on_apply: boolean;
|
||||
last_applied_commit_sha: string | null;
|
||||
@@ -118,8 +121,11 @@ export function GitSourcePanel({
|
||||
const [composePaths, setComposePaths] = useState<string[]>(['compose.yaml']);
|
||||
const [contextDir, setContextDir] = useState('');
|
||||
const [syncEnv, setSyncEnv] = useState(false);
|
||||
const [authType, setAuthType] = useState<'none' | 'token'>('none');
|
||||
const [authType, setAuthType] = useState<'none' | 'token' | 'deploy_key'>('none');
|
||||
const [token, setToken] = useState('');
|
||||
const [deployKey, setDeployKey] = useState('');
|
||||
const [sshKnownHostsEntry, setSshKnownHostsEntry] = useState('');
|
||||
const [sshHostKeyFingerprint, setSshHostKeyFingerprint] = useState('');
|
||||
const [applyModeOverride, setApplyModeOverride] = useState<ApplyMode | null>(null);
|
||||
|
||||
const [pull, setPull] = useState<PullResult | null>(null);
|
||||
@@ -143,6 +149,9 @@ export function GitSourcePanel({
|
||||
setSyncEnv(false);
|
||||
setAuthType('none');
|
||||
setToken('');
|
||||
setDeployKey('');
|
||||
setSshKnownHostsEntry('');
|
||||
setSshHostKeyFingerprint('');
|
||||
setApplyModeOverride(null);
|
||||
}, []);
|
||||
|
||||
@@ -165,6 +174,9 @@ export function GitSourcePanel({
|
||||
setSyncEnv(data.sync_env);
|
||||
setAuthType(data.auth_type);
|
||||
setToken('');
|
||||
setDeployKey('');
|
||||
setSshKnownHostsEntry('');
|
||||
setSshHostKeyFingerprint('');
|
||||
setApplyModeOverride(null);
|
||||
}
|
||||
} else if (res.status === 404) {
|
||||
@@ -201,8 +213,9 @@ export function GitSourcePanel({
|
||||
toast.error('Repository URL, ref, and at least one compose file are required.');
|
||||
return;
|
||||
}
|
||||
if (!/^https:\/\//i.test(repoUrl.trim())) {
|
||||
toast.error('Only HTTPS repository URLs are supported.');
|
||||
const trimmedUrl = repoUrl.trim();
|
||||
if (!isSupportedGitRepoUrl(trimmedUrl)) {
|
||||
toast.error(UNSUPPORTED_GIT_REPO_URL_MESSAGE);
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
@@ -223,12 +236,20 @@ export function GitSourcePanel({
|
||||
if (authType === 'token' && token !== '') {
|
||||
body.token = token;
|
||||
}
|
||||
if (authType === 'deploy_key') {
|
||||
if (deployKey !== '') body.deploy_key = deployKey;
|
||||
if (sshKnownHostsEntry !== '') body.ssh_known_hosts_entry = sshKnownHostsEntry;
|
||||
if (sshHostKeyFingerprint !== '') body.ssh_host_key_fingerprint = sshHostKeyFingerprint;
|
||||
}
|
||||
const res = await apiFetch(`/stacks/${encodeURIComponent(stackName)}/git-source`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (res.ok) {
|
||||
setToken('');
|
||||
setDeployKey('');
|
||||
setSshKnownHostsEntry('');
|
||||
setSshHostKeyFingerprint('');
|
||||
setApplyModeOverride(null);
|
||||
toast.success('Git source saved.');
|
||||
onSourceChanged?.();
|
||||
@@ -263,6 +284,10 @@ export function GitSourcePanel({
|
||||
auth_type: authType,
|
||||
};
|
||||
if (authType === 'token' && token !== '') body.token = token;
|
||||
if (authType === 'deploy_key') {
|
||||
if (deployKey !== '') body.deploy_key = deployKey;
|
||||
if (sshKnownHostsEntry !== '') body.ssh_known_hosts_entry = sshKnownHostsEntry;
|
||||
}
|
||||
const res = await apiFetch(`/stacks/${encodeURIComponent(stackName)}/git-source/browse`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
@@ -472,6 +497,7 @@ export function GitSourcePanel({
|
||||
|
||||
<GitSourceFields
|
||||
variant="edit"
|
||||
stackName={stackName}
|
||||
disabled={!canEdit || saving}
|
||||
repoUrl={repoUrl}
|
||||
branch={branch}
|
||||
@@ -480,7 +506,12 @@ export function GitSourcePanel({
|
||||
syncEnv={syncEnv}
|
||||
authType={authType}
|
||||
token={token}
|
||||
deployKey={deployKey}
|
||||
sshKnownHostsEntry={sshKnownHostsEntry}
|
||||
sshHostKeyFingerprint={sshHostKeyFingerprint}
|
||||
hasStoredToken={source?.has_token ?? false}
|
||||
hasStoredDeployKey={source?.has_deploy_key ?? false}
|
||||
storedHostKeyFingerprint={source?.ssh_host_key_fingerprint ?? null}
|
||||
applyMode={applyMode}
|
||||
onRepoUrlChange={setRepoUrl}
|
||||
onBranchChange={setBranch}
|
||||
@@ -489,6 +520,9 @@ export function GitSourcePanel({
|
||||
onSyncEnvChange={setSyncEnv}
|
||||
onAuthTypeChange={setAuthType}
|
||||
onTokenChange={setToken}
|
||||
onDeployKeyChange={setDeployKey}
|
||||
onSshKnownHostsEntryChange={setSshKnownHostsEntry}
|
||||
onSshHostKeyFingerprintChange={setSshHostKeyFingerprint}
|
||||
onApplyModeChange={setApplyModeOverride}
|
||||
onBrowse={browseRepo}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isSupportedGitRepoUrl } from './gitRepoUrl';
|
||||
|
||||
describe('isSupportedGitRepoUrl', () => {
|
||||
it('accepts HTTPS URLs', () => {
|
||||
expect(isSupportedGitRepoUrl('https://github.com/org/repo.git')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts scp-style SSH URLs with any username', () => {
|
||||
expect(isSupportedGitRepoUrl('git@github.com:org/repo.git')).toBe(true);
|
||||
expect(isSupportedGitRepoUrl('gituser@git.example.com:org/repo.git')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts ssh:// URLs with any username', () => {
|
||||
expect(isSupportedGitRepoUrl('ssh://gituser@git.example.com:2222/org/repo.git')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects malformed or unsupported URLs', () => {
|
||||
expect(isSupportedGitRepoUrl('git@host-only')).toBe(false);
|
||||
expect(isSupportedGitRepoUrl('http://github.com/org/repo.git')).toBe(false);
|
||||
expect(isSupportedGitRepoUrl('@host:repo.git')).toBe(false);
|
||||
expect(isSupportedGitRepoUrl('git@host:../escape.git')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
/** scp-style `user@host:org/repo.git` (any SSH username, not only `git`). */
|
||||
const SCP_URL_PATTERN = /^([^@\s/]+)@([^:\s]+):(.+)$/;
|
||||
|
||||
function isValidScpStyleSshUrl(trimmed: string): boolean {
|
||||
const match = SCP_URL_PATTERN.exec(trimmed);
|
||||
if (!match) return false;
|
||||
const user = match[1];
|
||||
const hostPart = match[2];
|
||||
const repoPath = match[3].trim();
|
||||
if (!user || !hostPart || !repoPath || repoPath.includes('..')) return false;
|
||||
const colon = hostPart.lastIndexOf(':');
|
||||
if (colon > 0 && colon < hostPart.length - 1) {
|
||||
const portText = hostPart.slice(colon + 1);
|
||||
const parsedPort = Number.parseInt(portText, 10);
|
||||
if (!Number.isFinite(parsedPort) || parsedPort < 1 || parsedPort > 65535) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isValidSshProtocolUrl(trimmed: string): boolean {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(trimmed);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (url.protocol !== 'ssh:') return false;
|
||||
if (!url.hostname || url.username === '' || url.password !== '') return false;
|
||||
if (url.search !== '' || url.hash !== '') return false;
|
||||
const port = url.port ? Number.parseInt(url.port, 10) : 22;
|
||||
if (!Number.isFinite(port) || port < 1 || port > 65535) return false;
|
||||
const pathname = url.pathname;
|
||||
if (pathname === '/' || pathname.includes('..')) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Matches backend Git transport URL acceptance for HTTPS and SSH. */
|
||||
export function isSupportedGitRepoUrl(raw: string): boolean {
|
||||
const trimmed = raw.trim();
|
||||
if (/^https:\/\//i.test(trimmed)) return true;
|
||||
if (/^ssh:\/\//i.test(trimmed)) return isValidSshProtocolUrl(trimmed);
|
||||
return isValidScpStyleSshUrl(trimmed);
|
||||
}
|
||||
|
||||
export const UNSUPPORTED_GIT_REPO_URL_MESSAGE =
|
||||
'Use an https:// URL or an SSH URL (user@host:org/repo.git or ssh://).';
|
||||
Reference in New Issue
Block a user