feat(git-sync): двусторонний Docmost↔git синк на унифицированном конвертере (#359)
Схлопнутая дельта feat/git-sync-2 поверх актуального develop (3085ec1b). Почему squash, а не буквальный rebase: в ветке 113 коммитов, из которых develop уже впитал ~80 в курированном виде при унификации конвертера (#326/#293) — но не patch-identical, поэтому они не отваливаются сами; а коммит, УДАЛЯЮЩИЙ вендоренную копию конвертера, апстримный и в replay-набор не входит, так что наивный replay заново добавил бы вендоренные копии. Поэтому корректный итог — «develop + чистая net-дельта ветки», сведённая 3-way мержом (merge-base5336f06d). Net-дельта: серверный git-sync модуль (GitSyncModule/orchestrator/HTTP), движок git-sync (layout/reconcile/pull/push/stabilize + QA), два фикса round-trip/data-loss конвертера поверх УНИФИЦИРОВАННОГО @docmost/prosemirror-markdown (вендоренной копии больше нет — git-sync целиком на унифицированном конвертере, поведение унифицированного принято за эталон), e2e-скрипты, доки. Конфликты (4) слиты объединением: main.ts (метрики + GitHttpService), apps/server/package.json (pretest-суперсет; moduleNameMapper: git-sync→src и .js-strip оставлены, а bare-specifier @docmost/prosemirror-markdown→src УБРАН — серверный jest выровнен на develop-подход #345 со сборённым пакетом), .env.example (метрики + GIT_SYNC блоки), AGENTS.md (строки про пакеты; устаревшая заметка про «три hand-synced копии схемы» в git-sync поправлена — схема теперь только в @docmost/prosemirror-markdown). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,414 @@
|
||||
// Unit tests for the pure CGI-response helpers used by GitHttpBackendService.
|
||||
// The header/body split MUST treat the body as binary (Buffer) and never
|
||||
// stringify it; the Status: header sets the HTTP status (default 200).
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
// Mock the spawn boundary so run() never launches a real `git http-backend`; the
|
||||
// fake child lets us drive every stdout/stderr/error/close branch by hand.
|
||||
jest.mock('node:child_process', () => ({ spawn: jest.fn() }));
|
||||
// vaultGitEnv just builds the CGI env overlay; stub it to a passthrough so the
|
||||
// service runs without the real engine. The service loads it at runtime via the
|
||||
// `loadGitSync()` bridge (the ESM `@docmost/git-sync` package cannot be
|
||||
// `require()`d under jest), so we mock that loader rather than the package.
|
||||
jest.mock('../git-sync.loader', () => ({
|
||||
loadGitSync: jest.fn(async () => ({
|
||||
vaultGitEnv: (overlay: Record<string, string>) => overlay,
|
||||
})),
|
||||
}));
|
||||
|
||||
import {
|
||||
parseCgiResponse,
|
||||
splitCgiBuffer,
|
||||
buildGitBackendCgiEnv,
|
||||
GitHttpBackendService,
|
||||
} from './git-http-backend.service';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import type { GitHttpBackendRequest } from './git-http-backend.service';
|
||||
|
||||
const spawnMock = spawn as unknown as jest.Mock;
|
||||
|
||||
/** A fake `git http-backend` child: EventEmitter + stdout/stderr/stdin streams. */
|
||||
function fakeChild() {
|
||||
const child = new EventEmitter() as any;
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
// stdin is written/ended/piped to; capture the calls, swallow nothing.
|
||||
child.stdin = Object.assign(new EventEmitter(), {
|
||||
end: jest.fn(),
|
||||
write: jest.fn(),
|
||||
});
|
||||
// The watchdog kills the child on timeout; capture the signal.
|
||||
child.kill = jest.fn();
|
||||
return child;
|
||||
}
|
||||
|
||||
/** A fake raw Node ServerResponse capturing status/headers/body/end. */
|
||||
function fakeRes() {
|
||||
const res: any = {
|
||||
headersSent: false,
|
||||
writableEnded: false,
|
||||
statusCode: 200,
|
||||
_headers: {} as Record<string, string>,
|
||||
_written: [] as Buffer[],
|
||||
setHeader: jest.fn((name: string, value: string) => {
|
||||
res._headers[name] = value;
|
||||
}),
|
||||
write: jest.fn((chunk: Buffer) => {
|
||||
res._written.push(chunk);
|
||||
return true;
|
||||
}),
|
||||
end: jest.fn((chunk?: Buffer | string) => {
|
||||
if (chunk !== undefined) res._written.push(chunk as Buffer);
|
||||
res.writableEnded = true;
|
||||
}),
|
||||
};
|
||||
return res;
|
||||
}
|
||||
|
||||
/** A fake raw Node IncomingMessage (GET => no body piped). */
|
||||
function fakeReq() {
|
||||
const req = new EventEmitter() as any;
|
||||
req.pipe = jest.fn();
|
||||
return req;
|
||||
}
|
||||
|
||||
const baseRequest: GitHttpBackendRequest = {
|
||||
spaceId: 'space-1',
|
||||
subpath: 'info/refs',
|
||||
method: 'GET',
|
||||
queryString: 'service=git-upload-pack',
|
||||
contentType: '',
|
||||
remoteUser: 'alice@example.com',
|
||||
};
|
||||
|
||||
function buildService(backendTimeoutMs = 120000) {
|
||||
const env = {
|
||||
getGitSyncDataDir: jest.fn(() => '/vaults'),
|
||||
// The watchdog timeout for the spawned git http-backend. Tests inject a tiny
|
||||
// value (or use fake timers) to drive the timeout branch.
|
||||
getGitSyncBackendTimeoutMs: jest.fn(() => backendTimeoutMs),
|
||||
};
|
||||
return new GitHttpBackendService(env as any);
|
||||
}
|
||||
|
||||
// `run()` now awaits the async `loadGitSync()` bridge before it spawns the
|
||||
// child, so the spawn (and its stream-handler wiring) happens one microtask
|
||||
// after `run()` is called. These tests drive the fake child synchronously, so
|
||||
// flush the microtask queue first to let `run()` reach the spawn.
|
||||
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
describe('GitHttpBackendService.run', () => {
|
||||
beforeEach(() => {
|
||||
spawnMock.mockReset();
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
});
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('(a) responds 500 when the child errors before any headers were written', async () => {
|
||||
const child = fakeChild();
|
||||
spawnMock.mockReturnValue(child);
|
||||
const service = buildService();
|
||||
const res = fakeRes();
|
||||
|
||||
const p = service.run(baseRequest, fakeReq(), res);
|
||||
await flush();
|
||||
// Emit a child 'error' before any stdout -> 500, headers not already sent.
|
||||
child.emit('error', new Error('ENOENT spawn git'));
|
||||
await p;
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res._headers['Content-Type']).toBe('text/plain');
|
||||
expect(res.end).toHaveBeenCalledWith('Internal server error');
|
||||
});
|
||||
|
||||
it('(a) responds 500 when the child closes before a complete CGI header block', async () => {
|
||||
const child = fakeChild();
|
||||
spawnMock.mockReturnValue(child);
|
||||
const service = buildService();
|
||||
const res = fakeRes();
|
||||
|
||||
const p = service.run(baseRequest, fakeReq(), res);
|
||||
await flush();
|
||||
// stderr diagnostics, then a close with no valid CGI output -> 500.
|
||||
child.stderr.emit('data', Buffer.from('fatal: boom'));
|
||||
child.emit('close', 128);
|
||||
await p;
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.end).toHaveBeenCalledWith('Internal server error');
|
||||
});
|
||||
|
||||
it('(b) parses the CGI header block, sets status/headers, writes the body', async () => {
|
||||
const child = fakeChild();
|
||||
spawnMock.mockReturnValue(child);
|
||||
const service = buildService();
|
||||
const res = fakeRes();
|
||||
|
||||
const p = service.run(baseRequest, fakeReq(), res);
|
||||
await flush();
|
||||
// A full CGI response: status line + header + blank line + body.
|
||||
child.stdout.emit(
|
||||
'data',
|
||||
Buffer.from(
|
||||
'Status: 200 OK\r\nContent-Type: application/x-git-upload-pack-advertisement\r\n\r\nPACKBODY',
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
child.emit('close', 0);
|
||||
await p;
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res._headers['Content-Type']).toBe(
|
||||
'application/x-git-upload-pack-advertisement',
|
||||
);
|
||||
expect(Buffer.concat(res._written.map((c) => Buffer.from(c))).toString()).toContain(
|
||||
'PACKBODY',
|
||||
);
|
||||
expect(res.writableEnded).toBe(true);
|
||||
});
|
||||
|
||||
it('(c) swallows a stdout stream error (EPIPE) without throwing or 500ing', async () => {
|
||||
const child = fakeChild();
|
||||
spawnMock.mockReturnValue(child);
|
||||
const service = buildService();
|
||||
const res = fakeRes();
|
||||
const warnSpy = jest.spyOn(Logger.prototype, 'warn');
|
||||
|
||||
const p = service.run(baseRequest, fakeReq(), res);
|
||||
await flush();
|
||||
// The stdout 'error' handler must absorb this — no unhandled throw, no 500.
|
||||
expect(() => child.stdout.emit('error', new Error('EPIPE'))).not.toThrow();
|
||||
expect(() => child.stderr.emit('error', new Error('EPIPE'))).not.toThrow();
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
expect(res.statusCode).not.toBe(500);
|
||||
|
||||
// Let run() settle so the promise does not dangle.
|
||||
child.emit('close', 0);
|
||||
await p;
|
||||
});
|
||||
|
||||
it('(d) timeout: a child that never closes is killed and a 500 is sent', async () => {
|
||||
// The child never emits stdout/close (a stalled git-receive-pack). With a
|
||||
// tiny injected watchdog timeout the run() promise must still resolve: the
|
||||
// child is killed and a clean 500 is sent (no headers were sent yet).
|
||||
const child = fakeChild();
|
||||
spawnMock.mockReturnValue(child);
|
||||
const service = buildService(5); // 5ms watchdog
|
||||
const res = fakeRes();
|
||||
const warnSpy = jest.spyOn(Logger.prototype, 'warn');
|
||||
|
||||
// run() resolves only via the watchdog firing (no close/error emitted).
|
||||
await service.run(baseRequest, fakeReq(), res);
|
||||
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGTERM');
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.end).toHaveBeenCalledWith('Internal server error');
|
||||
});
|
||||
|
||||
it('(d) timeout watchdog is cleared on a normal close (no kill, no 500)', async () => {
|
||||
// A normal request that completes well within the watchdog window must NOT be
|
||||
// killed and must NOT trip the timeout 500 — the timer is cleared on close.
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const child = fakeChild();
|
||||
spawnMock.mockReturnValue(child);
|
||||
const service = buildService(120000);
|
||||
const res = fakeRes();
|
||||
|
||||
const p = service.run(baseRequest, fakeReq(), res);
|
||||
// loadGitSync resolves on a real microtask; advance it under fake timers.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
child.stdout.emit(
|
||||
'data',
|
||||
Buffer.from('Status: 200 OK\r\nContent-Type: text/plain\r\n\r\nOK', 'utf8'),
|
||||
);
|
||||
child.emit('close', 0);
|
||||
await p;
|
||||
|
||||
// The watchdog never fired even if we advance past its window.
|
||||
jest.advanceTimersByTime(200000);
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
expect(res.statusCode).toBe(200);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('spawn throwing synchronously -> 500 (spawn-failed)', async () => {
|
||||
spawnMock.mockImplementation(() => {
|
||||
throw new Error('spawn EACCES');
|
||||
});
|
||||
const service = buildService();
|
||||
const res = fakeRes();
|
||||
|
||||
await service.run(baseRequest, fakeReq(), res);
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.end).toHaveBeenCalledWith('Internal server error');
|
||||
});
|
||||
|
||||
it('(abort) an ALREADY-aborted signal -> no spawn, 500 lock-lost', async () => {
|
||||
// The per-space lock was already lost before run() reached the spawn: we must
|
||||
// NOT start writing the working tree after a possible lock takeover.
|
||||
const child = fakeChild();
|
||||
spawnMock.mockReturnValue(child);
|
||||
const service = buildService();
|
||||
const res = fakeRes();
|
||||
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await service.run(baseRequest, fakeReq(), res, controller.signal);
|
||||
|
||||
expect(spawnMock).not.toHaveBeenCalled();
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.end).toHaveBeenCalledWith('Internal server error');
|
||||
});
|
||||
|
||||
it('(abort) a live signal aborted mid-request -> child SIGTERM + response closed', async () => {
|
||||
// The lock lapses mid-push: the abort fires, the child is killed (SIGTERM,
|
||||
// then SIGKILL on escalation), and the response is finished.
|
||||
const child = fakeChild();
|
||||
spawnMock.mockReturnValue(child);
|
||||
const service = buildService();
|
||||
const res = fakeRes();
|
||||
const warnSpy = jest.spyOn(Logger.prototype, 'warn');
|
||||
|
||||
const controller = new AbortController();
|
||||
const p = service.run(baseRequest, fakeReq(), res, controller.signal);
|
||||
await flush(); // let run() reach the spawn + wire the abort listener
|
||||
controller.abort();
|
||||
await p;
|
||||
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGTERM');
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
// No headers were sent before the abort -> a clean 500 is sent and ended.
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.writableEnded).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildGitBackendCgiEnv', () => {
|
||||
const base = {
|
||||
spaceId: 'space-1',
|
||||
subpath: 'info/refs',
|
||||
method: 'GET',
|
||||
queryString: 'service=git-upload-pack',
|
||||
contentType: '',
|
||||
remoteUser: 'alice@example.com',
|
||||
};
|
||||
|
||||
it('points PATH_INFO at the NON-bare repo dir (no .git suffix)', () => {
|
||||
// Regression guard: the vault lives at <root>/<spaceId> (a working repo), so
|
||||
// PATH_INFO must be /<spaceId>/<subpath>. A `.git` suffix made git
|
||||
// http-backend resolve <root>/<spaceId>.git and 404 every fetch/push.
|
||||
const env = buildGitBackendCgiEnv(base, '/vaults');
|
||||
expect(env.PATH_INFO).toBe('/space-1/info/refs');
|
||||
expect(env.PATH_INFO).not.toContain('.git');
|
||||
expect(env.GIT_PROJECT_ROOT).toBe('/vaults');
|
||||
});
|
||||
|
||||
it('forwards method/query/content-type/remote-user and exports all repos', () => {
|
||||
const env = buildGitBackendCgiEnv(
|
||||
{ ...base, method: 'POST', subpath: 'git-receive-pack', contentType: 'application/x-git-receive-pack-request', queryString: '' },
|
||||
'/vaults',
|
||||
);
|
||||
expect(env.REQUEST_METHOD).toBe('POST');
|
||||
expect(env.PATH_INFO).toBe('/space-1/git-receive-pack');
|
||||
expect(env.CONTENT_TYPE).toBe('application/x-git-receive-pack-request');
|
||||
expect(env.REMOTE_USER).toBe('alice@example.com');
|
||||
expect(env.GIT_HTTP_EXPORT_ALL).toBe('1');
|
||||
});
|
||||
|
||||
it('sets GIT_PROTOCOL only when the client sent the header', () => {
|
||||
expect(buildGitBackendCgiEnv(base, '/vaults').GIT_PROTOCOL).toBeUndefined();
|
||||
expect(
|
||||
buildGitBackendCgiEnv({ ...base, gitProtocol: 'version=2' }, '/vaults')
|
||||
.GIT_PROTOCOL,
|
||||
).toBe('version=2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseCgiResponse', () => {
|
||||
it('defaults to status 200 with no Status header', () => {
|
||||
const r = parseCgiResponse('Content-Type: application/x-git-upload-pack-result');
|
||||
expect(r.statusCode).toBe(200);
|
||||
expect(r.headers).toEqual([
|
||||
['Content-Type', 'application/x-git-upload-pack-result'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('honors a Status header and does not forward it', () => {
|
||||
const r = parseCgiResponse('Status: 404 Not Found\nContent-Type: text/plain');
|
||||
expect(r.statusCode).toBe(404);
|
||||
expect(r.headers).toEqual([['Content-Type', 'text/plain']]);
|
||||
});
|
||||
|
||||
it('parses multiple headers and trims whitespace', () => {
|
||||
const r = parseCgiResponse(
|
||||
'Status: 403 Forbidden\r\nContent-Type: text/plain \r\nX-Foo: bar ',
|
||||
);
|
||||
expect(r.statusCode).toBe(403);
|
||||
expect(r.headers).toEqual([
|
||||
['Content-Type', 'text/plain'],
|
||||
['X-Foo', 'bar'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores malformed (colon-less) lines defensively', () => {
|
||||
const r = parseCgiResponse('Content-Type: text/plain\ngarbage-line\nX-A: b');
|
||||
expect(r.statusCode).toBe(200);
|
||||
expect(r.headers).toEqual([
|
||||
['Content-Type', 'text/plain'],
|
||||
['X-A', 'b'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores an out-of-range Status code and keeps the default', () => {
|
||||
const r = parseCgiResponse('Status: not-a-number\nContent-Type: text/plain');
|
||||
expect(r.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('treats the Status header case-insensitively', () => {
|
||||
const r = parseCgiResponse('status: 500 Boom');
|
||||
expect(r.statusCode).toBe(500);
|
||||
expect(r.headers).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('splitCgiBuffer', () => {
|
||||
it('splits on a CRLF blank line and keeps the body as bytes', () => {
|
||||
const buf = Buffer.concat([
|
||||
Buffer.from('Status: 200 OK\r\nContent-Type: text/plain\r\n\r\n', 'utf8'),
|
||||
Buffer.from([0x00, 0x01, 0x02, 0xff]),
|
||||
]);
|
||||
const split = splitCgiBuffer(buf);
|
||||
expect(split).not.toBeNull();
|
||||
expect(split!.headerText).toBe('Status: 200 OK\r\nContent-Type: text/plain');
|
||||
expect(Array.from(split!.body)).toEqual([0x00, 0x01, 0x02, 0xff]);
|
||||
});
|
||||
|
||||
it('splits on a bare LF blank line', () => {
|
||||
const buf = Buffer.from('Content-Type: text/plain\n\nhello', 'utf8');
|
||||
const split = splitCgiBuffer(buf);
|
||||
expect(split).not.toBeNull();
|
||||
expect(split!.headerText).toBe('Content-Type: text/plain');
|
||||
expect(split!.body.toString('utf8')).toBe('hello');
|
||||
});
|
||||
|
||||
it('returns an empty body when nothing follows the separator', () => {
|
||||
const buf = Buffer.from('Content-Type: text/plain\r\n\r\n', 'utf8');
|
||||
const split = splitCgiBuffer(buf);
|
||||
expect(split).not.toBeNull();
|
||||
expect(split!.body.length).toBe(0);
|
||||
});
|
||||
|
||||
it('returns null when there is no blank-line separator yet', () => {
|
||||
const buf = Buffer.from('Content-Type: text/plain\r\nincomplete', 'utf8');
|
||||
expect(splitCgiBuffer(buf)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,419 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { spawn } from 'node:child_process';
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import { loadGitSync } from '../git-sync.loader';
|
||||
import { EnvironmentService } from '../../environment/environment.service';
|
||||
|
||||
/** The parsed first part of a CGI response: the HTTP status + header pairs. */
|
||||
export interface ParsedCgiResponse {
|
||||
statusCode: number;
|
||||
/** Lower-cased? No — keep header names verbatim as git http-backend emits. */
|
||||
headers: Array<[string, string]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the CGI header block emitted by `git http-backend` into an HTTP status
|
||||
* and a list of header pairs. The input is ONLY the header text (everything up
|
||||
* to, but not including, the blank-line separator) — the binary body is split
|
||||
* off by the caller on the raw Buffer (never stringified).
|
||||
*
|
||||
* CGI semantics (RFC 3875 §6): a `Status: <code> <reason>` header sets the HTTP
|
||||
* status (default 200 when absent). Every other header is forwarded verbatim.
|
||||
* Header lines are `Name: value`; a line without a ':' is ignored defensively.
|
||||
*
|
||||
* Pure + framework-free so it is unit-testable in isolation.
|
||||
*/
|
||||
export function parseCgiResponse(headerBlock: string): ParsedCgiResponse {
|
||||
let statusCode = 200;
|
||||
const headers: Array<[string, string]> = [];
|
||||
|
||||
// Header lines may be separated by CRLF or LF; split on either.
|
||||
const lines = headerBlock.split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
if (line.length === 0) continue;
|
||||
const sep = line.indexOf(':');
|
||||
if (sep === -1) continue; // not a header line — ignore defensively
|
||||
const name = line.slice(0, sep).trim();
|
||||
const value = line.slice(sep + 1).trim();
|
||||
if (name.toLowerCase() === 'status') {
|
||||
// `Status: 404 Not Found` — the leading integer is the HTTP status code.
|
||||
const code = parseInt(value, 10);
|
||||
if (Number.isFinite(code) && code >= 100 && code <= 599) {
|
||||
statusCode = code;
|
||||
}
|
||||
continue; // never forward the CGI Status header itself
|
||||
}
|
||||
headers.push([name, value]);
|
||||
}
|
||||
|
||||
return { statusCode, headers };
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a raw CGI response buffer at the first blank-line boundary
|
||||
* (`\r\n\r\n` or `\n\n`). Returns the header text and the remaining body bytes.
|
||||
* Returns null when no blank-line separator is present (a malformed response).
|
||||
*
|
||||
* Pure (operates on Buffers, never stringifies the body) so it is testable.
|
||||
*/
|
||||
export function splitCgiBuffer(
|
||||
buf: Buffer,
|
||||
): { headerText: string; body: Buffer } | null {
|
||||
// Prefer the CRLF separator; fall back to bare LF.
|
||||
let idx = buf.indexOf('\r\n\r\n');
|
||||
let sepLen = 4;
|
||||
if (idx === -1) {
|
||||
idx = buf.indexOf('\n\n');
|
||||
sepLen = 2;
|
||||
}
|
||||
if (idx === -1) return null;
|
||||
const headerText = buf.subarray(0, idx).toString('utf8');
|
||||
const body = buf.subarray(idx + sepLen);
|
||||
return { headerText, body };
|
||||
}
|
||||
|
||||
/** A parsed git smart-HTTP request, resolved by the controller/handler. */
|
||||
export interface GitHttpBackendRequest {
|
||||
/** The space id (the on-disk vault dir name == GIT_PROJECT_ROOT child). */
|
||||
spaceId: string;
|
||||
/** The subpath after `<spaceId>.git/`, e.g. `info/refs` or `git-receive-pack`. */
|
||||
subpath: string;
|
||||
/** REQUEST_METHOD — `GET` or `POST`. */
|
||||
method: string;
|
||||
/** Raw query string WITHOUT the leading '?', e.g. `service=git-receive-pack`. */
|
||||
queryString: string;
|
||||
/** Content-Type header value (may be empty for GET). */
|
||||
contentType: string;
|
||||
/** The Git-Protocol request header value, or undefined when absent. */
|
||||
gitProtocol?: string;
|
||||
/** Content-Encoding request header (e.g. `gzip`), or undefined when absent.
|
||||
* git gzips RPC bodies >1KiB; http-backend only inflates when HTTP_CONTENT_ENCODING
|
||||
* is present, so it MUST be forwarded or a non-trivial `git pull` fails with
|
||||
* `fatal: expected 'packfile'` (review #4). */
|
||||
contentEncoding?: string;
|
||||
/** Authenticated user email — used as REMOTE_USER (reflog identity). */
|
||||
remoteUser: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridges an HTTP git smart-protocol request to `git http-backend` (the CGI that
|
||||
* implements the entire smart-HTTP protocol: info/refs, upload-pack,
|
||||
* receive-pack, protocol v2, dumb fallback). We do NOT reimplement pkt-line.
|
||||
*
|
||||
* The Fastify reply is hijacked by the caller; this service streams the request
|
||||
* body to the child's stdin and writes the child's CGI response (status +
|
||||
* headers parsed from the leading header block, then the raw binary body) to the
|
||||
* Node response. Errors before any output produce a 500. Credentials are never
|
||||
* logged.
|
||||
*/
|
||||
/**
|
||||
* Build the `git http-backend` CGI environment overlay for one request (the
|
||||
* variables layered on top of `vaultGitEnv`'s cwd-isolated base). Pure so the
|
||||
* PATH_INFO / REMOTE_USER / conditional GIT_PROTOCOL wiring is unit-testable
|
||||
* without spawning git.
|
||||
*
|
||||
* PATH_INFO is the repo-relative CGI path. The vault is a NON-BARE working repo
|
||||
* on disk at `<dataDir>/<spaceId>` (the engine needs a working tree), so the
|
||||
* repo directory git http-backend must resolve is `<spaceId>` — NOT
|
||||
* `<spaceId>.git`. The URL carries the conventional `.git` suffix (stripped by
|
||||
* parseGitPath into `spaceId`); re-appending it here pointed the CGI at a
|
||||
* non-existent `<dataDir>/<spaceId>.git` and every fetch/push 404'd.
|
||||
*/
|
||||
export function buildGitBackendCgiEnv(
|
||||
parsed: GitHttpBackendRequest,
|
||||
projectRoot: string,
|
||||
): Record<string, string> {
|
||||
const cgiEnv: Record<string, string> = {
|
||||
GIT_PROJECT_ROOT: projectRoot,
|
||||
GIT_HTTP_EXPORT_ALL: '1', // authz is done by us; no git-daemon-export-ok file
|
||||
PATH_INFO: `/${parsed.spaceId}/${parsed.subpath}`,
|
||||
REQUEST_METHOD: parsed.method,
|
||||
QUERY_STRING: parsed.queryString,
|
||||
CONTENT_TYPE: parsed.contentType,
|
||||
REMOTE_USER: parsed.remoteUser,
|
||||
};
|
||||
// GIT_PROTOCOL is only set when the client sent the Git-Protocol header.
|
||||
if (parsed.gitProtocol) {
|
||||
cgiEnv.GIT_PROTOCOL = parsed.gitProtocol;
|
||||
}
|
||||
// HTTP_CONTENT_ENCODING must be forwarded so git http-backend inflates a
|
||||
// gzip'd RPC body (git compresses receive-pack/upload-pack bodies >1KiB).
|
||||
// Without it a non-trivial `git pull` negotiation fails deterministically with
|
||||
// `fatal: expected 'packfile'` (review #4). The body is piped to stdin as-is
|
||||
// (no upstream decompression), so the CGI must do the inflate.
|
||||
if (parsed.contentEncoding) {
|
||||
cgiEnv.HTTP_CONTENT_ENCODING = parsed.contentEncoding;
|
||||
}
|
||||
return cgiEnv;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class GitHttpBackendService {
|
||||
private readonly logger = new Logger(GitHttpBackendService.name);
|
||||
|
||||
constructor(private readonly environmentService: EnvironmentService) {}
|
||||
|
||||
/**
|
||||
* Spawn `git http-backend` for one request and bridge it to the raw Node
|
||||
* request/response. Resolves when the response has been fully written (the
|
||||
* child exited and its output was flushed), or after a 500 was sent on an
|
||||
* early failure. Never rejects — push ingestion relies on this resolving so
|
||||
* the lock-held cycle body can run afterwards.
|
||||
*
|
||||
* `signal` (optional) is the git-sync per-space lock's lost-lock abort signal.
|
||||
* A receive-pack writes `main`'s working tree, so if the lock lapses mid-push
|
||||
* (heartbeat CAS miss / Redis outage) the signal fires and we kill the child —
|
||||
* preventing it from continuing to write the working tree while another replica
|
||||
* may have taken over the lock and started a cycle (warning #3).
|
||||
*/
|
||||
async run(
|
||||
parsed: GitHttpBackendRequest,
|
||||
rawReq: IncomingMessage,
|
||||
rawRes: ServerResponse,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const { vaultGitEnv } = await loadGitSync();
|
||||
const projectRoot = this.environmentService.getGitSyncDataDir();
|
||||
// Build the CGI env from the engine's cwd-isolated base (strips GIT_DIR /
|
||||
// GIT_WORK_TREE), then layer the http-backend CGI variables. PATH is
|
||||
// preserved (vaultGitEnv already copies process.env, so PATH carries
|
||||
// through).
|
||||
const env = vaultGitEnv(buildGitBackendCgiEnv(parsed, projectRoot));
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
let settled = false;
|
||||
// Set once the child exists so the abort handler can target it.
|
||||
let onAbort: (() => void) | null = null;
|
||||
// The watchdog timer; cleared centrally in done() so EVERY settle path
|
||||
// (close, error, timeout, abort) tears it down exactly once.
|
||||
let watchdogTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const done = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (watchdogTimer) clearTimeout(watchdogTimer);
|
||||
// Detach the abort listener so a later lock loss does not fire into a
|
||||
// request that already finished.
|
||||
if (onAbort) {
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
onAbort = null;
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
|
||||
// Reject early if the lock was already lost before we even spawned: do not
|
||||
// start writing the working tree after a possible lock takeover.
|
||||
if (signal?.aborted) {
|
||||
if (!rawRes.headersSent) this.send500(rawRes, 'lock-lost');
|
||||
else
|
||||
try {
|
||||
rawRes.end();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return done();
|
||||
}
|
||||
|
||||
let child: ReturnType<typeof spawn>;
|
||||
try {
|
||||
child = spawn('git', ['http-backend'], { env });
|
||||
} catch (err) {
|
||||
this.send500(rawRes, 'spawn-failed', err);
|
||||
return done();
|
||||
}
|
||||
|
||||
// Lost-lock abort: the per-space lock lapsed mid-request. Kill the child so
|
||||
// a receive-pack stops writing `main`'s working tree before another replica
|
||||
// (which may now hold the lock) starts a cycle. Same kill+finish path the
|
||||
// watchdog uses (extracted into terminateChild).
|
||||
onAbort = () => {
|
||||
this.terminateChild(
|
||||
child,
|
||||
rawRes,
|
||||
headerParsed,
|
||||
'lock-lost',
|
||||
'git http-backend aborted (git-sync lock lost mid-request); killing child',
|
||||
done,
|
||||
);
|
||||
};
|
||||
signal?.addEventListener('abort', onAbort);
|
||||
|
||||
// Watchdog: a client that opens git-receive-pack and stalls keeps the
|
||||
// child alive forever, so run() never resolves and (because this runs
|
||||
// inside withSpaceLock) the per-space lock is held + heartbeat-refreshed
|
||||
// indefinitely. Bound the request: on expiry kill the child, send a clean
|
||||
// 500 if nothing was sent yet, and settle the promise. `.unref()` so the
|
||||
// timer never keeps the event loop alive; ALWAYS cleared in done().
|
||||
watchdogTimer = setTimeout(() => {
|
||||
this.terminateChild(
|
||||
child,
|
||||
rawRes,
|
||||
headerParsed,
|
||||
'timeout',
|
||||
`git http-backend timed out after ` +
|
||||
`${this.environmentService.getGitSyncBackendTimeoutMs()}ms; killing child`,
|
||||
done,
|
||||
);
|
||||
}, this.environmentService.getGitSyncBackendTimeoutMs());
|
||||
watchdogTimer.unref?.();
|
||||
|
||||
// Accumulate stdout until we have the full CGI header block, then write the
|
||||
// parsed status/headers and start streaming the remaining body bytes.
|
||||
let headerParsed = false;
|
||||
let pending: Buffer = Buffer.alloc(0);
|
||||
|
||||
const flushHeadersAndBody = (chunk: Buffer): void => {
|
||||
pending = Buffer.concat([pending, chunk]);
|
||||
const split = splitCgiBuffer(pending);
|
||||
if (!split) return; // header block not complete yet
|
||||
headerParsed = true;
|
||||
const { statusCode, headers } = parseCgiResponse(split.headerText);
|
||||
rawRes.statusCode = statusCode;
|
||||
for (const [name, value] of headers) {
|
||||
rawRes.setHeader(name, value);
|
||||
}
|
||||
if (split.body.length > 0) rawRes.write(split.body);
|
||||
pending = Buffer.alloc(0);
|
||||
};
|
||||
|
||||
child.stdout?.on('data', (chunk: Buffer) => {
|
||||
if (headerParsed) {
|
||||
rawRes.write(chunk);
|
||||
} else {
|
||||
flushHeadersAndBody(chunk);
|
||||
}
|
||||
});
|
||||
// A stream 'error' (e.g. EPIPE when the client aborts mid-response) is an
|
||||
// EventEmitter 'error' with no listener -> Node rethrows it as an uncaught
|
||||
// exception and crashes the process. Swallow + log it (never echo to the
|
||||
// client); child.on('close')/'error' below drives the actual cleanup.
|
||||
child.stdout?.on('error', (err) => {
|
||||
this.logger.warn(`git http-backend stdout stream error: ${err.message}`);
|
||||
});
|
||||
|
||||
let stderr = '';
|
||||
child.stderr?.on('data', (chunk: Buffer) => {
|
||||
// Capture for diagnostics; never echo to the client. http-backend writes
|
||||
// CGI errors here. We do NOT log the request body or any credentials.
|
||||
if (stderr.length < 8192) stderr += chunk.toString('utf8');
|
||||
});
|
||||
child.stderr?.on('error', (err) => {
|
||||
this.logger.warn(`git http-backend stderr stream error: ${err.message}`);
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
// The watchdog timer is cleared centrally in done().
|
||||
if (!headerParsed && !rawRes.headersSent) {
|
||||
this.send500(rawRes, 'child-error', err);
|
||||
} else {
|
||||
// Output already started — we can only terminate the stream.
|
||||
try {
|
||||
rawRes.end();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
done();
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
// The watchdog timer is cleared centrally in done().
|
||||
if (!headerParsed && !rawRes.headersSent) {
|
||||
// The child exited before emitting a complete CGI header block.
|
||||
this.logger.error(
|
||||
`git http-backend produced no valid response (exit ${code}) for ` +
|
||||
`space; stderr: ${stderr.trim().slice(0, 500)}`,
|
||||
);
|
||||
this.send500(rawRes, 'no-output');
|
||||
} else {
|
||||
try {
|
||||
rawRes.end();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
done();
|
||||
});
|
||||
|
||||
// Pipe the request body to the child's stdin. For GET there is no body, so
|
||||
// end stdin immediately. We pipe `rawReq` (the raw Node stream) directly so
|
||||
// large pushes are streamed, not buffered.
|
||||
if (parsed.method === 'POST') {
|
||||
rawReq.pipe(child.stdin!);
|
||||
rawReq.on('error', () => {
|
||||
try {
|
||||
child.stdin?.end();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
} else {
|
||||
child.stdin?.end();
|
||||
}
|
||||
// Swallow EPIPE etc. on the child's stdin so a client disconnect does not
|
||||
// crash the process.
|
||||
child.stdin?.on('error', () => {
|
||||
/* ignore broken-pipe on stdin */
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill the child (SIGTERM, then SIGKILL after a grace period if it ignores the
|
||||
* term) and finish the HTTP response cleanly, then settle. Shared by the two
|
||||
* forced-termination paths — the watchdog timeout and the lost-lock abort —
|
||||
* which differ ONLY by the log line and the send500 `reason`. If no response
|
||||
* has started a clean 500 is sent; otherwise the in-flight stream is just
|
||||
* ended. Never throws (a thrown kill/end would crash the request).
|
||||
*/
|
||||
private terminateChild(
|
||||
child: ReturnType<typeof spawn>,
|
||||
rawRes: ServerResponse,
|
||||
responseStarted: boolean,
|
||||
send500Reason: string,
|
||||
logMessage: string,
|
||||
done: () => void,
|
||||
): void {
|
||||
this.logger.warn(logMessage);
|
||||
try {
|
||||
child.kill('SIGTERM');
|
||||
// Escalate to SIGKILL shortly after in case SIGTERM is ignored.
|
||||
const sigkill = setTimeout(() => {
|
||||
try {
|
||||
child.kill('SIGKILL');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, 2000);
|
||||
sigkill.unref?.();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (!responseStarted && !rawRes.headersSent) {
|
||||
this.send500(rawRes, send500Reason);
|
||||
} else {
|
||||
try {
|
||||
rawRes.end();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
done();
|
||||
}
|
||||
|
||||
/** Send a clean 500 without leaking credentials or the request body. */
|
||||
private send500(rawRes: ServerResponse, reason: string, err?: unknown): void {
|
||||
const message = err instanceof Error ? err.message : undefined;
|
||||
this.logger.error(
|
||||
`git http-backend failed (${reason})${message ? `: ${message}` : ''}`,
|
||||
);
|
||||
try {
|
||||
if (!rawRes.headersSent) {
|
||||
rawRes.statusCode = 500;
|
||||
rawRes.setHeader('Content-Type', 'text/plain');
|
||||
}
|
||||
rawRes.end('Internal server error');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
// Unit tests for the pure /git smart-HTTP helpers: URL parsing, service->kind
|
||||
// mapping (read vs write), and the gating/auth decision precedence.
|
||||
import {
|
||||
decideGitHttpGate,
|
||||
parseGitPath,
|
||||
resolveServiceKind,
|
||||
} from './git-http.helpers';
|
||||
|
||||
describe('parseGitPath', () => {
|
||||
it('parses spaceId + subpath, stripping the trailing .git', () => {
|
||||
expect(parseGitPath('abc123.git/info/refs')).toEqual({
|
||||
spaceId: 'abc123',
|
||||
subpath: 'info/refs',
|
||||
});
|
||||
});
|
||||
|
||||
it('tolerates a leading slash', () => {
|
||||
expect(parseGitPath('/abc.git/git-receive-pack')).toEqual({
|
||||
spaceId: 'abc',
|
||||
subpath: 'git-receive-pack',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an empty subpath for the bare repo root', () => {
|
||||
expect(parseGitPath('abc.git')).toEqual({ spaceId: 'abc', subpath: '' });
|
||||
});
|
||||
|
||||
it('returns null when the first segment lacks .git', () => {
|
||||
expect(parseGitPath('abc/info/refs')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null on an empty space id', () => {
|
||||
expect(parseGitPath('.git/info/refs')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects path traversal', () => {
|
||||
expect(parseGitPath('abc.git/../../etc/passwd')).toBeNull();
|
||||
expect(parseGitPath('..git/x')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects percent-encoded dot/slash traversal in the subpath (case-insensitive)', () => {
|
||||
expect(parseGitPath('abc.git/%2e%2e%2fetc/passwd')).toBeNull();
|
||||
expect(parseGitPath('abc.git/%2E%2E/secret')).toBeNull();
|
||||
expect(parseGitPath('abc.git/objects/%2fabsolute')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveServiceKind', () => {
|
||||
it('GET info/refs?service=git-upload-pack -> read', () => {
|
||||
expect(
|
||||
resolveServiceKind({
|
||||
method: 'GET',
|
||||
subpath: 'info/refs',
|
||||
service: 'git-upload-pack',
|
||||
}),
|
||||
).toBe('read');
|
||||
});
|
||||
|
||||
it('GET info/refs?service=git-receive-pack -> write', () => {
|
||||
expect(
|
||||
resolveServiceKind({
|
||||
method: 'GET',
|
||||
subpath: 'info/refs',
|
||||
service: 'git-receive-pack',
|
||||
}),
|
||||
).toBe('write');
|
||||
});
|
||||
|
||||
it('POST git-upload-pack -> read', () => {
|
||||
expect(
|
||||
resolveServiceKind({ method: 'POST', subpath: 'git-upload-pack' }),
|
||||
).toBe('read');
|
||||
});
|
||||
|
||||
it('POST git-receive-pack -> write', () => {
|
||||
expect(
|
||||
resolveServiceKind({ method: 'POST', subpath: 'git-receive-pack' }),
|
||||
).toBe('write');
|
||||
});
|
||||
|
||||
it('a dumb-protocol GET (HEAD / objects) -> read', () => {
|
||||
expect(resolveServiceKind({ method: 'GET', subpath: 'HEAD' })).toBe('read');
|
||||
expect(
|
||||
resolveServiceKind({ method: 'GET', subpath: 'objects/12/abcdef' }),
|
||||
).toBe('read');
|
||||
});
|
||||
|
||||
it('info/refs with no/unknown service -> read (dumb discovery)', () => {
|
||||
expect(resolveServiceKind({ method: 'GET', subpath: 'info/refs' })).toBe(
|
||||
'read',
|
||||
);
|
||||
});
|
||||
|
||||
it('an unknown POST endpoint -> null', () => {
|
||||
expect(resolveServiceKind({ method: 'POST', subpath: 'whatever' })).toBeNull();
|
||||
});
|
||||
|
||||
it('an unsupported method -> null', () => {
|
||||
expect(
|
||||
resolveServiceKind({ method: 'DELETE', subpath: 'git-receive-pack' }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('decideGitHttpGate', () => {
|
||||
const base = {
|
||||
hasCredentials: true,
|
||||
credentialsValid: true,
|
||||
serviceKind: 'read' as const,
|
||||
gitSyncEnabled: true,
|
||||
gitHttpEnabled: true,
|
||||
spaceExists: true,
|
||||
spaceGitSyncEnabled: true,
|
||||
userIsSpaceMember: true,
|
||||
permissionGranted: true,
|
||||
};
|
||||
|
||||
it('proceeds on the happy path', () => {
|
||||
expect(decideGitHttpGate(base)).toEqual({ kind: 'proceed' });
|
||||
});
|
||||
|
||||
it('401 when credentials are missing (even for a valid space)', () => {
|
||||
expect(
|
||||
decideGitHttpGate({ ...base, hasCredentials: false }),
|
||||
).toEqual({ kind: 'unauthorized' });
|
||||
});
|
||||
|
||||
it('401 when credentials are present but invalid', () => {
|
||||
expect(
|
||||
decideGitHttpGate({ ...base, credentialsValid: false }),
|
||||
).toEqual({ kind: 'unauthorized' });
|
||||
});
|
||||
|
||||
it('400 on an unparseable service kind', () => {
|
||||
expect(decideGitHttpGate({ ...base, serviceKind: null })).toEqual({
|
||||
kind: 'bad-request',
|
||||
});
|
||||
});
|
||||
|
||||
it('404 when the space is not git-sync-enabled (never reveals existence)', () => {
|
||||
expect(
|
||||
decideGitHttpGate({ ...base, spaceGitSyncEnabled: false }),
|
||||
).toEqual({ kind: 'not-found' });
|
||||
});
|
||||
|
||||
it('404 when the space does not exist', () => {
|
||||
expect(decideGitHttpGate({ ...base, spaceExists: false })).toEqual({
|
||||
kind: 'not-found',
|
||||
});
|
||||
});
|
||||
|
||||
it('404 when git-sync is globally disabled', () => {
|
||||
expect(decideGitHttpGate({ ...base, gitSyncEnabled: false })).toEqual({
|
||||
kind: 'not-found',
|
||||
});
|
||||
});
|
||||
|
||||
it('404 when the git-http host is disabled', () => {
|
||||
expect(decideGitHttpGate({ ...base, gitHttpEnabled: false })).toEqual({
|
||||
kind: 'not-found',
|
||||
});
|
||||
});
|
||||
|
||||
it('403 when a MEMBER lacks the required permission (reader on write)', () => {
|
||||
// A member of the space (existence already known to them) who lacks the role:
|
||||
// 403 leaks nothing new.
|
||||
expect(
|
||||
decideGitHttpGate({
|
||||
...base,
|
||||
serviceKind: 'write',
|
||||
userIsSpaceMember: true,
|
||||
permissionGranted: false,
|
||||
}),
|
||||
).toEqual({ kind: 'forbidden' });
|
||||
});
|
||||
|
||||
it('404 (NOT 403) when an authenticated NON-member hits a git-sync space', () => {
|
||||
// SECURITY: a non-member must be indistinguishable from a missing/disabled
|
||||
// space. If this returned 403, the 403↔404 difference would let any
|
||||
// authenticated workspace user brute-force slugs to discover which spaces
|
||||
// exist and which have git-sync enabled.
|
||||
expect(
|
||||
decideGitHttpGate({
|
||||
...base,
|
||||
serviceKind: 'write',
|
||||
userIsSpaceMember: false,
|
||||
permissionGranted: false,
|
||||
}),
|
||||
).toEqual({ kind: 'not-found' });
|
||||
// Same for a read by a non-member.
|
||||
expect(
|
||||
decideGitHttpGate({
|
||||
...base,
|
||||
serviceKind: 'read',
|
||||
userIsSpaceMember: false,
|
||||
permissionGranted: false,
|
||||
}),
|
||||
).toEqual({ kind: 'not-found' });
|
||||
});
|
||||
|
||||
it('still 401 (not 404) for missing creds against a disabled space', () => {
|
||||
// Anonymous probe must always get 401 first, regardless of space state.
|
||||
expect(
|
||||
decideGitHttpGate({
|
||||
...base,
|
||||
hasCredentials: false,
|
||||
spaceGitSyncEnabled: false,
|
||||
}),
|
||||
).toEqual({ kind: 'unauthorized' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
// Pure, framework-free helpers for the /git smart-HTTP host. They carry no Nest
|
||||
// / DI / concrete-service imports so the request parsing and the auth/authz
|
||||
// gating DECISION can be unit-tested in isolation, and nothing here ever logs a
|
||||
// password or the Authorization header.
|
||||
|
||||
/** The git operation a request maps to: a read (fetch/clone) or a write (push). */
|
||||
export type GitHttpServiceKind = 'read' | 'write';
|
||||
|
||||
/** A parsed `/git/<spaceId>.git/<subpath>` URL. */
|
||||
export interface ParsedGitPath {
|
||||
spaceId: string;
|
||||
/** The subpath after `<spaceId>.git/` (no leading slash), e.g. `info/refs`. */
|
||||
subpath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the `<rest>` of a `/git/<rest>` URL path (no query string) into the
|
||||
* space id and the repo-relative subpath. The space id is the first path
|
||||
* segment with its trailing `.git` stripped. Returns null when the shape does
|
||||
* not match (missing `.git`, empty space id, traversal attempt).
|
||||
*
|
||||
* `rest` MUST already be URL-path-decoded of its query string by the caller
|
||||
* (pass the pathname only). We reject `..` segments defensively even though
|
||||
* http-backend resolves PATH_INFO against GIT_PROJECT_ROOT.
|
||||
*/
|
||||
export function parseGitPath(rest: string): ParsedGitPath | null {
|
||||
// Strip a leading slash, then take the first segment as `<spaceId>.git`.
|
||||
const clean = rest.replace(/^\/+/, '');
|
||||
const slash = clean.indexOf('/');
|
||||
const first = slash === -1 ? clean : clean.slice(0, slash);
|
||||
const subpath = slash === -1 ? '' : clean.slice(slash + 1);
|
||||
|
||||
if (!first.endsWith('.git')) return null;
|
||||
const spaceId = first.slice(0, -'.git'.length);
|
||||
if (!spaceId) return null;
|
||||
|
||||
// Reject path traversal / degenerate ids in either component.
|
||||
if (
|
||||
spaceId === '.' ||
|
||||
spaceId.includes('..') ||
|
||||
spaceId.includes('/') ||
|
||||
subpath.split('/').some((seg) => seg === '..')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Defense-in-depth: reject percent-encoded dot/slash traversal (`%2e`, `%2f`,
|
||||
// case-insensitive) in the subpath BEFORE it is used to build PATH_INFO — a
|
||||
// decoder downstream could otherwise turn `%2e%2e%2f` back into `../`.
|
||||
if (/%2e|%2f/i.test(subpath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { spaceId, subpath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a parsed git request (method + subpath + query) to the required operation
|
||||
* kind. The smart-HTTP shapes:
|
||||
* - GET info/refs?service=git-upload-pack -> read (fetch)
|
||||
* - GET info/refs?service=git-receive-pack -> write (push)
|
||||
* - POST git-upload-pack -> read (fetch)
|
||||
* - POST git-receive-pack -> write (push)
|
||||
* - any other dumb-protocol GET (HEAD, objects/…) -> read
|
||||
* Returns null for an unsupported shape (e.g. a POST that is neither pack
|
||||
* endpoint) so the caller can 403/404 rather than guess.
|
||||
*/
|
||||
export function resolveServiceKind(input: {
|
||||
method: string;
|
||||
subpath: string;
|
||||
service?: string;
|
||||
}): GitHttpServiceKind | null {
|
||||
const method = input.method.toUpperCase();
|
||||
const subpath = input.subpath;
|
||||
|
||||
if (method === 'GET') {
|
||||
if (subpath === 'info/refs') {
|
||||
if (input.service === 'git-receive-pack') return 'write';
|
||||
if (input.service === 'git-upload-pack') return 'read';
|
||||
// info/refs without a known service: dumb-protocol discovery — read.
|
||||
return 'read';
|
||||
}
|
||||
// Dumb-protocol object/ref fetches (HEAD, objects/…) are reads.
|
||||
return 'read';
|
||||
}
|
||||
|
||||
if (method === 'POST') {
|
||||
if (subpath === 'git-receive-pack') return 'write';
|
||||
if (subpath === 'git-upload-pack') return 'read';
|
||||
return null; // unknown POST endpoint
|
||||
}
|
||||
|
||||
return null; // unsupported method
|
||||
}
|
||||
|
||||
/** The outcome of the gating/auth decision the request handler must enforce. */
|
||||
export type GitHttpGateDecision =
|
||||
| { kind: 'unauthorized' } // 401 + WWW-Authenticate (missing/invalid creds)
|
||||
| { kind: 'not-found' } // 404 (space hidden / sync or http disabled)
|
||||
| { kind: 'forbidden' } // 403 (authenticated but lacks the permission)
|
||||
| { kind: 'bad-request' } // 400 (unparseable git request shape)
|
||||
| { kind: 'proceed' }; // run http-backend
|
||||
|
||||
/**
|
||||
* Pure gating decision, mirroring the handler precedence so it can be unit
|
||||
* tested without the DB / CASL graph. Inputs are the already-resolved booleans
|
||||
* the handler computes from EnvironmentService / SpaceRepo / SpaceAbilityFactory.
|
||||
*
|
||||
* Precedence (matches the spec):
|
||||
* 1. no/invalid Basic credentials -> 401 (regardless of space).
|
||||
* 2. credentials present but invalid -> 401.
|
||||
* 3. unparseable git request shape -> 400.
|
||||
* 4. git-sync globally disabled, or git-http disabled, or the space is missing
|
||||
* / not git-sync-enabled, OR the authenticated user is NOT a member of the
|
||||
* space (has no role at all) -> 404 (never reveal existence).
|
||||
* 5. a MEMBER of the space who lacks the required perm (e.g. a reader trying to
|
||||
* push) -> 403.
|
||||
* 6. otherwise -> proceed.
|
||||
*
|
||||
* Note (4) is checked AFTER (1)/(2): an anonymous probe always gets 401 first;
|
||||
* an authenticated user hitting a hidden/disabled space — OR a space they are not
|
||||
* a member of — gets 404 (not 403). Folding non-membership into the 404 branch is
|
||||
* a SECURITY requirement: if a non-member got 403 here (as a "permission denied")
|
||||
* while a non-existent / sync-disabled space got 404, the 403↔404 difference would
|
||||
* let any authenticated workspace user brute-force slugs to discover which spaces
|
||||
* exist and which have git-sync enabled — including spaces they cannot see. 403 is
|
||||
* therefore reserved for the one case where existence is ALREADY known to the
|
||||
* caller because they ARE a member (so it leaks nothing new): a member without the
|
||||
* required role. `userIsSpaceMember` is the resolved "the user has SOME role in
|
||||
* this space" boolean (false when SpaceAbilityFactory.createForUser throws
|
||||
* NotFound / the user has no role).
|
||||
*/
|
||||
export function decideGitHttpGate(input: {
|
||||
hasCredentials: boolean;
|
||||
credentialsValid: boolean;
|
||||
serviceKind: GitHttpServiceKind | null;
|
||||
gitSyncEnabled: boolean;
|
||||
gitHttpEnabled: boolean;
|
||||
spaceExists: boolean;
|
||||
spaceGitSyncEnabled: boolean;
|
||||
/** The user has SOME role in the space (false = non-member -> 404, not 403). */
|
||||
userIsSpaceMember: boolean;
|
||||
permissionGranted: boolean;
|
||||
}): GitHttpGateDecision {
|
||||
if (!input.hasCredentials) return { kind: 'unauthorized' };
|
||||
if (!input.credentialsValid) return { kind: 'unauthorized' };
|
||||
if (input.serviceKind === null) return { kind: 'bad-request' };
|
||||
|
||||
if (
|
||||
!input.gitSyncEnabled ||
|
||||
!input.gitHttpEnabled ||
|
||||
!input.spaceExists ||
|
||||
!input.spaceGitSyncEnabled ||
|
||||
// A non-member must be indistinguishable from a missing/disabled space: 404,
|
||||
// never 403 (otherwise the 403↔404 split leaks space existence — see above).
|
||||
!input.userIsSpaceMember
|
||||
) {
|
||||
return { kind: 'not-found' };
|
||||
}
|
||||
|
||||
if (!input.permissionGranted) return { kind: 'forbidden' };
|
||||
|
||||
return { kind: 'proceed' };
|
||||
}
|
||||
@@ -0,0 +1,643 @@
|
||||
// Unit tests for GitHttpService — the /git smart-HTTP handler. Everything it
|
||||
// depends on (backend, auth, repos, ability factory, env, orchestrator) is
|
||||
// mocked so we exercise ONLY the handler wiring: workspace resolution (which is
|
||||
// done HERE, not by DomainMiddleware — see FIX 1), the auth/gating precedence,
|
||||
// the read-vs-write dispatch, and that a fetch does NOT take the lock.
|
||||
//
|
||||
// These tests deliberately NEVER set `req.raw.workspaceId`: the workspace must
|
||||
// come from WorkspaceRepo. If the handler regressed to reading
|
||||
// `req.raw.workspaceId`, the happy-path fetch test below would fail (the repo
|
||||
// would not be consulted and the request would 401).
|
||||
import {
|
||||
Logger,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { CREDENTIALS_MISMATCH_MESSAGE } from '../../../core/auth/auth.constants';
|
||||
import {
|
||||
SpaceCaslAction,
|
||||
SpaceCaslSubject,
|
||||
} from '../../../core/casl/interfaces/space-ability.type';
|
||||
import { GitHttpService } from './git-http.service';
|
||||
import { GitSyncLockHeldError } from '../services/git-sync.orchestrator';
|
||||
|
||||
type AnyMock = jest.Mock;
|
||||
|
||||
interface BuildOptions {
|
||||
selfHosted?: boolean;
|
||||
gitSyncEnabled?: boolean;
|
||||
gitHttpEnabled?: boolean;
|
||||
/** What workspaceRepo.findFirst() returns (self-hosted resolution). */
|
||||
workspace?: { id: string } | null;
|
||||
/** What spaceRepo.findById() returns. */
|
||||
space?: { id: string; settings?: unknown } | null;
|
||||
/** Result of authService.verifyUserCredentials: a user, or throw 401. */
|
||||
user?: { id: string; email: string } | null;
|
||||
/** Whether the created ability grants the requested action. */
|
||||
abilityCan?: boolean;
|
||||
}
|
||||
|
||||
interface Built {
|
||||
service: GitHttpService;
|
||||
env: Record<string, AnyMock>;
|
||||
authService: { verifyUserCredentials: AnyMock };
|
||||
spaceRepo: { findById: AnyMock };
|
||||
workspaceRepo: { findFirst: AnyMock; findByHostname: AnyMock };
|
||||
abilityFactory: { createForUser: AnyMock };
|
||||
abilityCan: AnyMock;
|
||||
vaultRegistry: { ensureServable: AnyMock };
|
||||
orchestrator: {
|
||||
ingestExternalPush: AnyMock;
|
||||
serveReadAdvertisement: AnyMock;
|
||||
};
|
||||
backend: { run: AnyMock };
|
||||
}
|
||||
|
||||
function build(opts: BuildOptions = {}): Built {
|
||||
const {
|
||||
selfHosted = true,
|
||||
gitSyncEnabled = true,
|
||||
gitHttpEnabled = true,
|
||||
workspace = { id: 'ws-1' },
|
||||
space = { id: 'space-1', settings: { gitSync: { enabled: true } } },
|
||||
user = { id: 'user-1', email: 'dev@example.com' },
|
||||
abilityCan = true,
|
||||
} = opts;
|
||||
|
||||
const env: Record<string, AnyMock> = {
|
||||
isSelfHosted: jest.fn(() => selfHosted),
|
||||
isCloud: jest.fn(() => !selfHosted),
|
||||
isGitSyncEnabled: jest.fn(() => gitSyncEnabled),
|
||||
isGitSyncHttpEnabled: jest.fn(() => gitHttpEnabled),
|
||||
};
|
||||
|
||||
const authService = {
|
||||
verifyUserCredentials: jest.fn(async () => {
|
||||
if (!user) throw new UnauthorizedException();
|
||||
return user;
|
||||
}),
|
||||
};
|
||||
|
||||
const spaceRepo = { findById: jest.fn(async () => space) };
|
||||
|
||||
const workspaceRepo = {
|
||||
findFirst: jest.fn(async () => workspace),
|
||||
findByHostname: jest.fn(async () => workspace),
|
||||
};
|
||||
|
||||
const abilityCanMock = jest.fn(() => abilityCan);
|
||||
const abilityFactory = {
|
||||
createForUser: jest.fn(async () => ({ can: abilityCanMock })),
|
||||
};
|
||||
|
||||
const vaultRegistry = { ensureServable: jest.fn(async () => undefined) };
|
||||
const orchestrator = {
|
||||
ingestExternalPush: jest.fn(async () => undefined),
|
||||
// The read-advertisement wrapper pins HEAD under the lock then serves; the
|
||||
// mock just runs the serve callback so the read path still hits backend.run.
|
||||
serveReadAdvertisement: jest.fn(
|
||||
async (_spaceId: string, serve: () => Promise<void>) => serve(),
|
||||
),
|
||||
};
|
||||
const backend = { run: jest.fn(async () => undefined) };
|
||||
|
||||
const service = new GitHttpService(
|
||||
env as any,
|
||||
authService as any,
|
||||
spaceRepo as any,
|
||||
workspaceRepo as any,
|
||||
abilityFactory as any,
|
||||
vaultRegistry as any,
|
||||
orchestrator as any,
|
||||
backend as any,
|
||||
);
|
||||
|
||||
return {
|
||||
service,
|
||||
env,
|
||||
authService,
|
||||
spaceRepo,
|
||||
workspaceRepo,
|
||||
abilityFactory,
|
||||
abilityCan: abilityCanMock,
|
||||
vaultRegistry,
|
||||
orchestrator,
|
||||
backend,
|
||||
};
|
||||
}
|
||||
|
||||
/** A fake Fastify reply capturing the terminal status/headers/body. */
|
||||
function fakeReply() {
|
||||
const state: {
|
||||
statusCode?: number;
|
||||
headers: Record<string, string>;
|
||||
body?: unknown;
|
||||
hijacked: boolean;
|
||||
sent: boolean;
|
||||
} = { headers: {}, hijacked: false, sent: false };
|
||||
|
||||
const reply: any = {
|
||||
header(name: string, value: string) {
|
||||
state.headers[name] = value;
|
||||
return reply;
|
||||
},
|
||||
status(code: number) {
|
||||
state.statusCode = code;
|
||||
return reply;
|
||||
},
|
||||
send(body: unknown) {
|
||||
state.body = body;
|
||||
state.sent = true;
|
||||
return reply;
|
||||
},
|
||||
hijack() {
|
||||
state.hijacked = true;
|
||||
},
|
||||
get sent() {
|
||||
return state.sent;
|
||||
},
|
||||
// The raw Node response — only touched on the streaming/error paths.
|
||||
raw: {
|
||||
headersSent: false,
|
||||
writableEnded: false,
|
||||
statusCode: 200,
|
||||
setHeader: jest.fn(),
|
||||
end: jest.fn(),
|
||||
},
|
||||
};
|
||||
return { reply, state };
|
||||
}
|
||||
|
||||
/** A fake Fastify request for a /git smart-HTTP call. */
|
||||
function fakeRequest(opts: {
|
||||
url: string;
|
||||
method?: string;
|
||||
authorization?: string;
|
||||
host?: string;
|
||||
}) {
|
||||
const { url, method = 'GET', authorization, host = 'docs.example.com' } = opts;
|
||||
const headers: Record<string, string> = { host };
|
||||
if (authorization) headers['authorization'] = authorization;
|
||||
// query is parsed by Fastify; mirror the `service` param when present.
|
||||
const qIdx = url.indexOf('?');
|
||||
const query: Record<string, string> = {};
|
||||
if (qIdx !== -1) {
|
||||
for (const pair of url.slice(qIdx + 1).split('&')) {
|
||||
const [k, v] = pair.split('=');
|
||||
if (k) query[k] = v ?? '';
|
||||
}
|
||||
}
|
||||
return {
|
||||
url,
|
||||
method,
|
||||
headers,
|
||||
query,
|
||||
// raw is intentionally WITHOUT workspaceId — the handler must resolve it
|
||||
// itself via WorkspaceRepo (a regression to req.raw.workspaceId would 401).
|
||||
raw: {},
|
||||
} as any;
|
||||
}
|
||||
|
||||
function basic(email: string, password: string): string {
|
||||
return 'Basic ' + Buffer.from(`${email}:${password}`).toString('base64');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// Silence the handler's logger.warn/error in negative-path tests.
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
describe('GitHttpService.handle', () => {
|
||||
it('fetch with valid creds resolves the workspace via the repo and dispatches WITHOUT the lock', async () => {
|
||||
const built = build({ selfHosted: true });
|
||||
const { reply, state } = fakeReply();
|
||||
const req = fakeRequest({
|
||||
url: '/git/space-1.git/info/refs?service=git-upload-pack',
|
||||
method: 'GET',
|
||||
authorization: basic('dev@example.com', 'pw'),
|
||||
});
|
||||
|
||||
await built.service.handle(req, reply);
|
||||
|
||||
// The workspace came from WorkspaceRepo, NOT req.raw.workspaceId.
|
||||
expect(built.workspaceRepo.findFirst).toHaveBeenCalledTimes(1);
|
||||
expect(built.authService.verifyUserCredentials).toHaveBeenCalledWith(
|
||||
{ email: 'dev@example.com', password: 'pw' },
|
||||
'ws-1',
|
||||
);
|
||||
expect(built.spaceRepo.findById).toHaveBeenCalledWith('space-1', 'ws-1');
|
||||
// Read ability was evaluated.
|
||||
expect(built.abilityCan).toHaveBeenCalledWith(
|
||||
SpaceCaslAction.Read,
|
||||
SpaceCaslSubject.Page,
|
||||
);
|
||||
// It proceeded: vault prepared, reply hijacked, backend ran directly.
|
||||
expect(built.vaultRegistry.ensureServable).toHaveBeenCalledWith('space-1');
|
||||
expect(state.hijacked).toBe(true);
|
||||
expect(built.backend.run).toHaveBeenCalledTimes(1);
|
||||
// A fetch must NOT take the push lock.
|
||||
expect(built.orchestrator.ingestExternalPush).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('upload-pack ref advertisement is served HEAD-pinned via serveReadAdvertisement (bug #3)', async () => {
|
||||
// GET info/refs?service=git-upload-pack carries the HEAD symref a clone reads
|
||||
// for its default branch, so it must be served with HEAD pinned to `main`
|
||||
// (under the lock) — not streamed raw — or a clone racing a mid-pull cycle
|
||||
// would default to the read-only `docmost` mirror.
|
||||
const built = build({ abilityCan: true });
|
||||
const { reply } = fakeReply();
|
||||
const req = fakeRequest({
|
||||
url: '/git/space-1.git/info/refs?service=git-upload-pack',
|
||||
method: 'GET',
|
||||
authorization: basic('dev@example.com', 'pw'),
|
||||
});
|
||||
|
||||
await built.service.handle(req, reply);
|
||||
|
||||
expect(built.orchestrator.serveReadAdvertisement).toHaveBeenCalledTimes(1);
|
||||
expect(built.orchestrator.serveReadAdvertisement.mock.calls[0][0]).toBe(
|
||||
'space-1',
|
||||
);
|
||||
// The wrapper still streams the backend (the mock runs the serve callback).
|
||||
expect(built.backend.run).toHaveBeenCalledTimes(1);
|
||||
expect(built.orchestrator.ingestExternalPush).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a POST git-upload-pack pack fetch streams directly (no HEAD-pin needed, resolved by SHA)', async () => {
|
||||
// The pack negotiation is object-SHA based; only the ref advertisement carries
|
||||
// the HEAD symref, so the pack POST streams the backend directly (no lock).
|
||||
const built = build({ abilityCan: true });
|
||||
const { reply } = fakeReply();
|
||||
const req = fakeRequest({
|
||||
url: '/git/space-1.git/git-upload-pack',
|
||||
method: 'POST',
|
||||
authorization: basic('dev@example.com', 'pw'),
|
||||
});
|
||||
|
||||
await built.service.handle(req, reply);
|
||||
|
||||
expect(built.orchestrator.serveReadAdvertisement).not.toHaveBeenCalled();
|
||||
expect(built.backend.run).toHaveBeenCalledTimes(1);
|
||||
expect(built.orchestrator.ingestExternalPush).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cloud deployment resolves the workspace by the host subdomain', async () => {
|
||||
const built = build({ selfHosted: false });
|
||||
const { reply } = fakeReply();
|
||||
const req = fakeRequest({
|
||||
url: '/git/space-1.git/info/refs?service=git-upload-pack',
|
||||
method: 'GET',
|
||||
authorization: basic('dev@example.com', 'pw'),
|
||||
host: 'acme.example.com',
|
||||
});
|
||||
|
||||
await built.service.handle(req, reply);
|
||||
|
||||
expect(built.workspaceRepo.findByHostname).toHaveBeenCalledWith('acme');
|
||||
expect(built.workspaceRepo.findFirst).not.toHaveBeenCalled();
|
||||
expect(built.backend.run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('missing Basic credentials -> 401 with WWW-Authenticate', async () => {
|
||||
const built = build();
|
||||
const { reply, state } = fakeReply();
|
||||
const req = fakeRequest({
|
||||
url: '/git/space-1.git/info/refs?service=git-upload-pack',
|
||||
method: 'GET',
|
||||
// no Authorization header
|
||||
});
|
||||
|
||||
await built.service.handle(req, reply);
|
||||
|
||||
expect(state.statusCode).toBe(401);
|
||||
expect(state.headers['WWW-Authenticate']).toBe('Basic realm="gitmost"');
|
||||
expect(built.backend.run).not.toHaveBeenCalled();
|
||||
expect(built.authService.verifyUserCredentials).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('invalid Basic credentials -> 401 with WWW-Authenticate', async () => {
|
||||
const built = build({ user: null }); // verifyUserCredentials throws 401
|
||||
const { reply, state } = fakeReply();
|
||||
const req = fakeRequest({
|
||||
url: '/git/space-1.git/info/refs?service=git-upload-pack',
|
||||
method: 'GET',
|
||||
authorization: basic('dev@example.com', 'wrong'),
|
||||
});
|
||||
|
||||
await built.service.handle(req, reply);
|
||||
|
||||
expect(state.statusCode).toBe(401);
|
||||
expect(state.headers['WWW-Authenticate']).toBe('Basic realm="gitmost"');
|
||||
expect(built.backend.run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a write by a Read-only user -> 403 (reader cannot push)', async () => {
|
||||
const built = build({ abilityCan: false });
|
||||
const { reply, state } = fakeReply();
|
||||
const req = fakeRequest({
|
||||
url: '/git/space-1.git/git-receive-pack',
|
||||
method: 'POST',
|
||||
authorization: basic('dev@example.com', 'pw'),
|
||||
});
|
||||
|
||||
await built.service.handle(req, reply);
|
||||
|
||||
// The Manage ability was checked for a write and denied.
|
||||
expect(built.abilityCan).toHaveBeenCalledWith(
|
||||
SpaceCaslAction.Manage,
|
||||
SpaceCaslSubject.Page,
|
||||
);
|
||||
expect(state.statusCode).toBe(403);
|
||||
expect(built.orchestrator.ingestExternalPush).not.toHaveBeenCalled();
|
||||
expect(built.backend.run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('an authenticated NON-member of a git-sync space -> 404, NOT 403 (no existence leak)', async () => {
|
||||
// createForUser throws NotFound when the user holds no role in the space (a
|
||||
// non-member). The gate must return 404 — the SAME response a missing /
|
||||
// sync-disabled space gives — so a 403↔404 difference cannot be used to
|
||||
// brute-force which spaces exist / have git-sync enabled (the security fix).
|
||||
const built = build({ abilityCan: false });
|
||||
built.abilityFactory.createForUser.mockRejectedValue(
|
||||
new NotFoundException('Space permissions not found'),
|
||||
);
|
||||
const { reply, state } = fakeReply();
|
||||
const req = fakeRequest({
|
||||
url: '/git/secret-space.git/info/refs?service=git-upload-pack',
|
||||
method: 'GET',
|
||||
authorization: basic('dev@example.com', 'pw'),
|
||||
});
|
||||
|
||||
await built.service.handle(req, reply);
|
||||
|
||||
expect(built.abilityFactory.createForUser).toHaveBeenCalledTimes(1);
|
||||
expect(state.statusCode).toBe(404);
|
||||
expect(built.backend.run).not.toHaveBeenCalled();
|
||||
expect(built.orchestrator.ingestExternalPush).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a space that is not git-sync-enabled -> 404 (existence never revealed)', async () => {
|
||||
const built = build({
|
||||
space: { id: 'space-1', settings: { gitSync: { enabled: false } } },
|
||||
});
|
||||
const { reply, state } = fakeReply();
|
||||
const req = fakeRequest({
|
||||
url: '/git/space-1.git/info/refs?service=git-upload-pack',
|
||||
method: 'GET',
|
||||
authorization: basic('dev@example.com', 'pw'),
|
||||
});
|
||||
|
||||
await built.service.handle(req, reply);
|
||||
|
||||
expect(state.statusCode).toBe(404);
|
||||
// CASL is never even evaluated for a non-candidate space.
|
||||
expect(built.abilityFactory.createForUser).not.toHaveBeenCalled();
|
||||
expect(built.backend.run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('git-sync globally disabled -> 404 even with valid creds', async () => {
|
||||
const built = build({ gitSyncEnabled: false });
|
||||
const { reply, state } = fakeReply();
|
||||
const req = fakeRequest({
|
||||
url: '/git/space-1.git/info/refs?service=git-upload-pack',
|
||||
method: 'GET',
|
||||
authorization: basic('dev@example.com', 'pw'),
|
||||
});
|
||||
|
||||
await built.service.handle(req, reply);
|
||||
|
||||
expect(state.statusCode).toBe(404);
|
||||
expect(built.backend.run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a valid write proceeds through the orchestrator (push takes the lock)', async () => {
|
||||
const built = build({ abilityCan: true });
|
||||
const { reply, state } = fakeReply();
|
||||
const req = fakeRequest({
|
||||
url: '/git/space-1.git/git-receive-pack',
|
||||
method: 'POST',
|
||||
authorization: basic('dev@example.com', 'pw'),
|
||||
});
|
||||
|
||||
await built.service.handle(req, reply);
|
||||
|
||||
expect(built.abilityCan).toHaveBeenCalledWith(
|
||||
SpaceCaslAction.Manage,
|
||||
SpaceCaslSubject.Page,
|
||||
);
|
||||
expect(state.hijacked).toBe(true);
|
||||
expect(built.orchestrator.ingestExternalPush).toHaveBeenCalledTimes(1);
|
||||
const [spaceId, workspaceId] =
|
||||
built.orchestrator.ingestExternalPush.mock.calls[0];
|
||||
expect(spaceId).toBe('space-1');
|
||||
expect(workspaceId).toBe('ws-1');
|
||||
});
|
||||
|
||||
it('GET info/refs?service=git-receive-pack streams the backend WITHOUT a cycle/lock (so the follow-up POST never 503-collides)', async () => {
|
||||
// A push is a TWO-request exchange: GET info/refs?service=git-receive-pack
|
||||
// (ref advertisement) then POST git-receive-pack (the pack). The info/refs
|
||||
// request is write-AUTHORIZED (push perms needed to see those refs) but is
|
||||
// READ-ONLY — it must NOT run ingestExternalPush (a Docmost cycle under the
|
||||
// per-space lock), or the immediately-following POST collides with the still-
|
||||
// running cycle and deterministically 503s. It must just stream the backend.
|
||||
const built = build({ abilityCan: true });
|
||||
const { reply } = fakeReply();
|
||||
const req = fakeRequest({
|
||||
url: '/git/space-1.git/info/refs?service=git-receive-pack',
|
||||
method: 'GET',
|
||||
authorization: basic('dev@example.com', 'pw'),
|
||||
});
|
||||
|
||||
await built.service.handle(req, reply);
|
||||
|
||||
// Authorized as a write (Manage), but executed as a plain stream.
|
||||
expect(built.abilityCan).toHaveBeenCalledWith(
|
||||
SpaceCaslAction.Manage,
|
||||
SpaceCaslSubject.Page,
|
||||
);
|
||||
expect(built.orchestrator.ingestExternalPush).not.toHaveBeenCalled();
|
||||
expect(built.backend.run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('a push that loses the lock -> 503 with Retry-After and a busy body (headers not written twice)', async () => {
|
||||
const built = build({ abilityCan: true });
|
||||
// The lock could not be acquired: the receive-pack closure never ran, so the
|
||||
// response is still unwritten and the handler must answer 503 itself.
|
||||
built.orchestrator.ingestExternalPush.mockRejectedValue(
|
||||
new GitSyncLockHeldError('space-1'),
|
||||
);
|
||||
const { reply, state } = fakeReply();
|
||||
const req = fakeRequest({
|
||||
url: '/git/space-1.git/git-receive-pack',
|
||||
method: 'POST',
|
||||
authorization: basic('dev@example.com', 'pw'),
|
||||
});
|
||||
|
||||
await built.service.handle(req, reply);
|
||||
|
||||
// It hijacked and went through the orchestrator (write path), but the lock
|
||||
// was held so the backend never ran.
|
||||
expect(state.hijacked).toBe(true);
|
||||
expect(built.orchestrator.ingestExternalPush).toHaveBeenCalledTimes(1);
|
||||
expect(built.backend.run).not.toHaveBeenCalled();
|
||||
|
||||
// 503 + Retry-After were written on the raw response (headersSent was false).
|
||||
const raw = reply.raw as any;
|
||||
expect(raw.statusCode).toBe(503);
|
||||
expect(raw.setHeader).toHaveBeenCalledWith('Content-Type', 'text/plain');
|
||||
expect(raw.setHeader).toHaveBeenCalledWith('Retry-After', '1');
|
||||
// The body carries the busy/retry message and the response was ended once.
|
||||
expect(raw.end).toHaveBeenCalledTimes(1);
|
||||
expect(raw.end).toHaveBeenCalledWith('git-sync busy, retry');
|
||||
// Exactly the two headers above were set — no double write of headers.
|
||||
expect(raw.setHeader).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does NOT rewrite the 503 status/headers when the response is already sent', async () => {
|
||||
const built = build({ abilityCan: true });
|
||||
built.orchestrator.ingestExternalPush.mockRejectedValue(
|
||||
new GitSyncLockHeldError('space-1'),
|
||||
);
|
||||
const { reply } = fakeReply();
|
||||
// Simulate the (defensive) case where headers were already flushed: the
|
||||
// handler must skip statusCode/setHeader and only end() the socket.
|
||||
const raw = reply.raw as any;
|
||||
raw.headersSent = true;
|
||||
const req = fakeRequest({
|
||||
url: '/git/space-1.git/git-receive-pack',
|
||||
method: 'POST',
|
||||
authorization: basic('dev@example.com', 'pw'),
|
||||
});
|
||||
|
||||
await built.service.handle(req, reply);
|
||||
|
||||
// No header writes when headersSent is already true (no "headers already
|
||||
// sent" double-write path), but the body/end still runs.
|
||||
expect(raw.setHeader).not.toHaveBeenCalled();
|
||||
expect(raw.statusCode).toBe(200); // untouched default from the fake
|
||||
expect(raw.end).toHaveBeenCalledTimes(1);
|
||||
expect(raw.end).toHaveBeenCalledWith('git-sync busy, retry');
|
||||
});
|
||||
|
||||
it('an unresolvable workspace -> 401 (credentials cannot be validated without one)', async () => {
|
||||
const built = build({ workspace: null });
|
||||
const { reply, state } = fakeReply();
|
||||
const req = fakeRequest({
|
||||
url: '/git/space-1.git/info/refs?service=git-upload-pack',
|
||||
method: 'GET',
|
||||
authorization: basic('dev@example.com', 'pw'),
|
||||
});
|
||||
|
||||
await built.service.handle(req, reply);
|
||||
|
||||
// Without a workspace we cannot run verifyUserCredentials, so credentials
|
||||
// are not validated -> 401 (the 401-before-404 ordering is preserved: an
|
||||
// unauthenticated request never reaches the space-existence 404).
|
||||
expect(built.workspaceRepo.findFirst).toHaveBeenCalledTimes(1);
|
||||
expect(built.authService.verifyUserCredentials).not.toHaveBeenCalled();
|
||||
expect(state.statusCode).toBe(401);
|
||||
expect(state.headers['WWW-Authenticate']).toBe('Basic realm="gitmost"');
|
||||
expect(built.backend.run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// --- brute-force throttle (must-fix #1, mirrors the /mcp Basic limiter) -----
|
||||
describe('HTTP-Basic brute-force throttle', () => {
|
||||
/** A request with wrong credentials for the given email. */
|
||||
const wrongCredReq = (email = 'dev@example.com') =>
|
||||
fakeRequest({
|
||||
url: '/git/space-1.git/info/refs?service=git-upload-pack',
|
||||
method: 'GET',
|
||||
authorization: basic(email, 'wrong'),
|
||||
});
|
||||
|
||||
it('rejects the (threshold+1)-th failed attempt with 429 BEFORE bcrypt', async () => {
|
||||
const built = build();
|
||||
// Realistic credential failure: verifyUserCredentials throws the SAME
|
||||
// UnauthorizedException(CREDENTIALS_MISMATCH_MESSAGE) production throws, so
|
||||
// isCredentialsFailure matches and the reservation is KEPT (counted).
|
||||
built.authService.verifyUserCredentials.mockRejectedValue(
|
||||
new UnauthorizedException(CREDENTIALS_MISMATCH_MESSAGE),
|
||||
);
|
||||
|
||||
// 5 failed attempts (threshold = 5): each runs the credential check -> 401.
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const { reply, state } = fakeReply();
|
||||
await built.service.handle(wrongCredReq(), reply);
|
||||
expect(state.statusCode).toBe(401);
|
||||
}
|
||||
expect(built.authService.verifyUserCredentials).toHaveBeenCalledTimes(5);
|
||||
|
||||
// The 6th attempt is throttled: 429, Retry-After, and bcrypt is NOT run.
|
||||
const { reply, state } = fakeReply();
|
||||
await built.service.handle(wrongCredReq(), reply);
|
||||
expect(state.statusCode).toBe(429);
|
||||
expect(state.headers['Retry-After']).toBe('60');
|
||||
expect(state.headers['WWW-Authenticate']).toBe('Basic realm="gitmost"');
|
||||
// Still 5 — the 6th never reached verifyUserCredentials (pre-bcrypt reject).
|
||||
expect(built.authService.verifyUserCredentials).toHaveBeenCalledTimes(5);
|
||||
expect(built.backend.run).not.toHaveBeenCalled();
|
||||
|
||||
built.service.onModuleDestroy();
|
||||
});
|
||||
|
||||
it('a successful auth resets the limiter so later attempts are not throttled', async () => {
|
||||
const built = build();
|
||||
const verify = built.authService.verifyUserCredentials;
|
||||
// First 4 attempts fail (credential mismatch), then one SUCCEEDS.
|
||||
verify
|
||||
.mockRejectedValueOnce(new UnauthorizedException(CREDENTIALS_MISMATCH_MESSAGE))
|
||||
.mockRejectedValueOnce(new UnauthorizedException(CREDENTIALS_MISMATCH_MESSAGE))
|
||||
.mockRejectedValueOnce(new UnauthorizedException(CREDENTIALS_MISMATCH_MESSAGE))
|
||||
.mockRejectedValueOnce(new UnauthorizedException(CREDENTIALS_MISMATCH_MESSAGE))
|
||||
.mockResolvedValueOnce({ id: 'user-1', email: 'dev@example.com' });
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const { reply } = fakeReply();
|
||||
await built.service.handle(wrongCredReq(), reply);
|
||||
}
|
||||
// 5th attempt succeeds -> proceeds (not throttled) and clears the budget.
|
||||
const okReply = fakeReply();
|
||||
await built.service.handle(
|
||||
fakeRequest({
|
||||
url: '/git/space-1.git/info/refs?service=git-upload-pack',
|
||||
method: 'GET',
|
||||
authorization: basic('dev@example.com', 'right'),
|
||||
}),
|
||||
okReply.reply,
|
||||
);
|
||||
expect(okReply.state.hijacked).toBe(true); // proceeded to the backend
|
||||
|
||||
// After the reset, a fresh wrong attempt is evaluated (401), NOT a 429 —
|
||||
// proving the per-IP/per-IP+email budget was cleared by the success.
|
||||
verify.mockRejectedValueOnce(
|
||||
new UnauthorizedException(CREDENTIALS_MISMATCH_MESSAGE),
|
||||
);
|
||||
const { reply, state } = fakeReply();
|
||||
await built.service.handle(wrongCredReq(), reply);
|
||||
expect(state.statusCode).toBe(401);
|
||||
|
||||
built.service.onModuleDestroy();
|
||||
});
|
||||
|
||||
it('a non-credential error releases the reservation (does not burn the budget)', async () => {
|
||||
const built = build();
|
||||
// A DB error (not a credentials mismatch) must NOT count toward the limiter.
|
||||
built.authService.verifyUserCredentials.mockRejectedValue(
|
||||
new Error('db down'),
|
||||
);
|
||||
|
||||
// 10 such failures — far beyond the threshold — must all be 401, never 429,
|
||||
// because each releases its reservation.
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const { reply, state } = fakeReply();
|
||||
await built.service.handle(wrongCredReq(), reply);
|
||||
expect(state.statusCode).toBe(401);
|
||||
}
|
||||
expect(built.authService.verifyUserCredentials).toHaveBeenCalledTimes(10);
|
||||
|
||||
built.service.onModuleDestroy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,488 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
OnModuleDestroy,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { AuthService } from '../../../core/auth/services/auth.service';
|
||||
import SpaceAbilityFactory from '../../../core/casl/abilities/space-ability.factory';
|
||||
import {
|
||||
SpaceCaslAction,
|
||||
SpaceCaslSubject,
|
||||
} from '../../../core/casl/interfaces/space-ability.type';
|
||||
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
|
||||
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
||||
import { User } from '@docmost/db/types/entity.types';
|
||||
import {
|
||||
parseBasicAuth,
|
||||
FailedLoginLimiter,
|
||||
clientIp,
|
||||
isCredentialsFailure,
|
||||
} from '../../mcp/mcp-auth.helpers';
|
||||
import { resolveRequestWorkspace } from '../../../common/helpers/resolve-request-workspace';
|
||||
import { EnvironmentService } from '../../environment/environment.service';
|
||||
import { VaultRegistryService } from '../services/vault-registry.service';
|
||||
import {
|
||||
GitSyncLockHeldError,
|
||||
GitSyncOrchestrator,
|
||||
} from '../services/git-sync.orchestrator';
|
||||
import { GitHttpBackendService } from './git-http-backend.service';
|
||||
import {
|
||||
decideGitHttpGate,
|
||||
parseGitPath,
|
||||
resolveServiceKind,
|
||||
GitHttpServiceKind,
|
||||
} from './git-http.helpers';
|
||||
|
||||
const WWW_AUTHENTICATE = 'Basic realm="gitmost"';
|
||||
|
||||
/**
|
||||
* The /git smart-HTTP host. Wires request parsing, the reused auth primitives
|
||||
* (HTTP Basic -> AuthService.verifyUserCredentials), per-space gating
|
||||
* (EnvironmentService flags + space.settings.gitSync.enabled), CASL authz
|
||||
* (SpaceAbilityFactory), and dispatch to `git http-backend`:
|
||||
* - fetch (read) -> ensureServable then stream http-backend directly (no lock).
|
||||
* - push (write) -> ensureServable then orchestrator.ingestExternalPush, which
|
||||
* runs the receive-pack under the space lock and then a Docmost cycle.
|
||||
*
|
||||
* Mounted at the ROOT (`/git/...`) by a raw Fastify route in main.ts (the global
|
||||
* `/api` prefix does not apply). Never logs the password or Authorization header.
|
||||
*/
|
||||
@Injectable()
|
||||
export class GitHttpService implements OnModuleDestroy {
|
||||
private readonly logger = new Logger(GitHttpService.name);
|
||||
|
||||
/**
|
||||
* In-process brute-force speed bump for the /git HTTP-Basic path. The raw
|
||||
* `/git/*` Fastify route bypasses the Nest pipeline (so ThrottlerGuard, which is
|
||||
* only on controllers, never runs) and there is no fastify rate-limit plugin, so
|
||||
* without this `verifyUserCredentials` (bcrypt) would run unthrottled on every
|
||||
* request once GIT_SYNC_HTTP_ENABLED is on. Mirrors the /mcp Basic path EXACTLY
|
||||
* (FailedLoginLimiter, same 5/60s thresholds, the same per-IP / per-IP+email /
|
||||
* global-per-email keys) so the two auth seams cannot diverge. A speed bump, not
|
||||
* a hard boundary (in-process, per replica).
|
||||
*/
|
||||
private readonly failedLogins = new FailedLoginLimiter(5, 60_000);
|
||||
/** Periodic sweep to bound limiter memory (mirrors McpService / mcp http.ts). */
|
||||
private readonly sweepIntervalMs = 60_000;
|
||||
private readonly sweepTimer: NodeJS.Timeout;
|
||||
|
||||
constructor(
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private readonly authService: AuthService,
|
||||
private readonly spaceRepo: SpaceRepo,
|
||||
private readonly workspaceRepo: WorkspaceRepo,
|
||||
private readonly spaceAbilityFactory: SpaceAbilityFactory,
|
||||
private readonly vaultRegistry: VaultRegistryService,
|
||||
private readonly orchestrator: GitSyncOrchestrator,
|
||||
private readonly backend: GitHttpBackendService,
|
||||
) {
|
||||
this.sweepTimer = setInterval(() => {
|
||||
try {
|
||||
this.failedLogins.sweep();
|
||||
} catch (err) {
|
||||
this.logger.error('git-http failed-login limiter sweep failed', err as Error);
|
||||
}
|
||||
}, this.sweepIntervalMs);
|
||||
// Never keep the event loop alive solely for the sweep timer.
|
||||
this.sweepTimer.unref?.();
|
||||
}
|
||||
|
||||
onModuleDestroy(): void {
|
||||
clearInterval(this.sweepTimer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the workspace for a /git request the SAME way DomainMiddleware does,
|
||||
* because Nest middleware does NOT run for this raw root-mounted route (it is
|
||||
* registered under the global '/api' router), so `req.raw.workspaceId` is never
|
||||
* populated here. Delegates to the shared `resolveRequestWorkspace` helper (the
|
||||
* SAME self-hosted/cloud branch DomainMiddleware uses) and returns just the id:
|
||||
* - self-hosted (single workspace) -> workspaceRepo.findFirst();
|
||||
* - cloud (multi-tenant) -> resolve by the host-header subdomain.
|
||||
* Returns null when no workspace resolves; the gate then 404s (after the
|
||||
* 401-before-404 credential check encoded in decideGitHttpGate).
|
||||
*/
|
||||
private async resolveWorkspaceId(req: FastifyRequest): Promise<string | null> {
|
||||
try {
|
||||
// Same self-hosted/cloud resolution DomainMiddleware uses — shared so the
|
||||
// branch cannot drift between the two call sites.
|
||||
const workspace = await resolveRequestWorkspace(
|
||||
this.environmentService,
|
||||
this.workspaceRepo,
|
||||
this.headerValue(req.headers['host']),
|
||||
);
|
||||
return workspace?.id ?? null;
|
||||
} catch (err) {
|
||||
// A DB error resolving the workspace must not leak details; treat as
|
||||
// unresolvable (the gate will 404, unless creds are missing -> 401 first).
|
||||
this.logger.warn(
|
||||
`git-http: workspace resolution error: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle one `/git/<spaceId>.git/<subpath>` request. `rest` is the path AFTER
|
||||
* the `/git/` prefix (no query string). The Fastify reply is hijacked before
|
||||
* any streaming so the binary CGI body is written directly to the raw socket.
|
||||
*/
|
||||
async handle(req: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
const rawReq = req.raw;
|
||||
const rawRes = reply.raw;
|
||||
|
||||
// --- parse the URL into spaceId + subpath -------------------------------
|
||||
const rest = this.extractRest(req.url);
|
||||
const parsedPath = rest === null ? null : parseGitPath(rest);
|
||||
|
||||
// --- resolve the requested git service kind (read vs write) -------------
|
||||
const service =
|
||||
typeof req.query === 'object' && req.query !== null
|
||||
? (req.query as Record<string, string | undefined>).service
|
||||
: undefined;
|
||||
const serviceKind: GitHttpServiceKind | null = parsedPath
|
||||
? resolveServiceKind({
|
||||
method: req.method,
|
||||
subpath: parsedPath.subpath,
|
||||
service,
|
||||
})
|
||||
: null;
|
||||
|
||||
// --- authenticate (HTTP Basic) ------------------------------------------
|
||||
const authHeader = req.headers['authorization'];
|
||||
const basic = parseBasicAuth(
|
||||
Array.isArray(authHeader) ? authHeader[0] : authHeader,
|
||||
);
|
||||
// Resolve the workspace ourselves — DomainMiddleware does NOT run for this
|
||||
// raw root route, so `req.raw.workspaceId` is never set (see resolver doc).
|
||||
const workspaceId: string | null = await this.resolveWorkspaceId(req);
|
||||
|
||||
let user: User | undefined;
|
||||
let credentialsValid = false;
|
||||
let throttled = false;
|
||||
if (basic && workspaceId) {
|
||||
// Brute-force speed bump, mirroring the /mcp Basic path EXACTLY. Reserve
|
||||
// ALL three keys ATOMICALLY and BEFORE bcrypt (tryReserve folds the check
|
||||
// and the increment into one synchronous step), so the (threshold+1)-th
|
||||
// attempt is rejected before verifyUserCredentials/bcrypt ever runs and
|
||||
// concurrent attempts for one email cannot all observe count=0. The
|
||||
// reservation IS the recorded failure: a genuine credential failure leaves
|
||||
// it in place, a SUCCESS clears it (reset), a non-credential error releases
|
||||
// it (so it cannot burn a victim's budget).
|
||||
const emailLc = basic.email.toLowerCase();
|
||||
const ip = clientIp(req);
|
||||
const ipKey = `ip:${ip}`;
|
||||
const ipEmailKey = `ip-email:${ip}:${emailLc}`;
|
||||
// GLOBAL per-email backstop (no IP): the only key that survives IP / XFF
|
||||
// rotation, so it is the real account-brute defense (see mcp-auth.helpers).
|
||||
const emailKey = `email:${emailLc}`;
|
||||
const ipOk = this.failedLogins.tryReserve(ipKey);
|
||||
const ipEmailOk = this.failedLogins.tryReserve(ipEmailKey);
|
||||
const emailOk = this.failedLogins.tryReserve(emailKey);
|
||||
if (!ipOk || !ipEmailOk || !emailOk) {
|
||||
// Blocked: release only the keys we actually reserved this call so an
|
||||
// already-throttled request does not over-charge keys still under budget
|
||||
// (matches the /mcp reserve model). Do NOT run bcrypt.
|
||||
if (ipOk) this.failedLogins.release(ipKey);
|
||||
if (ipEmailOk) this.failedLogins.release(ipEmailKey);
|
||||
if (emailOk) this.failedLogins.release(emailKey);
|
||||
throttled = true;
|
||||
} else {
|
||||
try {
|
||||
user = await this.authService.verifyUserCredentials(
|
||||
{ email: basic.email, password: basic.password },
|
||||
workspaceId,
|
||||
);
|
||||
credentialsValid = true;
|
||||
// Success: clear the per-IP and per-IP+email budgets fully; for the
|
||||
// GLOBAL per-email key only release the one increment THIS request took
|
||||
// (do not reset() it, or a victim's own success would wipe a parallel
|
||||
// attacker's accumulated failures for that email — same rule as /mcp).
|
||||
this.failedLogins.reset(ipKey);
|
||||
this.failedLogins.reset(ipEmailKey);
|
||||
this.failedLogins.release(emailKey);
|
||||
} catch (err) {
|
||||
// Only a genuine credentials failure (wrong email/password) keeps the
|
||||
// reservation (it IS the recorded failure). Any other error — DB error,
|
||||
// etc. — is NOT a password-guess signal, so release the reservation so
|
||||
// it cannot burn a victim's limiter budget. credentialsValid stays
|
||||
// false either way (the gate then 401s).
|
||||
if (!isCredentialsFailure(err)) {
|
||||
this.failedLogins.release(ipKey);
|
||||
this.failedLogins.release(ipEmailKey);
|
||||
this.failedLogins.release(emailKey);
|
||||
}
|
||||
if (!(err instanceof UnauthorizedException)) {
|
||||
// A non-credential failure (e.g. DB error): treat as invalid creds
|
||||
// for the gate (a 401), and log without leaking the password/header.
|
||||
this.logger.warn(
|
||||
`git-http: credential check error: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
credentialsValid = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Brute-force throttle tripped: reject BEFORE the gate (and before any space
|
||||
// lookup), so a throttled attacker gets a uniform 429 with no bcrypt and no
|
||||
// existence signal. WWW-Authenticate is still sent so a legitimate client
|
||||
// re-prompts after the window.
|
||||
if (throttled) {
|
||||
reply
|
||||
.header('WWW-Authenticate', WWW_AUTHENTICATE)
|
||||
.header('Retry-After', '60')
|
||||
.status(429)
|
||||
.send('Too many failed authentication attempts. Try again later.');
|
||||
return;
|
||||
}
|
||||
|
||||
// --- resolve the space + per-space gating + CASL ------------------------
|
||||
let spaceExists = false;
|
||||
let spaceGitSyncEnabled = false;
|
||||
let spaceId: string | undefined;
|
||||
// The user has SOME role in the space. SECURITY: a non-member must get the
|
||||
// SAME 404 a missing/disabled space gets — never a 403 — or the 403↔404 split
|
||||
// would let any authenticated user brute-force slugs to learn which spaces
|
||||
// exist / have sync enabled (the leak this gate's contract forbids). 403 is
|
||||
// reserved for a MEMBER who lacks the required role (existence already known).
|
||||
let userIsSpaceMember = false;
|
||||
let permissionGranted = false;
|
||||
if (credentialsValid && user && workspaceId && parsedPath && serviceKind) {
|
||||
const space = await this.spaceRepo.findById(
|
||||
parsedPath.spaceId,
|
||||
workspaceId,
|
||||
);
|
||||
if (space) {
|
||||
spaceExists = true;
|
||||
spaceId = space.id;
|
||||
spaceGitSyncEnabled =
|
||||
(space.settings as any)?.gitSync?.enabled === true;
|
||||
|
||||
// Only evaluate CASL when the space is actually a sync candidate — an
|
||||
// unrelated space stays a 404 (existence is never revealed).
|
||||
if (spaceGitSyncEnabled) {
|
||||
try {
|
||||
const ability = await this.spaceAbilityFactory.createForUser(
|
||||
user,
|
||||
space.id,
|
||||
);
|
||||
// createForUser RESOLVED -> the user holds a role in this space (it
|
||||
// throws NotFound for a non-member). Record membership BEFORE the
|
||||
// permission check: a member lacking the role -> 403; a non-member ->
|
||||
// 404 (handled by the gate via userIsSpaceMember=false below).
|
||||
userIsSpaceMember = true;
|
||||
const action =
|
||||
serviceKind === 'write'
|
||||
? SpaceCaslAction.Manage
|
||||
: SpaceCaslAction.Read;
|
||||
permissionGranted = ability.can(action, SpaceCaslSubject.Page);
|
||||
} catch {
|
||||
// createForUser throws NotFoundException when the user has no role in
|
||||
// the space (a non-member). Leave userIsSpaceMember=false so the gate
|
||||
// returns 404, NOT 403 — a non-member must not be able to tell this
|
||||
// space apart from a non-existent one. (Any other error also falls
|
||||
// here and is treated as non-member -> 404, the safe default that
|
||||
// never reveals existence.)
|
||||
userIsSpaceMember = false;
|
||||
permissionGranted = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- the gate decision (pure) -------------------------------------------
|
||||
const decision = decideGitHttpGate({
|
||||
hasCredentials: Boolean(basic),
|
||||
credentialsValid,
|
||||
serviceKind,
|
||||
gitSyncEnabled: this.environmentService.isGitSyncEnabled(),
|
||||
gitHttpEnabled: this.environmentService.isGitSyncHttpEnabled(),
|
||||
spaceExists,
|
||||
spaceGitSyncEnabled,
|
||||
userIsSpaceMember,
|
||||
permissionGranted,
|
||||
});
|
||||
|
||||
if (decision.kind === 'unauthorized') {
|
||||
reply
|
||||
.header('WWW-Authenticate', WWW_AUTHENTICATE)
|
||||
.status(401)
|
||||
.send('Authentication required');
|
||||
return;
|
||||
}
|
||||
if (decision.kind === 'bad-request') {
|
||||
reply.status(400).send('Bad request');
|
||||
return;
|
||||
}
|
||||
if (decision.kind === 'not-found') {
|
||||
reply.status(404).send('Not found');
|
||||
return;
|
||||
}
|
||||
if (decision.kind === 'forbidden') {
|
||||
reply.status(403).send('Forbidden');
|
||||
return;
|
||||
}
|
||||
|
||||
// decision.kind === 'proceed' — guaranteed below (narrowing for TS).
|
||||
if (!parsedPath || !serviceKind || !spaceId || !user || !workspaceId) {
|
||||
// Defensive: 'proceed' implies these are set, but keep TS + runtime safe.
|
||||
reply.status(500).send('Internal server error');
|
||||
return;
|
||||
}
|
||||
|
||||
// --- dispatch to git http-backend ---------------------------------------
|
||||
const backendRequest = {
|
||||
spaceId,
|
||||
subpath: parsedPath.subpath,
|
||||
method: req.method,
|
||||
queryString: this.extractQueryString(req.url),
|
||||
contentType: this.headerValue(req.headers['content-type']) ?? '',
|
||||
gitProtocol: this.headerValue(req.headers['git-protocol']),
|
||||
// Forward Content-Encoding so http-backend inflates gzip'd RPC bodies
|
||||
// (review #4) — else a non-trivial `git pull` fails with expected 'packfile'.
|
||||
contentEncoding: this.headerValue(req.headers['content-encoding']),
|
||||
remoteUser: user.email,
|
||||
};
|
||||
|
||||
try {
|
||||
// Idempotently make the vault servable (repo + receive/upload config).
|
||||
await this.vaultRegistry.ensureServable(spaceId);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`git-http: failed to prepare vault for space ${spaceId}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
if (!reply.sent) reply.status(500).send('Internal server error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Hijack the reply so the backend can stream the raw (possibly binary) CGI
|
||||
// response directly to the socket (mirrors the MCP transport pattern).
|
||||
reply.hijack();
|
||||
|
||||
// Only the ACTUAL pack-receiving write (POST git-receive-pack) runs under the
|
||||
// space lock + a Docmost cycle. Everything else streams the http-backend
|
||||
// directly with NO lock and NO cycle: a fetch/clone (read), AND the
|
||||
// write-AUTHORIZED but READ-ONLY ref advertisement
|
||||
// (GET info/refs?service=git-receive-pack). Running a cycle on info/refs is
|
||||
// both wasteful and HARMFUL — it holds the per-space lock, so the push's
|
||||
// immediately-following POST git-receive-pack collides with it and 503s
|
||||
// (a deterministic push failure). Authz already happened above via the gate.
|
||||
const isReceivePack =
|
||||
req.method === 'POST' && parsedPath.subpath === 'git-receive-pack';
|
||||
if (serviceKind === 'read' || !isReceivePack) {
|
||||
// The clone's default branch comes from the HEAD symref advertised by the
|
||||
// upload-pack ref advertisement (or a dumb `GET HEAD`). The engine
|
||||
// transiently checks out the read-only `docmost` mirror mid-cycle, so serve
|
||||
// THAT advertisement with HEAD pinned to `main` under the per-space lock so
|
||||
// a clone never defaults to `docmost` (bug #3). Pack streaming and every
|
||||
// other read are resolved by object SHA and need no pin, so they stream
|
||||
// directly (no lock) as before.
|
||||
const isReadAdvertise =
|
||||
req.method === 'GET' &&
|
||||
((parsedPath.subpath === 'info/refs' &&
|
||||
service === 'git-upload-pack') ||
|
||||
parsedPath.subpath === 'HEAD');
|
||||
if (isReadAdvertise) {
|
||||
// The read-advertise path runs under the space lock (to pin HEAD=main).
|
||||
// AFTER reply.hijack() Fastify no longer manages this response, so a
|
||||
// rejection here (e.g. SpaceLockService.acquire when Redis is down) would
|
||||
// otherwise leave the socket open forever — every clone/fetch hangs until
|
||||
// the client times out (review #5). Mirror the push branch: catch, answer
|
||||
// 500 if nothing was written yet, and always end the raw socket.
|
||||
try {
|
||||
await this.orchestrator.serveReadAdvertisement(spaceId, () =>
|
||||
this.backend.run(backendRequest, rawReq, rawRes),
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`git-sync: read advertisement failed for space ${spaceId}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
if (!rawRes.headersSent) {
|
||||
rawRes.statusCode = 500;
|
||||
rawRes.setHeader('Content-Type', 'text/plain');
|
||||
rawRes.end('Internal server error');
|
||||
} else if (!rawRes.writableEnded) {
|
||||
rawRes.end();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await this.backend.run(backendRequest, rawReq, rawRes);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Push: run the receive-pack under the space lock, then a Docmost cycle.
|
||||
try {
|
||||
await this.orchestrator.ingestExternalPush(
|
||||
spaceId,
|
||||
workspaceId,
|
||||
// The lock's lost-lock signal is threaded into the backend so the
|
||||
// receive-pack child is killed if the lock lapses mid-write (warning #3).
|
||||
(signal) => this.backend.run(backendRequest, rawReq, rawRes, signal),
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof GitSyncLockHeldError) {
|
||||
// The lock could not be acquired and the receive-pack never ran, so the
|
||||
// response is still unwritten — answer 503 so git retries.
|
||||
if (!rawRes.headersSent) {
|
||||
rawRes.statusCode = 503;
|
||||
rawRes.setHeader('Content-Type', 'text/plain');
|
||||
rawRes.setHeader('Retry-After', '1');
|
||||
}
|
||||
try {
|
||||
rawRes.end('git-sync busy, retry');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Any other error: the receive-pack closure handles its own response, so
|
||||
// we only log here and make sure the socket is closed.
|
||||
this.logger.error(
|
||||
`git-http: push ingestion error for space ${spaceId}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
try {
|
||||
if (!rawRes.writableEnded) rawRes.end();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalise a possibly-array header value to its first string. */
|
||||
private headerValue(value: string | string[] | undefined): string | undefined {
|
||||
if (Array.isArray(value)) return value[0];
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the part of the URL AFTER `/git/` and BEFORE the query string.
|
||||
* Returns null when the URL is not under `/git/`.
|
||||
*/
|
||||
private extractRest(url: string): string | null {
|
||||
const qIdx = url.indexOf('?');
|
||||
const pathname = qIdx === -1 ? url : url.slice(0, qIdx);
|
||||
const prefix = '/git/';
|
||||
if (!pathname.startsWith(prefix)) return null;
|
||||
return pathname.slice(prefix.length);
|
||||
}
|
||||
|
||||
/** The raw query string without the leading '?', or '' when none. */
|
||||
private extractQueryString(url: string): string {
|
||||
const qIdx = url.indexOf('?');
|
||||
return qIdx === -1 ? '' : url.slice(qIdx + 1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user