mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-01 21:27:58 +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:
@@ -597,10 +597,7 @@ export function createRemoteProxyMiddleware(): RequestHandler {
|
||||
// the stack_name field from the buffered JSON. Non-stack-scoped creates
|
||||
// (no stack_name in body) pass through without evidence.
|
||||
if (isAlertCreateRoute(req) && req.user?.role !== 'admin' && req.user?.role !== 'node-admin') {
|
||||
const globalGrantsEdit =
|
||||
req.user?.role != null
|
||||
&& (ROLE_PERMISSIONS[req.user.role]?.includes('stack:edit') ?? false);
|
||||
if (!globalGrantsEdit) {
|
||||
if (!userHasGlobalStackEdit(req)) {
|
||||
const stackName = req.rawBody ? parseBodyStackName(req.rawBody) : null;
|
||||
if (stackName === undefined) {
|
||||
// Body is non-empty but not valid JSON; client error, not auth.
|
||||
@@ -609,21 +606,12 @@ export function createRemoteProxyMiddleware(): RequestHandler {
|
||||
return;
|
||||
}
|
||||
if (stackName) {
|
||||
const evidenceSupported = await remoteAdvertisesCapability(
|
||||
req.nodeId,
|
||||
SCOPED_STACK_AUTH_EVIDENCE_CAPABILITY,
|
||||
);
|
||||
if (!evidenceSupported) {
|
||||
res.status(403).json({
|
||||
error: `Remote node "${node.name}" does not support scoped stack authorization. Upgrade it before scoped users can act on it.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!await remoteSupportsScopedStackEdit(req, res, node)) return;
|
||||
if (!checkPermission(req, 'stack:edit', 'stack', stackName)) {
|
||||
res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' });
|
||||
return;
|
||||
}
|
||||
req.proxyScopedStackEvidence = { stackName, actions: ['stack:edit'] };
|
||||
attachScopedStackEditEvidence(req, stackName);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -632,61 +620,66 @@ export function createRemoteProxyMiddleware(): RequestHandler {
|
||||
// auto-heal has no pre-existing body buffering, so this gate handles
|
||||
// its own encoding rejection and buffering.
|
||||
if (isAutoHealCreateRoute(req) && req.user?.role !== 'admin' && req.user?.role !== 'node-admin') {
|
||||
const globalGrantsEdit =
|
||||
req.user?.role != null
|
||||
&& (ROLE_PERMISSIONS[req.user.role]?.includes('stack:edit') ?? false);
|
||||
if (!globalGrantsEdit) {
|
||||
if (hasNonIdentityContentEncoding(req)) {
|
||||
await drainRequestBody(req);
|
||||
console.error('[remoteNodeProxy] auto-heal body rejected: compressed encoding');
|
||||
res.status(415).json({
|
||||
error: 'Compressed request bodies are not supported for remote auto-heal creates',
|
||||
code: 'encoding_unsupported',
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
req.rawBody = await bufferRequestBody(req, AUTO_HEAL_PROXY_BODY_LIMIT);
|
||||
} catch (err) {
|
||||
const status = Number((err as { status?: number }).status);
|
||||
if (status === 413) {
|
||||
console.error('[remoteNodeProxy] auto-heal body rejected as too large:', err);
|
||||
res.status(413).json({ error: 'Auto-heal payload too large', code: 'entity_too_large' });
|
||||
return;
|
||||
}
|
||||
if (status === 400) {
|
||||
console.error('[remoteNodeProxy] auto-heal body incomplete:', err);
|
||||
res.status(400).json({ error: 'Incomplete request body' });
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const stackName = parseBodyStackName(req.rawBody);
|
||||
if (!userHasGlobalStackEdit(req)) {
|
||||
const rawBody = await bufferProxyJsonBody(req, res, PROXY_JSON_BODY_LIMIT, {
|
||||
logPrefix: '[remoteNodeProxy] auto-heal',
|
||||
encodingError: 'Compressed request bodies are not supported for remote auto-heal creates',
|
||||
tooLargeError: 'Auto-heal payload too large',
|
||||
});
|
||||
if (!rawBody) return;
|
||||
req.rawBody = rawBody;
|
||||
const stackName = parseBodyStackName(rawBody);
|
||||
if (stackName === undefined) {
|
||||
console.error('[remoteNodeProxy] auto-heal body is not valid JSON');
|
||||
res.status(400).json({ error: 'Request body is not valid JSON' });
|
||||
return;
|
||||
}
|
||||
if (stackName) {
|
||||
const evidenceSupported = await remoteAdvertisesCapability(
|
||||
req.nodeId,
|
||||
SCOPED_STACK_AUTH_EVIDENCE_CAPABILITY,
|
||||
);
|
||||
if (!evidenceSupported) {
|
||||
res.status(403).json({
|
||||
error: `Remote node "${node.name}" does not support scoped stack authorization. Upgrade it before scoped users can act on it.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!await remoteSupportsScopedStackEdit(req, res, node)) return;
|
||||
if (!checkPermission(req, 'stack:edit', 'stack', stackName)) {
|
||||
res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' });
|
||||
return;
|
||||
}
|
||||
req.proxyScopedStackEvidence = { stackName, actions: ['stack:edit'] };
|
||||
attachScopedStackEditEvidence(req, stackName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// POST /git-sources/ssh-host-key scoped-evidence gate: stack_name lives in
|
||||
// the JSON body, so classifyStackApiPath cannot authorize the edit flow.
|
||||
if (isSshHostKeyProbeRoute(req)) {
|
||||
const rawBody = await bufferProxyJsonBody(req, res, PROXY_JSON_BODY_LIMIT, {
|
||||
logPrefix: '[remoteNodeProxy] ssh-host-key',
|
||||
encodingError: 'Compressed request bodies are not supported for remote SSH host key probes',
|
||||
tooLargeError: 'SSH host key probe payload too large',
|
||||
});
|
||||
if (!rawBody) return;
|
||||
req.rawBody = rawBody;
|
||||
const stackName = parseBodyStackName(rawBody);
|
||||
if (stackName === undefined) {
|
||||
console.error('[remoteNodeProxy] ssh-host-key body is not valid JSON');
|
||||
res.status(400).json({ error: 'Request body is not valid JSON' });
|
||||
return;
|
||||
}
|
||||
if (stackName) {
|
||||
if (!checkPermission(req, 'stack:edit', 'stack', stackName)) {
|
||||
res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' });
|
||||
return;
|
||||
}
|
||||
if (
|
||||
req.user?.role !== 'admin'
|
||||
&& req.user?.role !== 'node-admin'
|
||||
&& !userHasGlobalStackEdit(req)
|
||||
) {
|
||||
if (!await remoteSupportsScopedStackEdit(req, res, node)) return;
|
||||
attachScopedStackEditEvidence(req, stackName);
|
||||
}
|
||||
} else if (!checkPermission(req, 'stack:create')) {
|
||||
res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Node-wide image refresh elevation gate: when a non-admin, non-node-admin
|
||||
// user triggers a manual refresh on a remote node, check the hub-side
|
||||
// scoped node:manage grant and elevate PROXY_ROLE_HEADER so the remote
|
||||
@@ -812,17 +805,22 @@ function isAlertCreateRoute(req: Request): boolean {
|
||||
return req.method === 'POST' && /^\/alerts\/?$/.test(req.path);
|
||||
}
|
||||
|
||||
/** Same default as express.json(); remote alert creates must not exceed it. */
|
||||
const ALERT_PROXY_BODY_LIMIT = 100 * 1024;
|
||||
/** Max request body size for buffered JSON proxy gates (same as express.json()). */
|
||||
const PROXY_JSON_BODY_LIMIT = 100 * 1024;
|
||||
|
||||
/** Same limit for auto-heal policy creates. */
|
||||
const AUTO_HEAL_PROXY_BODY_LIMIT = 100 * 1024;
|
||||
/** Same default as express.json(); remote alert creates must not exceed it. */
|
||||
const ALERT_PROXY_BODY_LIMIT = PROXY_JSON_BODY_LIMIT;
|
||||
|
||||
/** POST /auto-heal/policies (path is post-/api strip). */
|
||||
function isAutoHealCreateRoute(req: Request): boolean {
|
||||
return req.method === 'POST' && /^\/auto-heal\/policies\/?$/.test(req.path);
|
||||
}
|
||||
|
||||
/** POST /git-sources/ssh-host-key (path is post-/api strip). */
|
||||
function isSshHostKeyProbeRoute(req: Request): boolean {
|
||||
return req.method === 'POST' && /^\/git-sources\/ssh-host-key\/?$/.test(req.path);
|
||||
}
|
||||
|
||||
/** POST /image-updates/refresh with no stack-name segment (node-wide, not per-stack). */
|
||||
function isImageRefreshNodeWide(req: Request): boolean {
|
||||
return req.method === 'POST' && /^\/image-updates\/refresh\/?$/.test(req.path);
|
||||
@@ -849,6 +847,62 @@ function parseBodyStackName(rawBody: Buffer): string | null | undefined {
|
||||
/** Max time to wait for leftover body bytes after a size/encoding reject. */
|
||||
const DRAIN_TIMEOUT_MS = 5_000;
|
||||
|
||||
function userHasGlobalStackEdit(req: Request): boolean {
|
||||
return req.user?.role != null
|
||||
&& (ROLE_PERMISSIONS[req.user.role]?.includes('stack:edit') ?? false);
|
||||
}
|
||||
|
||||
async function remoteSupportsScopedStackEdit(
|
||||
req: Request,
|
||||
res: Response,
|
||||
node: { name: string },
|
||||
): Promise<boolean> {
|
||||
const evidenceSupported = await remoteAdvertisesCapability(
|
||||
req.nodeId,
|
||||
SCOPED_STACK_AUTH_EVIDENCE_CAPABILITY,
|
||||
);
|
||||
if (!evidenceSupported) {
|
||||
res.status(403).json({
|
||||
error: `Remote node "${node.name}" does not support scoped stack authorization. Upgrade it before scoped users can act on it.`,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function attachScopedStackEditEvidence(req: Request, stackName: string): void {
|
||||
req.proxyScopedStackEvidence = { stackName, actions: ['stack:edit'] };
|
||||
}
|
||||
|
||||
async function bufferProxyJsonBody(
|
||||
req: Request,
|
||||
res: Response,
|
||||
limit: number,
|
||||
labels: { logPrefix: string; encodingError: string; tooLargeError: string },
|
||||
): Promise<Buffer | null> {
|
||||
if (hasNonIdentityContentEncoding(req)) {
|
||||
await drainRequestBody(req);
|
||||
res.status(415).json({ error: labels.encodingError, code: 'encoding_unsupported' });
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return await bufferRequestBody(req, limit);
|
||||
} catch (err) {
|
||||
const status = Number((err as { status?: number }).status);
|
||||
if (status === 413) {
|
||||
console.error(`${labels.logPrefix} body rejected as too large:`, err);
|
||||
res.status(413).json({ error: labels.tooLargeError, code: 'entity_too_large' });
|
||||
return null;
|
||||
}
|
||||
if (status === 400) {
|
||||
console.error(`${labels.logPrefix} body incomplete:`, err);
|
||||
res.status(400).json({ error: 'Incomplete request body' });
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Error with HTTP status for the alert-body gate catch mapper. */
|
||||
function alertBodyError(message: string, status: number): Error {
|
||||
return Object.assign(new Error(message), { status, expose: true });
|
||||
|
||||
Reference in New Issue
Block a user