fix(git-sources): harden webhook delivery, transport errors, and clone limits (#1249)

* fix(git-sources): harden webhook delivery, transport errors, and clone limits

Map webhook-pull outcomes to real HTTP status codes (200 success, 202
debounced, 404 no source, 422 failure) instead of always returning 200, so a
Git provider and any monitoring on it can tell when a delivery actually failed.

Close a concurrent webhook fan-out gap: the debounce window is now re-checked
inside the per-stack lock, so simultaneous deliveries for one push run a single
clone instead of one per request. The whole pull/apply critical section runs
under a single lock acquisition.

Unwrap fetch transport causes (ENOTFOUND, ECONNREFUSED, ECONNRESET, TLS) so a
clone failure surfaces an actionable, host-qualified message instead of a bare
"fetch failed".

Cap how many bytes a single clone may download to protect the host disk;
operators can tune it with GITSOURCE_MAX_CLONE_BYTES (default 100 MB).

Log webhook pull failures server-side, since the webhook path is unattended.

* test(git-sources): assert surfaced host via toContain to satisfy CodeQL

* fix(git-sources): bound per-file read, treat debounced webhooks as non-failure, correct clone-cap docs

* docs(git-sources): correct clone-cap comment to describe a download bound, not disk
This commit is contained in:
Anso
2026-05-29 09:43:37 -04:00
committed by GitHub
parent b33a0e8422
commit 2844f606cd
10 changed files with 722 additions and 166 deletions
@@ -11,12 +11,36 @@
* Service-layer logic (encryption, error mapping, mutex, pending lifecycle)
* is covered in git-source-service.test.ts.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import fs from 'fs';
import path from 'path';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
import { DatabaseService } from '../services/DatabaseService';
import { GitSourceService } from '../services/GitSourceService';
function seedGitSource(stackName: string): void {
DatabaseService.getInstance().upsertGitSource({
stack_name: stackName,
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,
});
}
let tmpDir: string;
let app: import('express').Express;
@@ -291,6 +315,55 @@ describe('POST /api/stacks/from-git', () => {
});
});
describe('POST /api/stacks/:stackName/git-source/webhook-pull status codes', () => {
it('returns 404 (not 200) when the stack has no Git source configured', async () => {
const res = await request(app)
.post('/api/stacks/existing-stack/git-source/webhook-pull')
.set('Authorization', `Bearer ${adminToken()}`);
expect(res.status).toBe(404);
expect(res.body.error).toMatch(/no git source/i);
});
it('returns 401 without auth', async () => {
const res = await request(app).post('/api/stacks/existing-stack/git-source/webhook-pull');
expect(res.status).toBe(401);
});
it('maps a failed pull to 422 (not 200) so a Git provider sees the failure', async () => {
seedGitSource('webhook-status-422');
const pullSpy = vi.spyOn(GitSourceService.getInstance(), 'handleWebhookPull')
.mockResolvedValue({ status: 'error', message: 'Validation failed: bad compose' });
const res = await request(app)
.post('/api/stacks/webhook-status-422/git-source/webhook-pull')
.set('Authorization', `Bearer ${adminToken()}`);
expect(res.status).toBe(422);
expect(res.body.status).toBe('error');
pullSpy.mockRestore();
});
it('maps a debounced pull to 202', async () => {
seedGitSource('webhook-status-202');
const pullSpy = vi.spyOn(GitSourceService.getInstance(), 'handleWebhookPull')
.mockResolvedValue({ status: 'skipped', message: 'Rate limited (debounced).' });
const res = await request(app)
.post('/api/stacks/webhook-status-202/git-source/webhook-pull')
.set('Authorization', `Bearer ${adminToken()}`);
expect(res.status).toBe(202);
pullSpy.mockRestore();
});
it('maps a successful pull to 200', async () => {
seedGitSource('webhook-status-200');
const pullSpy = vi.spyOn(GitSourceService.getInstance(), 'handleWebhookPull')
.mockResolvedValue({ status: 'success', message: 'Pending update ready at abc1234.' });
const res = await request(app)
.post('/api/stacks/webhook-status-200/git-source/webhook-pull')
.set('Authorization', `Bearer ${adminToken()}`);
expect(res.status).toBe(200);
pullSpy.mockRestore();
});
});
describe('GET /api/git-sources', () => {
it('returns 200 and a JSON array for an authenticated admin', async () => {
const res = await request(app)