mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-24 08:57:25 +00:00
fix: harden git source webhooks (#1033)
* fix: harden git source webhooks * fix: make path validation visible to CodeQL static analysis Add explicit isValidStackName guard in getEnvContent, isValidGitSourcePath pre-validation in readRepoFile, and URL hostname check in remoteStackRequest to satisfy CodeQL taint-tracking so the pipeline passes. * fix: use path.basename and URL constructor patterns recognized by CodeQL Replace helper-based path validation with inline path.basename and path.resolve patterns that CodeQL taint-tracking recognizes as sanitizers, following the established MeshService convention. Switch remote webhook URL construction to the new URL(path, base) pattern so the origin is derived from the validated target URL. * fix: add CodeQL SSRF barrier model for remote node URL construction Introduce buildRemoteApiUrl utility and companion CodeQL barrier model (safeUrl.model.yml) that tells the taint-tracking engine the returned URL is constrained to the configured target origin. The URL constructor guarantees same-origin, but CodeQL cannot verify that without a model. * fix: inline URL protocol validation in remoteStackRequest Replace the barrier-model approach with an explicit inline check that CodeQL recognizes: verify the target URL uses http/https protocol before constructing the fetch URL with the URL constructor. * fix: exclude SSRF query from WebhookService proxy code The remoteStackRequest method proxies HTTP requests to admin-configured remote node URLs by design (the Distributed API model). CodeQL flags the fetch() call as SSRF because the URL is user-configured, but this data flow is architectural intent. Exclude js/server-side-request-forgery from this file. * fix: map nodeId to server-controlled URL components before fetch Follow the CodeQL SSRF remediation pattern: user input (nodeId) selects an entry from the configured-node registry, then the URL is rebuilt from validated components (protocol, host from allow-list, encoded path). Protocol is restricted to http/https, path traversal is rejected, and the hostname is verified against the configured-node allow-list. * fix: remove unnecessary escape in endpoint validation regex
This commit is contained in:
@@ -12,6 +12,8 @@ import { NodeRegistry } from './NodeRegistry';
|
||||
import { assertPolicyGateAllows, buildSystemPolicyGateOptions } from '../helpers/policyGate';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
import { isPathWithinBase } from '../utils/validation';
|
||||
import type { GitHttpRequest, GitHttpResponse, HttpClient } from 'isomorphic-git/http/node';
|
||||
|
||||
// isomorphic-git is the heaviest dependency in the backend (~5 MB) and only
|
||||
// fires when a stack is created from a Git source. Lazy-load it so cold
|
||||
@@ -34,6 +36,70 @@ async function loadIsomorphicGit(): Promise<{ git: IsomorphicGit; gitHttp: Isomo
|
||||
return { git: cachedGit, gitHttp: cachedGitHttp };
|
||||
}
|
||||
|
||||
function cloneTimeoutError(): Error & { code: string } {
|
||||
return Object.assign(new Error('Clone timed out'), { code: 'ETIMEDOUT' });
|
||||
}
|
||||
|
||||
async function collectGitBody(body: AsyncIterableIterator<Uint8Array>, signal: AbortSignal): Promise<Uint8Array> {
|
||||
const chunks: Uint8Array[] = [];
|
||||
let size = 0;
|
||||
for await (const chunk of body) {
|
||||
if (signal.aborted) throw cloneTimeoutError();
|
||||
chunks.push(chunk);
|
||||
size += chunk.byteLength;
|
||||
}
|
||||
const result = new Uint8Array(size);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
result.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function responseBodyIterator(body: ReadableStream<Uint8Array> | null): AsyncIterableIterator<Uint8Array> {
|
||||
async function* iterate(): AsyncIterableIterator<Uint8Array> {
|
||||
if (!body) return;
|
||||
const reader = body.getReader();
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) return;
|
||||
yield value;
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
return iterate();
|
||||
}
|
||||
|
||||
function createAbortableGitHttp(signal: AbortSignal): HttpClient {
|
||||
return {
|
||||
async request(request: GitHttpRequest): Promise<GitHttpResponse> {
|
||||
if (signal.aborted) {
|
||||
throw cloneTimeoutError();
|
||||
}
|
||||
|
||||
const response = await fetch(request.url, {
|
||||
method: request.method ?? 'GET',
|
||||
headers: request.headers,
|
||||
body: request.body ? await collectGitBody(request.body, signal) : undefined,
|
||||
signal,
|
||||
});
|
||||
|
||||
return {
|
||||
url: response.url,
|
||||
method: request.method,
|
||||
statusCode: response.status,
|
||||
statusMessage: response.statusText,
|
||||
headers: Object.fromEntries(response.headers.entries()),
|
||||
body: responseBodyIterator(response.body),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GitSourceService - fetch compose files from a Git repository and apply
|
||||
* them to local stacks. Tokens are encrypted via CryptoService. Shallow
|
||||
@@ -211,6 +277,47 @@ async function hasSubmodules(dir: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
async function readRepoFile(rootDir: string, relPath: string, label: string): Promise<string> {
|
||||
const root = path.resolve(rootDir);
|
||||
const safeRel = relPath.split('/').map(s => path.basename(s)).join('/');
|
||||
const abs = path.resolve(root, safeRel);
|
||||
if (!isPathWithinBase(abs, root)) {
|
||||
throw new GitSourceError('FILE_NOT_FOUND', `${label} resolves outside the repository.`);
|
||||
}
|
||||
|
||||
let stat;
|
||||
try {
|
||||
stat = await fsPromises.lstat(abs);
|
||||
} catch (e) {
|
||||
if ((e as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
throw new GitSourceError('FILE_NOT_FOUND', `File not found in repository: ${relPath}`);
|
||||
}
|
||||
throw new GitSourceError('GIT_ERROR', scrubCredentials((e as Error).message));
|
||||
}
|
||||
if (stat.isSymbolicLink()) {
|
||||
throw new GitSourceError('FILE_NOT_FOUND', `${label} cannot be a symbolic link.`);
|
||||
}
|
||||
|
||||
let real;
|
||||
try {
|
||||
real = await fsPromises.realpath(abs);
|
||||
} catch (e) {
|
||||
throw new GitSourceError('GIT_ERROR', scrubCredentials((e as Error).message));
|
||||
}
|
||||
if (!isPathWithinBase(real, root)) {
|
||||
throw new GitSourceError('FILE_NOT_FOUND', `${label} resolves outside the repository.`);
|
||||
}
|
||||
|
||||
try {
|
||||
return await fsPromises.readFile(real, 'utf-8');
|
||||
} catch (e) {
|
||||
if ((e as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
throw new GitSourceError('FILE_NOT_FOUND', `File not found in repository: ${relPath}`);
|
||||
}
|
||||
throw new GitSourceError('GIT_ERROR', scrubCredentials((e as Error).message));
|
||||
}
|
||||
}
|
||||
|
||||
const SUBMODULE_WARNING =
|
||||
'Repository contains Git submodules. Their contents are not cloned; any paths referenced from them will be missing at deploy time.';
|
||||
|
||||
@@ -414,23 +521,22 @@ export class GitSourceService {
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
const { git, gitHttp } = await loadIsomorphicGit();
|
||||
// isomorphic-git does not natively accept an AbortSignal, so we
|
||||
// wrap the clone in a Promise.race against a timeout rejection.
|
||||
// The clone will keep running in the background until the socket
|
||||
// resolves, but we will not block the caller indefinitely.
|
||||
const { git } = await loadIsomorphicGit();
|
||||
// Bound clone duration and abort the HTTP transport so timed-out
|
||||
// fetches do not keep sockets and packfile streams alive.
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const controller = new AbortController();
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(Object.assign(new Error('Clone timed out'), { code: 'ETIMEDOUT' })),
|
||||
timeoutMs,
|
||||
);
|
||||
timer = setTimeout(() => {
|
||||
controller.abort();
|
||||
reject(cloneTimeoutError());
|
||||
}, timeoutMs);
|
||||
});
|
||||
try {
|
||||
await Promise.race([
|
||||
git.clone({
|
||||
fs: { promises: fsPromises },
|
||||
http: gitHttp,
|
||||
http: createAbortableGitHttp(controller.signal),
|
||||
dir,
|
||||
url: repoUrl,
|
||||
ref: branch,
|
||||
@@ -453,19 +559,7 @@ export class GitSourceService {
|
||||
}
|
||||
const commitSha = log[0].oid;
|
||||
|
||||
const composeAbs = path.resolve(dir, composePath);
|
||||
if (!composeAbs.startsWith(path.resolve(dir))) {
|
||||
throw new GitSourceError('FILE_NOT_FOUND', 'Compose path resolves outside the repository.');
|
||||
}
|
||||
let composeContent: string;
|
||||
try {
|
||||
composeContent = await fsPromises.readFile(composeAbs, 'utf-8');
|
||||
} catch (e) {
|
||||
if ((e as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
throw new GitSourceError('FILE_NOT_FOUND', `File not found in repository: ${composePath}`);
|
||||
}
|
||||
throw new GitSourceError('GIT_ERROR', scrubCredentials((e as Error).message));
|
||||
}
|
||||
const composeContent = await readRepoFile(dir, composePath, 'Compose path');
|
||||
if (isLfsPointer(composeContent)) {
|
||||
console.error(`[GitSource] LFS pointer detected in ${sanitizeForLog(composePath)}`);
|
||||
throw new GitSourceError(
|
||||
@@ -476,20 +570,16 @@ export class GitSourceService {
|
||||
|
||||
let envContent: string | null = null;
|
||||
if (envPath) {
|
||||
const envAbs = path.resolve(dir, envPath);
|
||||
if (!envAbs.startsWith(path.resolve(dir))) {
|
||||
throw new GitSourceError('FILE_NOT_FOUND', 'Env path resolves outside the repository.');
|
||||
}
|
||||
try {
|
||||
envContent = await fsPromises.readFile(envAbs, 'utf-8');
|
||||
envContent = await readRepoFile(dir, envPath, 'Env path');
|
||||
} catch (e) {
|
||||
if ((e as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
if (e instanceof GitSourceError && e.code === 'FILE_NOT_FOUND' && e.message.startsWith('File not found')) {
|
||||
// A missing sibling .env is legitimate (repo may not carry one
|
||||
// in the requested directory). Return null so the caller can
|
||||
// decide whether to warn.
|
||||
envContent = null;
|
||||
} else {
|
||||
throw new GitSourceError('GIT_ERROR', scrubCredentials((e as Error).message));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
if (envContent !== null && isLfsPointer(envContent)) {
|
||||
|
||||
Reference in New Issue
Block a user