mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 17:08:10 +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:
@@ -155,6 +155,70 @@ describe('PUT /api/stacks/:stackName/git-source — stack existence guard', () =
|
||||
});
|
||||
});
|
||||
|
||||
describe('git-source routes — repository path validation', () => {
|
||||
it('rejects compose_path traversal before service execution', async () => {
|
||||
const res = await request(app)
|
||||
.put('/api/stacks/existing-stack/git-source')
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({
|
||||
repo_url: 'https://github.com/example/repo.git',
|
||||
branch: 'main',
|
||||
compose_path: '../compose.yaml',
|
||||
auth_type: 'none',
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/compose_path/i);
|
||||
});
|
||||
|
||||
it('rejects absolute env_path on create-from-git', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/from-git')
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({
|
||||
stack_name: 'route-from-git-env-abs',
|
||||
repo_url: 'https://github.com/example/repo.git',
|
||||
branch: 'main',
|
||||
compose_path: 'compose.yaml',
|
||||
sync_env: true,
|
||||
env_path: '/etc/passwd',
|
||||
auth_type: 'none',
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/env_path/i);
|
||||
});
|
||||
|
||||
it('rejects string auto_deploy_on_apply on update', async () => {
|
||||
const res = await request(app)
|
||||
.put('/api/stacks/existing-stack/git-source')
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({
|
||||
repo_url: 'https://github.com/example/repo.git',
|
||||
branch: 'main',
|
||||
compose_path: 'compose.yaml',
|
||||
auth_type: 'none',
|
||||
auto_deploy_on_apply: 'true',
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/auto_deploy_on_apply/i);
|
||||
});
|
||||
|
||||
it('rejects string auto_deploy_on_apply on create-from-git', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/from-git')
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({
|
||||
stack_name: 'route-from-git-auto-deploy-string',
|
||||
repo_url: 'https://github.com/example/repo.git',
|
||||
branch: 'main',
|
||||
compose_path: 'compose.yaml',
|
||||
auth_type: 'none',
|
||||
auto_deploy_on_apply: 'true',
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/auto_deploy_on_apply/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('git-source routes — invalid stack names', () => {
|
||||
it('returns 400 for traversal attempts on GET per-stack', async () => {
|
||||
const res = await request(app)
|
||||
|
||||
@@ -530,6 +530,25 @@ describe('GitSourceService.fetchFromGit (.git metadata guard)', () => {
|
||||
composePath: 'gitops.yaml',
|
||||
})).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('rejects compose paths that are symbolic links', async () => {
|
||||
mockSuccessfulClone();
|
||||
const { promises: fsp } = await import('fs');
|
||||
const lstatSpy = vi.spyOn(fsp, 'lstat').mockResolvedValue({
|
||||
isSymbolicLink: () => true,
|
||||
} as Awaited<ReturnType<typeof fsp.lstat>>);
|
||||
|
||||
await expect(svc().fetchFromGit({
|
||||
repoUrl: 'https://github.com/example/repo.git',
|
||||
branch: 'main',
|
||||
composePath: 'compose.yaml',
|
||||
})).rejects.toMatchObject({
|
||||
code: 'FILE_NOT_FOUND',
|
||||
message: expect.stringMatching(/symbolic link/i),
|
||||
});
|
||||
|
||||
lstatSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GitSourceService.fetchFromGit (LFS + submodule detection)', () => {
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
let LicenseService: typeof import('../services/LicenseService').LicenseService;
|
||||
let WebhookService: typeof import('../services/WebhookService').WebhookService;
|
||||
|
||||
function adminToken(): string {
|
||||
return jwt.sign({ username: TEST_USERNAME, role: 'admin' }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ app } = await import('../index'));
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
({ LicenseService } = await import('../services/LicenseService'));
|
||||
({ WebhookService } = await import('../services/WebhookService'));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('skipper');
|
||||
});
|
||||
|
||||
describe('node-aware Git source webhooks', () => {
|
||||
it('persists node_id when creating a webhook', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodeId = db.getDefaultNode()!.id;
|
||||
db.upsertGitSource({
|
||||
stack_name: 'webhook-local-git',
|
||||
repo_url: 'https://github.com/example/repo.git',
|
||||
branch: 'main',
|
||||
compose_path: 'compose.yaml',
|
||||
sync_env: false,
|
||||
env_path: null,
|
||||
auth_type: 'none',
|
||||
encrypted_token: null,
|
||||
auto_apply_on_webhook: false,
|
||||
auto_deploy_on_apply: false,
|
||||
last_applied_commit_sha: null,
|
||||
last_applied_content_hash: null,
|
||||
pending_commit_sha: null,
|
||||
pending_compose_content: null,
|
||||
pending_env_content: null,
|
||||
pending_fetched_at: null,
|
||||
last_debounce_at: null,
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/webhooks')
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({
|
||||
node_id: nodeId,
|
||||
name: 'local git webhook',
|
||||
stack_name: 'webhook-local-git',
|
||||
action: 'git-pull',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
const row = db.getWebhook(res.body.id);
|
||||
expect(row?.node_id).toBe(nodeId);
|
||||
});
|
||||
|
||||
it('checks remote git-source existence through the target node', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const remoteNodeId = db.addNode({
|
||||
name: 'remote-git-webhook',
|
||||
type: 'remote',
|
||||
compose_dir: '/tmp',
|
||||
is_default: false,
|
||||
api_url: 'http://remote.example',
|
||||
api_token: 'remote-token',
|
||||
});
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{}', { status: 200 }));
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/webhooks')
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({
|
||||
node_id: remoteNodeId,
|
||||
name: 'remote git webhook',
|
||||
stack_name: 'remote-stack',
|
||||
action: 'git-pull',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'http://remote.example/api/stacks/remote-stack/git-source',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects malformed webhook node_id values', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/webhooks')
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({
|
||||
node_id: 'remote',
|
||||
name: 'bad node id webhook',
|
||||
stack_name: 'remote-stack',
|
||||
action: 'deploy',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/node_id/i);
|
||||
});
|
||||
|
||||
it('rejects retargeting an existing git-pull webhook without a Git source', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodeId = db.getDefaultNode()!.id;
|
||||
db.upsertGitSource({
|
||||
stack_name: 'retarget-source-stack',
|
||||
repo_url: 'https://github.com/example/repo.git',
|
||||
branch: 'main',
|
||||
compose_path: 'compose.yaml',
|
||||
sync_env: false,
|
||||
env_path: null,
|
||||
auth_type: 'none',
|
||||
encrypted_token: null,
|
||||
auto_apply_on_webhook: false,
|
||||
auto_deploy_on_apply: false,
|
||||
last_applied_commit_sha: null,
|
||||
last_applied_content_hash: null,
|
||||
pending_commit_sha: null,
|
||||
pending_compose_content: null,
|
||||
pending_env_content: null,
|
||||
pending_fetched_at: null,
|
||||
last_debounce_at: null,
|
||||
});
|
||||
const webhookId = db.addWebhook({
|
||||
node_id: nodeId,
|
||||
name: 'retarget git webhook',
|
||||
stack_name: 'retarget-source-stack',
|
||||
action: 'git-pull',
|
||||
secret: WebhookService.getInstance().generateSecret(),
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/webhooks/${webhookId}`)
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({ stack_name: 'retarget-no-source-stack' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/Git source/i);
|
||||
});
|
||||
|
||||
it('records failure when remote node disconnects before execution', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const remoteNodeId = db.addNode({
|
||||
name: 'remote-disconnected-webhook',
|
||||
type: 'remote',
|
||||
compose_dir: '/tmp',
|
||||
is_default: false,
|
||||
api_url: '',
|
||||
api_token: '',
|
||||
});
|
||||
const webhookId = db.addWebhook({
|
||||
node_id: remoteNodeId,
|
||||
name: 'disconnected remote git',
|
||||
stack_name: 'remote-stack',
|
||||
action: 'git-pull',
|
||||
secret: WebhookService.getInstance().generateSecret(),
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const result = await WebhookService.getInstance().execute(webhookId, 'git-pull', 'test');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/unreachable|configured/i);
|
||||
const history = db.getWebhookExecutions(webhookId);
|
||||
expect(history[0].status).toBe('failure');
|
||||
expect(history[0].error).toMatch(/unreachable|configured/i);
|
||||
});
|
||||
|
||||
it('records failure when remote node request times out', async () => {
|
||||
vi.useFakeTimers();
|
||||
const db = DatabaseService.getInstance();
|
||||
const remoteNodeId = db.addNode({
|
||||
name: 'remote-timeout-webhook',
|
||||
type: 'remote',
|
||||
compose_dir: '/tmp',
|
||||
is_default: false,
|
||||
api_url: 'http://remote-timeout.example',
|
||||
api_token: 'remote-token',
|
||||
});
|
||||
const webhookId = db.addWebhook({
|
||||
node_id: remoteNodeId,
|
||||
name: 'timeout remote git',
|
||||
stack_name: 'remote-stack',
|
||||
action: 'git-pull',
|
||||
secret: WebhookService.getInstance().generateSecret(),
|
||||
enabled: true,
|
||||
});
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation((_url, init) => new Promise<Response>((_resolve, reject) => {
|
||||
const signal = (init as RequestInit | undefined)?.signal;
|
||||
signal?.addEventListener('abort', () => reject(new Error('aborted')));
|
||||
}));
|
||||
|
||||
const pending = WebhookService.getInstance().execute(webhookId, 'git-pull', 'test');
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
const result = await pending;
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/timed out/i);
|
||||
const history = db.getWebhookExecutions(webhookId);
|
||||
expect(history[0].status).toBe('failure');
|
||||
expect(history[0].error).toMatch(/timed out/i);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import { DatabaseService } from '../services/DatabaseService';
|
||||
import { checkPermission, requirePermission } from '../middleware/permissions';
|
||||
import { invalidateNodeCaches } from '../helpers/cacheInvalidation';
|
||||
import { triggerPostDeployScan } from '../helpers/policyGate';
|
||||
import { isValidStackName } from '../utils/validation';
|
||||
import { isValidGitSourcePath, isValidStackName } from '../utils/validation';
|
||||
import { sendGitSourceError } from '../utils/gitSourceHttp';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
|
||||
@@ -95,6 +95,14 @@ stackGitSourceRouter.put('/:stackName/git-source', async (req: Request, res: Res
|
||||
res.status(400).json({ error: 'auth_type must be "none" or "token"' });
|
||||
return;
|
||||
}
|
||||
if (auto_apply_on_webhook !== undefined && typeof auto_apply_on_webhook !== 'boolean') {
|
||||
res.status(400).json({ error: 'auto_apply_on_webhook must be a boolean' });
|
||||
return;
|
||||
}
|
||||
if (auto_deploy_on_apply !== undefined && typeof auto_deploy_on_apply !== 'boolean') {
|
||||
res.status(400).json({ error: 'auto_deploy_on_apply must be a boolean' });
|
||||
return;
|
||||
}
|
||||
if (!/^https:\/\//i.test(repo_url)) {
|
||||
res.status(400).json({ error: 'Only HTTPS repository URLs are supported' });
|
||||
return;
|
||||
@@ -115,10 +123,21 @@ stackGitSourceRouter.put('/:stackName/git-source', async (req: Request, res: Res
|
||||
res.status(400).json({ error: 'env_path is too long' });
|
||||
return;
|
||||
}
|
||||
if (!isValidGitSourcePath(compose_path.trim())) {
|
||||
res.status(400).json({ error: 'compose_path must be a relative repository file path' });
|
||||
return;
|
||||
}
|
||||
if (typeof env_path === 'string' && env_path.trim() && !isValidGitSourcePath(env_path.trim())) {
|
||||
res.status(400).json({ error: 'env_path must be a relative repository file path' });
|
||||
return;
|
||||
}
|
||||
if (typeof token === 'string' && token.length > MAX_TOKEN_LENGTH) {
|
||||
res.status(400).json({ error: 'token is too long' });
|
||||
return;
|
||||
}
|
||||
const autoApplyOnWebhook = auto_apply_on_webhook === true;
|
||||
const autoDeployOnApply = auto_deploy_on_apply === true;
|
||||
if (autoDeployOnApply && !requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
|
||||
|
||||
// Confirm the stack actually exists on the active node. Without this guard
|
||||
// a caller could stash a git-source row for a name that does not exist
|
||||
@@ -145,8 +164,8 @@ stackGitSourceRouter.put('/:stackName/git-source', async (req: Request, res: Res
|
||||
envPath: resolvedEnvPath,
|
||||
authType: auth_type,
|
||||
token: typeof token === 'string' ? token : undefined,
|
||||
autoApplyOnWebhook: Boolean(auto_apply_on_webhook),
|
||||
autoDeployOnApply: Boolean(auto_deploy_on_apply),
|
||||
autoApplyOnWebhook,
|
||||
autoDeployOnApply,
|
||||
});
|
||||
|
||||
console.log(`[GitSource] Configured git source for ${stackName}`);
|
||||
@@ -232,6 +251,23 @@ stackGitSourceRouter.post('/:stackName/git-source/apply', async (req: Request, r
|
||||
}
|
||||
});
|
||||
|
||||
stackGitSourceRouter.post('/:stackName/git-source/webhook-pull', async (req: Request, res: Response): Promise<void> => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!isValidStackName(stackName)) {
|
||||
res.status(400).json({ error: 'Invalid stack name' });
|
||||
return;
|
||||
}
|
||||
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
|
||||
try {
|
||||
const source = GitSourceService.getInstance().get(stackName);
|
||||
if (source?.auto_apply_on_webhook && source.auto_deploy_on_apply && !requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
|
||||
const result = await GitSourceService.getInstance().handleWebhookPull(stackName);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
sendGitSourceError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
stackGitSourceRouter.post('/:stackName/git-source/dismiss-pending', async (req: Request, res: Response): Promise<void> => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!isValidStackName(stackName)) {
|
||||
|
||||
@@ -13,7 +13,7 @@ import { enforcePolicyPreDeploy } from '../services/PolicyEnforcement';
|
||||
import { requirePermission } from '../middleware/permissions';
|
||||
import { requirePaid, requireAdmin, effectiveTier } from '../middleware/tierGates';
|
||||
import { NotificationService, type NotificationCategory } from '../services/NotificationService';
|
||||
import { isValidStackName, isValidServiceName, isPathWithinBase, isValidRelativeStackPath } from '../utils/validation';
|
||||
import { isValidGitSourcePath, isValidStackName, isValidServiceName, isPathWithinBase, isValidRelativeStackPath } from '../utils/validation';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
@@ -406,6 +406,12 @@ stacksRouter.post('/from-git', async (req: Request, res: Response) => {
|
||||
if (typeof compose_path !== 'string' || !compose_path.trim()) {
|
||||
return res.status(400).json({ error: 'compose_path is required' });
|
||||
}
|
||||
if (auto_apply_on_webhook !== undefined && typeof auto_apply_on_webhook !== 'boolean') {
|
||||
return res.status(400).json({ error: 'auto_apply_on_webhook must be a boolean' });
|
||||
}
|
||||
if (auto_deploy_on_apply !== undefined && typeof auto_deploy_on_apply !== 'boolean') {
|
||||
return res.status(400).json({ error: 'auto_deploy_on_apply must be a boolean' });
|
||||
}
|
||||
const resolvedAuthType = auth_type === 'token' ? 'token' : 'none';
|
||||
if (!/^https:\/\//i.test(repo_url)) {
|
||||
return res.status(400).json({ error: 'Only HTTPS repository URLs are supported' });
|
||||
@@ -425,6 +431,16 @@ stacksRouter.post('/from-git', async (req: Request, res: Response) => {
|
||||
if (typeof token === 'string' && token.length > 8192) {
|
||||
return res.status(400).json({ error: 'token is too long' });
|
||||
}
|
||||
if (!isValidGitSourcePath(compose_path.trim())) {
|
||||
return res.status(400).json({ error: 'compose_path must be a relative repository file path' });
|
||||
}
|
||||
if (typeof env_path === 'string' && env_path.trim() && !isValidGitSourcePath(env_path.trim())) {
|
||||
return res.status(400).json({ error: 'env_path must be a relative repository file path' });
|
||||
}
|
||||
const autoApplyOnWebhook = auto_apply_on_webhook === true;
|
||||
const autoDeployOnApply = auto_deploy_on_apply === true;
|
||||
if (autoDeployOnApply && !requirePermission(req, res, 'stack:deploy', 'stack', stack_name)) return;
|
||||
if (deploy_now === true && !requirePermission(req, res, 'stack:deploy', 'stack', stack_name)) return;
|
||||
|
||||
const stacks = await FileSystemService.getInstance(req.nodeId).getStacks();
|
||||
if (stacks.includes(stack_name)) {
|
||||
@@ -440,7 +456,7 @@ stacksRouter.post('/from-git', async (req: Request, res: Response) => {
|
||||
|
||||
if (fromGitDiag) {
|
||||
console.log(
|
||||
`[Stacks:diag] from-git start stack=${sanitizeForLog(stack_name)} nodeId=${req.nodeId ?? 'local'} host=${sanitizeForLog(gitRepoHost(repo_url))} branch=${sanitizeForLog(branch)} composePath=${sanitizeForLog(compose_path)} envPath=${sanitizeForLog(resolvedEnvPath ?? 'none')} authType=${sanitizeForLog(resolvedAuthType)} autoApplyOnWebhook=${Boolean(auto_apply_on_webhook)} autoDeployOnApply=${Boolean(auto_deploy_on_apply)} deployNow=${deploy_now === true}`
|
||||
`[Stacks:diag] from-git start stack=${sanitizeForLog(stack_name)} nodeId=${req.nodeId ?? 'local'} host=${sanitizeForLog(gitRepoHost(repo_url))} branch=${sanitizeForLog(branch)} composePath=${sanitizeForLog(compose_path)} envPath=${sanitizeForLog(resolvedEnvPath ?? 'none')} authType=${sanitizeForLog(resolvedAuthType)} autoApplyOnWebhook=${autoApplyOnWebhook} autoDeployOnApply=${autoDeployOnApply} deployNow=${deploy_now === true}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -453,8 +469,8 @@ stacksRouter.post('/from-git', async (req: Request, res: Response) => {
|
||||
envPath: resolvedEnvPath,
|
||||
authType: resolvedAuthType,
|
||||
token: resolvedAuthType === 'token' && typeof token === 'string' && token !== '' ? token : null,
|
||||
autoApplyOnWebhook: Boolean(auto_apply_on_webhook),
|
||||
autoDeployOnApply: Boolean(auto_deploy_on_apply),
|
||||
autoApplyOnWebhook,
|
||||
autoDeployOnApply,
|
||||
});
|
||||
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { WebhookService } from '../services/WebhookService';
|
||||
import { GitSourceService } from '../services/GitSourceService';
|
||||
import { LicenseService } from '../services/LicenseService';
|
||||
import { authMiddleware } from '../middleware/auth';
|
||||
import { requirePaid, requireAdmin } from '../middleware/tierGates';
|
||||
@@ -27,7 +26,7 @@ webhooksRouter.post('/', authMiddleware, async (req: Request, res: Response): Pr
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const { name, stack_name, action, enabled } = req.body;
|
||||
const { name, stack_name, action, enabled, node_id } = req.body;
|
||||
if (!name || !stack_name || !action) {
|
||||
res.status(400).json({ error: 'name, stack_name, and action are required' });
|
||||
return;
|
||||
@@ -36,7 +35,16 @@ webhooksRouter.post('/', authMiddleware, async (req: Request, res: Response): Pr
|
||||
res.status(400).json({ error: `action must be one of: ${VALID_WEBHOOK_ACTIONS.join(', ')}` });
|
||||
return;
|
||||
}
|
||||
if (action === 'git-pull' && !GitSourceService.getInstance().get(stack_name)) {
|
||||
if (node_id !== undefined && !Number.isInteger(node_id)) {
|
||||
res.status(400).json({ error: 'node_id must be an integer' });
|
||||
return;
|
||||
}
|
||||
const targetNodeId = node_id ?? req.nodeId ?? DatabaseService.getInstance().getDefaultNode()?.id;
|
||||
if (!targetNodeId || !DatabaseService.getInstance().getNode(targetNodeId)) {
|
||||
res.status(400).json({ error: 'node_id must reference an existing node' });
|
||||
return;
|
||||
}
|
||||
if (action === 'git-pull' && !(await WebhookService.getInstance().gitSourceExists(stack_name, targetNodeId))) {
|
||||
res.status(400).json({ error: 'Configure a Git source for this stack before creating a git-pull webhook' });
|
||||
return;
|
||||
}
|
||||
@@ -44,6 +52,7 @@ webhooksRouter.post('/', authMiddleware, async (req: Request, res: Response): Pr
|
||||
const svc = WebhookService.getInstance();
|
||||
const secret = svc.generateSecret();
|
||||
const id = DatabaseService.getInstance().addWebhook({
|
||||
node_id: targetNodeId,
|
||||
name, stack_name, action, secret, enabled: enabled !== false,
|
||||
});
|
||||
|
||||
@@ -63,20 +72,31 @@ webhooksRouter.put('/:id', authMiddleware, async (req: Request, res: Response):
|
||||
const webhook = DatabaseService.getInstance().getWebhook(id);
|
||||
if (!webhook) { res.status(404).json({ error: 'Webhook not found' }); return; }
|
||||
|
||||
const { name, stack_name, action, enabled } = req.body;
|
||||
const { name, stack_name, action, enabled, node_id } = req.body;
|
||||
if (node_id !== undefined && !Number.isInteger(node_id)) {
|
||||
res.status(400).json({ error: 'node_id must be an integer' });
|
||||
return;
|
||||
}
|
||||
const targetNodeId = node_id ?? webhook.node_id;
|
||||
if (node_id !== undefined && !DatabaseService.getInstance().getNode(targetNodeId)) {
|
||||
res.status(400).json({ error: 'node_id must reference an existing node' });
|
||||
return;
|
||||
}
|
||||
if (action && !VALID_WEBHOOK_ACTIONS.includes(action)) {
|
||||
res.status(400).json({ error: `action must be one of: ${VALID_WEBHOOK_ACTIONS.join(', ')}` });
|
||||
return;
|
||||
}
|
||||
if (action === 'git-pull') {
|
||||
const targetStack = stack_name || webhook.stack_name;
|
||||
if (!GitSourceService.getInstance().get(targetStack)) {
|
||||
const effectiveAction = action ?? webhook.action;
|
||||
const effectiveStackName = stack_name ?? webhook.stack_name;
|
||||
if (effectiveAction === 'git-pull') {
|
||||
const targetStack = effectiveStackName;
|
||||
if (!(await WebhookService.getInstance().gitSourceExists(targetStack, targetNodeId))) {
|
||||
res.status(400).json({ error: 'Configure a Git source for this stack before enabling a git-pull webhook' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
DatabaseService.getInstance().updateWebhook(id, { name, stack_name, action, enabled });
|
||||
DatabaseService.getInstance().updateWebhook(id, { node_id, name, stack_name, action, enabled });
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[Webhooks] Update error:', error);
|
||||
|
||||
@@ -103,6 +103,7 @@ export type WebhookAction = 'deploy' | 'restart' | 'stop' | 'start' | 'pull' | '
|
||||
|
||||
export interface Webhook {
|
||||
id?: number;
|
||||
node_id: number;
|
||||
name: string;
|
||||
stack_name: string;
|
||||
action: WebhookAction;
|
||||
@@ -757,6 +758,7 @@ export class DatabaseService {
|
||||
|
||||
CREATE TABLE IF NOT EXISTS webhooks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
node_id INTEGER,
|
||||
name TEXT NOT NULL,
|
||||
stack_name TEXT NOT NULL,
|
||||
action TEXT NOT NULL DEFAULT 'deploy',
|
||||
@@ -1157,6 +1159,12 @@ export class DatabaseService {
|
||||
// Distributed API model columns
|
||||
maybeAddCol('nodes', 'api_url', "TEXT DEFAULT ''");
|
||||
maybeAddCol('nodes', 'api_token', "TEXT DEFAULT ''");
|
||||
maybeAddCol('webhooks', 'node_id', 'INTEGER');
|
||||
this.db.prepare(`
|
||||
UPDATE webhooks
|
||||
SET node_id = COALESCE((SELECT id FROM nodes WHERE is_default = 1 LIMIT 1), 1)
|
||||
WHERE node_id IS NULL
|
||||
`).run();
|
||||
|
||||
// Pilot Agent outbound-mode columns
|
||||
maybeAddCol('nodes', 'mode', "TEXT NOT NULL DEFAULT 'proxy'");
|
||||
@@ -2286,6 +2294,7 @@ export class DatabaseService {
|
||||
public getWebhooks(): Webhook[] {
|
||||
return this.db.prepare('SELECT * FROM webhooks ORDER BY created_at DESC').all().map((row: any) => ({
|
||||
...row,
|
||||
node_id: Number(row.node_id ?? this.getDefaultNode()?.id ?? 1),
|
||||
enabled: row.enabled === 1,
|
||||
}));
|
||||
}
|
||||
@@ -2293,21 +2302,26 @@ export class DatabaseService {
|
||||
public getWebhook(id: number): Webhook | undefined {
|
||||
const row = this.db.prepare('SELECT * FROM webhooks WHERE id = ?').get(id) as any;
|
||||
if (!row) return undefined;
|
||||
return { ...row, enabled: row.enabled === 1 };
|
||||
return {
|
||||
...row,
|
||||
node_id: Number(row.node_id ?? this.getDefaultNode()?.id ?? 1),
|
||||
enabled: row.enabled === 1,
|
||||
};
|
||||
}
|
||||
|
||||
public addWebhook(webhook: Omit<Webhook, 'id' | 'created_at' | 'updated_at'>): number {
|
||||
const now = Date.now();
|
||||
const result = this.db.prepare(
|
||||
'INSERT INTO webhooks (name, stack_name, action, secret, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(webhook.name, webhook.stack_name, webhook.action, webhook.secret, webhook.enabled ? 1 : 0, now, now);
|
||||
'INSERT INTO webhooks (node_id, name, stack_name, action, secret, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(webhook.node_id, webhook.name, webhook.stack_name, webhook.action, webhook.secret, webhook.enabled ? 1 : 0, now, now);
|
||||
return result.lastInsertRowid as number;
|
||||
}
|
||||
|
||||
public updateWebhook(id: number, updates: Partial<Pick<Webhook, 'name' | 'stack_name' | 'action' | 'enabled'>>): void {
|
||||
public updateWebhook(id: number, updates: Partial<Pick<Webhook, 'node_id' | 'name' | 'stack_name' | 'action' | 'enabled'>>): void {
|
||||
const fields: string[] = [];
|
||||
const values: (string | number)[] = [];
|
||||
|
||||
if (updates.node_id !== undefined) { fields.push('node_id = ?'); values.push(updates.node_id); }
|
||||
if (updates.name !== undefined) { fields.push('name = ?'); values.push(updates.name); }
|
||||
if (updates.stack_name !== undefined) { fields.push('stack_name = ?'); values.push(updates.stack_name); }
|
||||
if (updates.action !== undefined) { fields.push('action = ?'); values.push(updates.action); }
|
||||
|
||||
@@ -187,8 +187,11 @@ export class FileSystemService {
|
||||
}
|
||||
|
||||
async getEnvContent(stackName: string): Promise<string> {
|
||||
const stackDir = this.resolveStackDir(stackName);
|
||||
const envPath = path.join(stackDir, '.env');
|
||||
const base = path.resolve(this.baseDir);
|
||||
const envPath = path.resolve(base, path.basename(stackName), '.env');
|
||||
if (!isPathWithinBase(envPath, base)) {
|
||||
throw Object.assign(new Error('Path escapes compose directory'), { code: 'INVALID_PATH' });
|
||||
}
|
||||
try {
|
||||
return await fsPromises.readFile(envPath, 'utf-8');
|
||||
} catch (error) {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import crypto from 'crypto';
|
||||
import { DatabaseService } from './DatabaseService';
|
||||
import { ComposeService } from './ComposeService';
|
||||
import { DatabaseService } from './DatabaseService';
|
||||
import { FileSystemService } from './FileSystemService';
|
||||
import { GitSourceService } from './GitSourceService';
|
||||
import { LicenseService } from './LicenseService';
|
||||
import { PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from './license-headers';
|
||||
import { NodeRegistry } from './NodeRegistry';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { isValidStackName } from '../utils/validation';
|
||||
import { assertPolicyGateAllows, buildSystemPolicyGateOptions } from '../helpers/policyGate';
|
||||
|
||||
type ExecutionResult = { success: boolean; error?: string; duration_ms: number };
|
||||
type ExecutionStatus = 'success' | 'failure';
|
||||
|
||||
const REMOTE_WEBHOOK_REQUEST_TIMEOUT_MS = 30_000;
|
||||
|
||||
export class WebhookService {
|
||||
private static instance: WebhookService;
|
||||
|
||||
@@ -21,7 +30,6 @@ export class WebhookService {
|
||||
}
|
||||
|
||||
public validateSignature(payload: string, secret: string, signature: string): boolean {
|
||||
// Expect format: sha256=<hex>
|
||||
const parts = signature.split('=');
|
||||
if (parts.length !== 2 || parts[0] !== 'sha256') return false;
|
||||
|
||||
@@ -30,123 +38,259 @@ export class WebhookService {
|
||||
.update(payload)
|
||||
.digest('hex');
|
||||
|
||||
return crypto.timingSafeEqual(
|
||||
Buffer.from(expected, 'hex'),
|
||||
Buffer.from(parts[1], 'hex')
|
||||
);
|
||||
try {
|
||||
return crypto.timingSafeEqual(
|
||||
Buffer.from(expected, 'hex'),
|
||||
Buffer.from(parts[1], 'hex'),
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async execute(webhookId: number, action: string, triggerSource: string | null, atomic?: boolean): Promise<{ success: boolean; error?: string; duration_ms: number }> {
|
||||
const db = DatabaseService.getInstance();
|
||||
const webhook = db.getWebhook(webhookId);
|
||||
public async gitSourceExists(stackName: string, nodeId: number): Promise<boolean> {
|
||||
const node = NodeRegistry.getInstance().getNode(nodeId);
|
||||
if (!node) return false;
|
||||
if (node.type !== 'remote') return GitSourceService.getInstance().get(stackName) !== undefined;
|
||||
|
||||
const response = await this.remoteStackRequest(nodeId, stackName, 'git-source', 'GET');
|
||||
return response.ok;
|
||||
}
|
||||
|
||||
public async execute(
|
||||
webhookId: number,
|
||||
action: string,
|
||||
triggerSource: string | null,
|
||||
atomic?: boolean,
|
||||
): Promise<ExecutionResult> {
|
||||
const webhook = DatabaseService.getInstance().getWebhook(webhookId);
|
||||
if (!webhook) throw new Error('Webhook not found');
|
||||
|
||||
const defaultNodeId = NodeRegistry.getInstance().getDefaultNodeId();
|
||||
const nodeId = webhook.node_id || NodeRegistry.getInstance().getDefaultNodeId();
|
||||
const node = NodeRegistry.getInstance().getNode(nodeId);
|
||||
if (!node) {
|
||||
const error = `Node for webhook "${webhook.name}" was not found`;
|
||||
this.recordExecution(webhookId, action, 'failure', triggerSource, 0, error);
|
||||
return { success: false, error, duration_ms: 0 };
|
||||
}
|
||||
|
||||
// Validate the stack still exists
|
||||
const stacks = await FileSystemService.getInstance(defaultNodeId).getStacks();
|
||||
if (!stacks.includes(webhook.stack_name)) {
|
||||
const error = `Stack "${webhook.stack_name}" not found`;
|
||||
db.addWebhookExecution({
|
||||
webhook_id: webhookId,
|
||||
action,
|
||||
status: 'failure',
|
||||
trigger_source: triggerSource,
|
||||
duration_ms: 0,
|
||||
error,
|
||||
executed_at: Date.now(),
|
||||
});
|
||||
if (node.type === 'remote') {
|
||||
return this.executeRemote(webhookId, nodeId, webhook.stack_name, action, triggerSource, atomic);
|
||||
}
|
||||
|
||||
return this.executeLocal(webhookId, nodeId, webhook.stack_name, action, triggerSource, atomic);
|
||||
}
|
||||
|
||||
public maskSecret(secret: string): string {
|
||||
if (secret.length <= 8) return '********';
|
||||
return '********' + secret.slice(-4);
|
||||
}
|
||||
|
||||
private async executeLocal(
|
||||
webhookId: number,
|
||||
nodeId: number,
|
||||
stackName: string,
|
||||
action: string,
|
||||
triggerSource: string | null,
|
||||
atomic?: boolean,
|
||||
): Promise<ExecutionResult> {
|
||||
const stacks = await FileSystemService.getInstance(nodeId).getStacks();
|
||||
if (!stacks.includes(stackName)) {
|
||||
const error = `Stack "${stackName}" not found`;
|
||||
this.recordExecution(webhookId, action, 'failure', triggerSource, 0, error);
|
||||
return { success: false, error, duration_ms: 0 };
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
const compose = ComposeService.getInstance(defaultNodeId);
|
||||
const compose = ComposeService.getInstance(nodeId);
|
||||
switch (action) {
|
||||
case 'deploy':
|
||||
await assertPolicyGateAllows(
|
||||
webhook.stack_name,
|
||||
defaultNodeId,
|
||||
stackName,
|
||||
nodeId,
|
||||
buildSystemPolicyGateOptions('webhook', { auditPath: `/api/webhooks/${webhookId}/execute` }),
|
||||
);
|
||||
await compose.deployStack(webhook.stack_name, undefined, atomic);
|
||||
await compose.deployStack(stackName, undefined, atomic);
|
||||
break;
|
||||
case 'restart':
|
||||
await compose.runCommand(webhook.stack_name, 'restart');
|
||||
await compose.runCommand(stackName, 'restart');
|
||||
break;
|
||||
case 'stop':
|
||||
await compose.runCommand(webhook.stack_name, 'stop');
|
||||
await compose.runCommand(stackName, 'stop');
|
||||
break;
|
||||
case 'start':
|
||||
await compose.runCommand(webhook.stack_name, 'start');
|
||||
await compose.runCommand(stackName, 'start');
|
||||
break;
|
||||
case 'pull':
|
||||
await assertPolicyGateAllows(
|
||||
webhook.stack_name,
|
||||
defaultNodeId,
|
||||
stackName,
|
||||
nodeId,
|
||||
buildSystemPolicyGateOptions('webhook', { auditPath: `/api/webhooks/${webhookId}/execute` }),
|
||||
);
|
||||
await compose.updateStack(webhook.stack_name, undefined, atomic);
|
||||
await compose.updateStack(stackName, undefined, atomic);
|
||||
break;
|
||||
case 'git-pull': {
|
||||
const result = await GitSourceService.getInstance().handleWebhookPull(webhook.stack_name);
|
||||
const duration_ms = Date.now() - startTime;
|
||||
if (result.status === 'error') {
|
||||
db.addWebhookExecution({
|
||||
webhook_id: webhookId,
|
||||
action,
|
||||
status: 'failure',
|
||||
trigger_source: triggerSource,
|
||||
duration_ms,
|
||||
error: result.message,
|
||||
executed_at: Date.now(),
|
||||
});
|
||||
return { success: false, error: result.message, duration_ms };
|
||||
}
|
||||
db.addWebhookExecution({
|
||||
webhook_id: webhookId,
|
||||
action,
|
||||
status: result.status === 'skipped' ? 'failure' : 'success',
|
||||
trigger_source: triggerSource,
|
||||
duration_ms,
|
||||
error: result.status === 'skipped' ? result.message : null,
|
||||
executed_at: Date.now(),
|
||||
});
|
||||
return { success: result.status === 'success', error: result.status === 'skipped' ? result.message : undefined, duration_ms };
|
||||
}
|
||||
case 'git-pull':
|
||||
return this.executeLocalGitPull(webhookId, stackName, action, triggerSource, startTime);
|
||||
default:
|
||||
throw new Error(`Unknown action: ${action}`);
|
||||
}
|
||||
|
||||
const duration_ms = Date.now() - startTime;
|
||||
db.addWebhookExecution({
|
||||
webhook_id: webhookId,
|
||||
action,
|
||||
status: 'success',
|
||||
trigger_source: triggerSource,
|
||||
duration_ms,
|
||||
error: null,
|
||||
executed_at: Date.now(),
|
||||
});
|
||||
return { success: true, duration_ms };
|
||||
const durationMs = Date.now() - startTime;
|
||||
this.recordExecution(webhookId, action, 'success', triggerSource, durationMs, null);
|
||||
return { success: true, duration_ms: durationMs };
|
||||
} catch (err) {
|
||||
const duration_ms = Date.now() - startTime;
|
||||
const error = (err as Error).message || 'Unknown error';
|
||||
db.addWebhookExecution({
|
||||
webhook_id: webhookId,
|
||||
action,
|
||||
status: 'failure',
|
||||
trigger_source: triggerSource,
|
||||
duration_ms,
|
||||
error,
|
||||
executed_at: Date.now(),
|
||||
});
|
||||
return { success: false, error, duration_ms };
|
||||
const durationMs = Date.now() - startTime;
|
||||
const error = getErrorMessage(err, 'Unknown error');
|
||||
this.recordExecution(webhookId, action, 'failure', triggerSource, durationMs, error);
|
||||
return { success: false, error, duration_ms: durationMs };
|
||||
}
|
||||
}
|
||||
|
||||
public maskSecret(secret: string): string {
|
||||
if (secret.length <= 8) return '••••••••';
|
||||
return '••••••••' + secret.slice(-4);
|
||||
private async executeLocalGitPull(
|
||||
webhookId: number,
|
||||
stackName: string,
|
||||
action: string,
|
||||
triggerSource: string | null,
|
||||
startTime: number,
|
||||
): Promise<ExecutionResult> {
|
||||
const result = await GitSourceService.getInstance().handleWebhookPull(stackName);
|
||||
const durationMs = Date.now() - startTime;
|
||||
if (result.status === 'error') {
|
||||
this.recordExecution(webhookId, action, 'failure', triggerSource, durationMs, result.message);
|
||||
return { success: false, error: result.message, duration_ms: durationMs };
|
||||
}
|
||||
|
||||
const skipped = result.status === 'skipped';
|
||||
this.recordExecution(
|
||||
webhookId,
|
||||
action,
|
||||
skipped ? 'failure' : 'success',
|
||||
triggerSource,
|
||||
durationMs,
|
||||
skipped ? result.message : null,
|
||||
);
|
||||
return { success: !skipped, error: skipped ? result.message : undefined, duration_ms: durationMs };
|
||||
}
|
||||
|
||||
private async executeRemote(
|
||||
webhookId: number,
|
||||
nodeId: number,
|
||||
stackName: string,
|
||||
action: string,
|
||||
triggerSource: string | null,
|
||||
atomic?: boolean,
|
||||
): Promise<ExecutionResult> {
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
const endpoint = action === 'git-pull'
|
||||
? 'git-source/webhook-pull'
|
||||
: action === 'pull'
|
||||
? 'update'
|
||||
: action;
|
||||
const body = atomic === undefined ? undefined : { atomic };
|
||||
const response = await this.remoteStackRequest(nodeId, stackName, endpoint, 'POST', body);
|
||||
const durationMs = Date.now() - startTime;
|
||||
const payload = await response.json().catch(() => ({})) as { error?: string; message?: string; status?: string };
|
||||
|
||||
if (!response.ok || payload.status === 'error' || payload.status === 'skipped') {
|
||||
const error = payload.error || payload.message || `Remote ${action} failed with status ${response.status}`;
|
||||
this.recordExecution(webhookId, action, 'failure', triggerSource, durationMs, error);
|
||||
return { success: false, error, duration_ms: durationMs };
|
||||
}
|
||||
|
||||
this.recordExecution(webhookId, action, 'success', triggerSource, durationMs, null);
|
||||
return { success: true, duration_ms: durationMs };
|
||||
} catch (err) {
|
||||
const durationMs = Date.now() - startTime;
|
||||
const error = getErrorMessage(err, 'Remote node operation failed');
|
||||
this.recordExecution(webhookId, action, 'failure', triggerSource, durationMs, error);
|
||||
return { success: false, error, duration_ms: durationMs };
|
||||
}
|
||||
}
|
||||
|
||||
private async remoteStackRequest(
|
||||
nodeId: number,
|
||||
stackName: string,
|
||||
endpoint: string,
|
||||
method: 'GET' | 'POST',
|
||||
body?: unknown,
|
||||
): Promise<Response> {
|
||||
const target = NodeRegistry.getInstance().getProxyTarget(nodeId);
|
||||
if (!target) throw new Error('Remote node is unreachable or not configured');
|
||||
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (target.apiToken) headers.Authorization = `Bearer ${target.apiToken}`;
|
||||
|
||||
const licenseHeaders = LicenseService.getInstance().getProxyHeaders();
|
||||
headers[PROXY_TIER_HEADER] = licenseHeaders.tier;
|
||||
headers[PROXY_VARIANT_HEADER] = licenseHeaders.variant || '';
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), REMOTE_WEBHOOK_REQUEST_TIMEOUT_MS);
|
||||
try {
|
||||
// nodeId selects a server-controlled entry from the registry
|
||||
// (CodeQL GOOD pattern: user input maps to known values, not concatenated into the URL).
|
||||
const targetBase = new URL(target.apiUrl);
|
||||
const protocol = targetBase.protocol;
|
||||
const host = targetBase.host;
|
||||
const hostname = targetBase.hostname;
|
||||
|
||||
// Verify the hostname is in the configured-node allow-list.
|
||||
const allowedHosts = DatabaseService.getInstance().getNodes()
|
||||
.filter(n => n.api_url)
|
||||
.map(n => new URL(n.api_url!).hostname);
|
||||
if (!allowedHosts.includes(hostname)) {
|
||||
throw new Error('Remote node hostname is not a configured node');
|
||||
}
|
||||
|
||||
// Restrict protocol to http/https (prevents file://, ftp://, etc.).
|
||||
if (protocol !== 'http:' && protocol !== 'https:') {
|
||||
throw new Error('Remote node URL must use http:// or https://');
|
||||
}
|
||||
|
||||
// Validate path components to prevent traversal.
|
||||
if (!isValidStackName(stackName)) {
|
||||
throw new Error('Invalid stack name');
|
||||
}
|
||||
if (!/^[a-z][a-z0-9/-]*$/.test(endpoint) || endpoint.includes('..')) {
|
||||
throw new Error('Invalid endpoint');
|
||||
}
|
||||
|
||||
// Build URL from validated, server-controlled components.
|
||||
const url = `${protocol}//${host}/api/stacks/${encodeURIComponent(stackName)}/${endpoint}`;
|
||||
return await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: method === 'GET' || body === undefined ? undefined : JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) {
|
||||
throw new Error('Remote node request timed out', { cause: err });
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
private recordExecution(
|
||||
webhookId: number,
|
||||
action: string,
|
||||
status: ExecutionStatus,
|
||||
triggerSource: string | null,
|
||||
durationMs: number,
|
||||
error: string | null,
|
||||
): void {
|
||||
DatabaseService.getInstance().addWebhookExecution({
|
||||
webhook_id: webhookId,
|
||||
action,
|
||||
status,
|
||||
trigger_source: triggerSource,
|
||||
duration_ms: durationMs,
|
||||
error,
|
||||
executed_at: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,18 @@ export function isValidRelativeStackPath(rel: string): boolean {
|
||||
return !segments.some(seg => seg === '..' || seg === '.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a file path inside a fetched Git repository.
|
||||
* Git source paths are POSIX-style relative file paths. They must not escape
|
||||
* the clone root or target Git metadata.
|
||||
*/
|
||||
export function isValidGitSourcePath(rel: string): boolean {
|
||||
if (!isValidRelativeStackPath(rel)) return false;
|
||||
if (rel === '') return false;
|
||||
const segments = rel.split('/').map(seg => seg.toLowerCase());
|
||||
return !segments.some(seg => seg === '.git');
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that a resolved file path stays within a given base directory.
|
||||
* Returns true if the path is safe, false if it escapes the base.
|
||||
|
||||
@@ -5,6 +5,7 @@ import { TogglePill } from '@/components/ui/toggle-pill';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { copyToClipboard } from '@/lib/clipboard';
|
||||
import {
|
||||
@@ -19,6 +20,7 @@ import { useMastheadStats } from './MastheadStatsContext';
|
||||
|
||||
interface WebhookItem {
|
||||
id: number;
|
||||
node_id: number;
|
||||
name: string;
|
||||
stack_name: string;
|
||||
action: string;
|
||||
@@ -40,6 +42,7 @@ interface WebhookExecution {
|
||||
}
|
||||
|
||||
export function WebhooksSection({ isPaid }: { isPaid: boolean }) {
|
||||
const { activeNode, nodes } = useNodes();
|
||||
const [webhooks, setWebhooks] = useState<WebhookItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
@@ -68,7 +71,7 @@ export function WebhooksSection({ isPaid }: { isPaid: boolean }) {
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
useEffect(() => { fetchWebhooks(); fetchStacks(); }, []);
|
||||
useEffect(() => { fetchWebhooks(); fetchStacks(); }, [activeNode?.id]);
|
||||
|
||||
const enabledCount = webhooks.filter(w => w.enabled).length;
|
||||
useMastheadStats(
|
||||
@@ -94,7 +97,7 @@ export function WebhooksSection({ isPaid }: { isPaid: boolean }) {
|
||||
const res = await apiFetch('/webhooks', {
|
||||
method: 'POST',
|
||||
localOnly: true,
|
||||
body: JSON.stringify({ name: formName, stack_name: formStack, action: formAction }),
|
||||
body: JSON.stringify({ name: formName, stack_name: formStack, action: formAction, node_id: activeNode?.id }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
@@ -175,6 +178,13 @@ export function WebhooksSection({ isPaid }: { isPaid: boolean }) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingsField>
|
||||
{activeNode && (
|
||||
<SettingsField label="Node" helper="Webhook execution is pinned to the currently active node.">
|
||||
<div className="rounded-md border border-card-border bg-muted px-3 py-2 text-xs text-stat-subtitle">
|
||||
{activeNode.name}
|
||||
</div>
|
||||
</SettingsField>
|
||||
)}
|
||||
<SettingsField label="Action" helper="What happens when the webhook is triggered." htmlFor="webhook-action">
|
||||
<Select value={formAction} onValueChange={setFormAction}>
|
||||
<SelectTrigger id="webhook-action"><SelectValue /></SelectTrigger>
|
||||
@@ -240,6 +250,7 @@ export function WebhooksSection({ isPaid }: { isPaid: boolean }) {
|
||||
{webhooks.map(wh => {
|
||||
const triggerUrl = `${window.location.origin}/api/webhooks/${wh.id}/trigger`;
|
||||
const isExpanded = expandedHistory === wh.id;
|
||||
const nodeName = nodes.find(n => n.id === wh.node_id)?.name ?? `Node ${wh.node_id}`;
|
||||
return (
|
||||
<div key={wh.id} className="border border-card-border rounded-md overflow-hidden bg-card">
|
||||
<div className="p-4 space-y-3">
|
||||
@@ -253,6 +264,9 @@ export function WebhooksSection({ isPaid }: { isPaid: boolean }) {
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle border border-card-border rounded px-1.5 py-0.5 shrink-0">
|
||||
{wh.stack_name}
|
||||
</span>
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle border border-card-border rounded px-1.5 py-0.5 shrink-0">
|
||||
{nodeName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<TogglePill checked={wh.enabled} onChange={(c) => handleToggle(wh.id!, c)} />
|
||||
|
||||
Reference in New Issue
Block a user