git-trace

romele / git-trace public

62 commits · 1 branch · 1 tag · 1 contributor (top: Romele) · first commit 3d ago · last push 12m ago

src/mirror.c

/* M15: repository mirroring / lightweight federation.
 *
 * A mirror row says "keep refs/mirror/… in this repo in sync with
 * <url>".  Synchronization is a plain `git fetch` with a constrained
 * refspec whose destination side is locked to refs/mirror/, so a
 * hostile or broken upstream can never overwrite local branches.
 *
 * Triggers: the CLI (git-trace mirror-sync [o/r]) or the webhook-shaped
 * endpoint POST /<o>/<r>/mirror-sync.  An upstream git-trace instance
 * registers a push webhook pointing at that path, signed with the
 * mirror's secret — event-driven federation, no polling daemon.
 *
 * Trust boundary: mirror URLs are configured by repo admins and the
 * fetch runs with daemon privileges.  Schemes are restricted to
 * http/https/git/ssh — file:// is refused outright (it would let an
 * admin exfiltrate arbitrary local paths into a repo).  SSRF against
 * internal addresses is possible via http/https/git URLs; that is a
 * documented admin-trust decision, same as outbound webhooks.
 */
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include <sqlite3.h>
#include "mirror.h"
#include "collab.h"
#include "gitx.h"
#include "repo.h"
#include "util.h"
#include "crypto.h"
#include "db.h"

int mirror_url_ok(const char *url)
{
    static const char *const sch[] = {
        "http://", "https://", "git://", "ssh://"
    };
    size_t n = strlen(url);
    if (n == 0 || n > 500 || strcspn(url, " \t\r\n'\"`$\\") != n)
        return 0;
    for (size_t i = 0; i < sizeof sch / sizeof sch[0]; i++) {
        size_t sl = strlen(sch[i]);
        if (strncmp(url, sch[i], sl) == 0 && url[sl] &&
            url[sl] != '/' && url[sl] != '?')
            return 1;
    }
    /* scp-like syntax: user@host:path — user must be sane */
    const char *at = strchr(url, '@');
    const char *co = at ? strchr(at, ':') : NULL;
    if (at && co && at - url >= 1 && at - url <= 64) {
        for (const char *p = url; p < at; p++)
            if (!(isalnum((unsigned char)*p) || *p == '.' ||
                  *p == '_' || *p == '-'))
                return 0;
        return co > at + 1 ? 1 : 0;
    }
    return 0;
}

/* shared refspec character whitelist (plus already consumed) */
static int refspec_chars_ok(const char *spec)
{
    for (const char *p = spec; *p; p++) {
        unsigned char c = (unsigned char)*p;
        if (!(isalnum(c) || c == '*' || c == '?' || c == '.' ||
              c == '_' || c == '-' || c == '/' || c == ':' || c == '+'))
            return 0;
    }
    return 1;
}

int mirror_refspec_ok(const char *spec)
{
    if (!*spec || strlen(spec) > 255)
        return 0;
    if (*spec == '+')
        spec++;
    const char *colon = strchr(spec, ':');
    if (!colon || strchr(colon + 1, ':'))
        return 0;
    if (!starts_with(spec, "refs/"))
        return 0;
    if (!starts_with(colon + 1, "refs/mirror/"))
        return 0;                       /* destination is locked down */
    return refspec_chars_ok(spec) && colon - spec > 5 &&
           strlen(colon + 1) > 12;
}

int mirror_refspec_push_ok(const char *spec)
{
    if (!*spec || strlen(spec) > 255)
        return 0;
    if (*spec == '+')
        spec++;
    const char *colon = strchr(spec, ':');
    if (!colon || strchr(colon + 1, ':'))
        return 0;
    /* source is locked to local branches — a push mirror must never
     * leak refs/mirror (or anything else) as remote branches */
    if (!starts_with(spec, "refs/heads/"))
        return 0;
    if (!starts_with(colon + 1, "refs/heads/"))
        return 0;
    return refspec_chars_ok(spec) && colon - spec > 10 &&
           strlen(colon + 1) > 11;
}

void mirror_url_mask(const char *url, char *out, size_t n)
{
    /* hide an embedded password: scheme://user:pass@host -> user:*** */
    const char *sc = strstr(url, "://");
    const char *at = sc ? strchr(sc + 3, '@') : NULL;
    const char *co = at ? memchr(sc + 3, ':', (size_t)(at - (sc + 3)))
                        : NULL;
    if (!co) {
        snprintf(out, n, "%s", url);
        return;
    }
    int pre = (int)(co + 1 - url);
    snprintf(out, n, "%.*s***%s", pre, url, at);
}

/* run `timeout 120 git -C <rdir> <op> -v <url> <spec>` capturing the
 * child's stdout+stderr into log.  op is "fetch" (pull mirrors) or
 * "push" (push mirrors); pull adds --no-tags --prune. */
static int remote_run(const char *rdir, const char *op, const char *url,
                      const char *spec, char *log, size_t lcap)
{
    int pfd[2];
    if (pipe(pfd) < 0)
        return -1;
    pid_t pid = fork();
    if (pid < 0) {
        close(pfd[0]);
        close(pfd[1]);
        return -1;
    }
    if (pid == 0) {
        dup2(pfd[1], 1);
        dup2(pfd[1], 2);
        close(pfd[0]);
        close(pfd[1]);
        if (chdir(rdir) < 0)
            _exit(126);
        setenv("GIT_TERMINAL_PROMPT", "0", 1);
        setenv("GIT_HTTP_LOW_SPEED_LIMIT", "1000", 1);
        setenv("GIT_HTTP_LOW_SPEED_TIME", "30", 1);
        if (strcmp(op, "fetch") == 0)
            execlp("timeout", "timeout", "120", "git", "fetch",
                   "--no-tags", "--prune", "-v", url, spec,
                   (char *)NULL);
        execlp("timeout", "timeout", "120", "git", "push",
               "-v", url, spec, (char *)NULL);
        _exit(127);
    }
    close(pfd[1]);
    size_t n = 0;
    for (;;) {
        char tmp[4096];
        ssize_t rd = read(pfd[0], tmp, sizeof tmp);
        if (rd <= 0)
            break;
        if (n + (size_t)rd < lcap) {
            memcpy(log + n, tmp, (size_t)rd);
            n += (size_t)rd;
        }
    }
    close(pfd[0]);
    if (lcap)
        log[n < lcap ? n : lcap - 1] = '\0';
    int st = 0;
    waitpid(pid, &st, 0);
    return WIFEXITED(st) ? WEXITSTATUS(st) : -1;
}

static int mirror_count_refs(const char *rdir)
{
    struct buf out;
    buf_init(&out);
    const char *argv[] = { "git", "for-each-ref",
                           "--format=%(refname)", "refs/mirror", NULL };
    git_capture(rdir, argv, &out);
    int nrefs = 0;
    for (char *p = out.data; p && *p; p++)
        if (*p == '\n')
            nrefs++;
    buf_free(&out);
    return nrefs;
}

static void sync_row(struct app_ctx *app, sqlite3_int64 mid,
                     sqlite3_int64 repo_id, const char *rdir,
                     const char *url, const char *spec)
{
    char log[4096];
    int rc = remote_run(rdir, "fetch", url, spec, log, sizeof log - 1);
    int nrefs = rc == 0 ? mirror_count_refs(rdir) : -1;
    char *e = log + strlen(log);
    while (e > log && (e[-1] == '\n' || e[-1] == ' ' || e[-1] == '\t'))
        *--e = '\0';
    sqlite3_stmt *st;
    sqlite3_prepare_v2(app->db,
        "UPDATE repo_mirrors SET last_ts=strftime('%s','now'),"
        " last_ok=?, last_err=?, last_refs=? WHERE id=?", -1, &st, NULL);
    sqlite3_bind_int(st, 1, rc == 0);
    sqlite3_bind_text(st, 2, log, -1, SQLITE_TRANSIENT);
    sqlite3_bind_int(st, 3, nrefs < 0 ? 0 : nrefs);
    sqlite3_bind_int64(st, 4, mid);
    sqlite3_step(st);
    sqlite3_finalize(st);
    db_audit(app->db, 0, "mirror", NULL, repo_id, "mirror_sync",
             "rc=%d refs=%d", rc, nrefs);
}

int gt_mirror_sync(struct app_ctx *app, const char *owner, const char *name)
{
    sqlite3_stmt *st;
    if (owner && name)
        sqlite3_prepare_v2(app->db,
            "SELECT m.id,m.url,m.refspec,r.id,m.repo_id,u.name,r.name"
            " FROM repo_mirrors m"
            " JOIN repos r ON r.id=m.repo_id"
            " JOIN users u ON u.id=r.owner_id"
            " WHERE m.enabled=1 AND m.direction='pull'"
            " AND u.name=? AND r.name=?",
            -1, &st, NULL);
    else
        sqlite3_prepare_v2(app->db,
            "SELECT m.id,m.url,m.refspec,r.id,m.repo_id,u.name,r.name"
            " FROM repo_mirrors m"
            " JOIN repos r ON r.id=m.repo_id"
            " JOIN users u ON u.id=r.owner_id WHERE m.enabled=1"
            " AND m.direction='pull'",
            -1, &st, NULL);
    if (owner && name) {
        sqlite3_bind_text(st, 1, owner, -1, SQLITE_TRANSIENT);
        sqlite3_bind_text(st, 2, name, -1, SQLITE_TRANSIENT);
    }
    int fails = 0;
    while (sqlite3_step(st) == SQLITE_ROW) {
        sqlite3_int64 mid = sqlite3_column_int64(st, 0);
        const char *url = (const char *)sqlite3_column_text(st, 1);
        const char *spec = (const char *)sqlite3_column_text(st, 2);
        sqlite3_int64 rid = sqlite3_column_int64(st, 4);
        const char *o = (const char *)sqlite3_column_text(st, 5);
        const char *n = (const char *)sqlite3_column_text(st, 6);
        /* copy row text: sync_row writes to the db while this
         * SELECT is still iterating */
        char ubuf[512], sbuf[256], obuf[128], nbuf[128];
        snprintf(ubuf, sizeof ubuf, "%s", url ? url : "");
        snprintf(sbuf, sizeof sbuf, "%s", spec ? spec : "");
        snprintf(obuf, sizeof obuf, "%s", o ? o : "");
        snprintf(nbuf, sizeof nbuf, "%s", n ? n : "");
        printf("mirror %s/%s <- %s\n", obuf, nbuf, ubuf);
        char rdir[PATH_MAX];
        if (repo_path(rdir, sizeof rdir, app->repos_root, obuf, nbuf) == 0)
            sync_row(app, mid, rid, rdir, ubuf, sbuf);
        /* fetch rc is recorded in the row; read it back for exit code */
        sqlite3_stmt *q;
        sqlite3_prepare_v2(app->db,
            "SELECT last_ok FROM repo_mirrors WHERE id=?", -1, &q, NULL);
        sqlite3_bind_int64(q, 1, mid);
        if (sqlite3_step(q) == SQLITE_ROW && !sqlite3_column_int(q, 0))
            fails++;
        sqlite3_finalize(q);
    }
    sqlite3_finalize(st);
    return fails;
}

/* --- push mirrors (direction='push') -------------------------------
 * Triggered by the local post-receive hook after refs/heads moves.
 * Loop safety: pushing an up-to-date ref runs no remote hooks, so a
 * two-way A<->B configuration converges in one extra hop; genuinely
 * divergent mirrors conflict only under concurrent human pushes.
 * Credentials may live inside the URL (https://user:pass@host) —
 * the stored error log is scrubbed before it can leak them. */
static void scrub_creds(char *log, const char *url)
{
    const char *sc = strstr(url, "://");
    const char *at = sc ? strchr(sc + 3, '@') : NULL;
    const char *co = at ? memchr(sc + 3, ':', (size_t)(at - (sc + 3)))
                        : NULL;
    if (!co)
        return;
    /* rewrite "user:pass@" occurrences as "user:***@" */
    char pat[160];
    int plen = (int)(at - (sc + 3));
    if (plen <= 0 || plen >= (int)sizeof pat)
        return;
    memcpy(pat, sc + 3, (size_t)plen);
    pat[plen] = '\0';
    size_t unlen = (size_t)(co - (sc + 3));
    char rep[170];
    snprintf(rep, sizeof rep, "%.*s***@", (int)unlen, sc + 3);
    char *p = log;
    while ((p = strstr(p, pat)) != NULL) {
        size_t tlen = strlen(p + plen);
        memmove(p + strlen(rep), p + plen, tlen + 1);
        memcpy(p, rep, strlen(rep));
        p += strlen(rep);       /* replacement never re-matches pat */
    }
}

static void push_row(struct app_ctx *app, sqlite3_int64 mid,
                     sqlite3_int64 repo_id, const char *rdir,
                     const char *url, const char *spec)
{
    char log[4096];
    int rc = remote_run(rdir, "push", url, spec, log, sizeof log - 1);
    scrub_creds(log, url);
    /* how many local heads does the spec cover */
    struct buf out;
    buf_init(&out);
    const char *argv[] = { "git", "for-each-ref",
                           "--format=%(refname)", "refs/heads", NULL };
    git_capture(rdir, argv, &out);
    int nrefs = 0;
    for (char *p = out.data; p && *p; p++)
        if (*p == '\n')
            nrefs++;
    buf_free(&out);
    char *e = log + strlen(log);
    while (e > log && (e[-1] == '\n' || e[-1] == ' ' || e[-1] == '\t'))
        *--e = '\0';
    sqlite3_stmt *st;
    sqlite3_prepare_v2(app->db,
        "UPDATE repo_mirrors SET last_ts=strftime('%s','now'),"
        " last_ok=?, last_err=?, last_refs=? WHERE id=?", -1, &st, NULL);
    sqlite3_bind_int(st, 1, rc == 0);
    sqlite3_bind_text(st, 2, log, -1, SQLITE_TRANSIENT);
    sqlite3_bind_int(st, 3, nrefs);
    sqlite3_bind_int64(st, 4, mid);
    sqlite3_step(st);
    sqlite3_finalize(st);
    db_audit(app->db, 0, "mirror", NULL, repo_id, "mirror_push",
             "rc=%d refs=%d", rc, nrefs);
}

int gt_mirror_push(struct app_ctx *app, const char *owner, const char *name)
{
    sqlite3_stmt *st;
    if (owner && name)
        sqlite3_prepare_v2(app->db,
            "SELECT m.id,m.url,m.refspec,m.repo_id,u.name,r.name"
            " FROM repo_mirrors m"
            " JOIN repos r ON r.id=m.repo_id"
            " JOIN users u ON u.id=r.owner_id"
            " WHERE m.enabled=1 AND m.direction='push'"
            " AND u.name=? AND r.name=?",
            -1, &st, NULL);
    else
        sqlite3_prepare_v2(app->db,
            "SELECT m.id,m.url,m.refspec,m.repo_id,u.name,r.name"
            " FROM repo_mirrors m"
            " JOIN repos r ON r.id=m.repo_id"
            " JOIN users u ON u.id=r.owner_id WHERE m.enabled=1"
            " AND m.direction='push'",
            -1, &st, NULL);
    if (owner && name) {
        sqlite3_bind_text(st, 1, owner, -1, SQLITE_TRANSIENT);
        sqlite3_bind_text(st, 2, name, -1, SQLITE_TRANSIENT);
    }
    int fails = 0;
    while (sqlite3_step(st) == SQLITE_ROW) {
        sqlite3_int64 mid = sqlite3_column_int64(st, 0);
        const char *url = (const char *)sqlite3_column_text(st, 1);
        const char *spec = (const char *)sqlite3_column_text(st, 2);
        sqlite3_int64 rid = sqlite3_column_int64(st, 3);
        const char *o = (const char *)sqlite3_column_text(st, 4);
        const char *n = (const char *)sqlite3_column_text(st, 5);
        char ubuf[512], sbuf[256], obuf[128], nbuf[128];
        snprintf(ubuf, sizeof ubuf, "%s", url ? url : "");
        snprintf(sbuf, sizeof sbuf, "%s", spec ? spec : "");
        snprintf(obuf, sizeof obuf, "%s", o ? o : "");
        snprintf(nbuf, sizeof nbuf, "%s", n ? n : "");
        char masked[512];
        mirror_url_mask(ubuf, masked, sizeof masked);
        printf("push-mirror %s/%s -> %s\n", obuf, nbuf, masked);
        char rdir[PATH_MAX];
        if (repo_path(rdir, sizeof rdir, app->repos_root, obuf, nbuf) == 0)
            push_row(app, mid, rid, rdir, ubuf, sbuf);
        sqlite3_stmt *q;
        sqlite3_prepare_v2(app->db,
            "SELECT last_ok FROM repo_mirrors WHERE id=?", -1, &q, NULL);
        sqlite3_bind_int64(q, 1, mid);
        if (sqlite3_step(q) == SQLITE_ROW && !sqlite3_column_int(q, 0))
            fails++;
        sqlite3_finalize(q);
    }
    sqlite3_finalize(st);
    return fails;
}

void mirror_trigger(struct app_ctx *app, struct request *r,
                    const char *owner, const char *name)
{
    if (strcmp(r->method, "POST") != 0) {
        http_error(r, 405, "method not allowed");
        return;
    }
    sqlite3_int64 repo_id = 0;
    {
        sqlite3_stmt *q;
        sqlite3_prepare_v2(app->db,
            "SELECT r.id FROM repos r JOIN users u ON u.id=r.owner_id"
            " WHERE u.name=? AND r.name=?", -1, &q, NULL);
        sqlite3_bind_text(q, 1, owner, -1, SQLITE_TRANSIENT);
        sqlite3_bind_text(q, 2, name, -1, SQLITE_TRANSIENT);
        if (sqlite3_step(q) == SQLITE_ROW)
            repo_id = sqlite3_column_int64(q, 0);
        sqlite3_finalize(q);
    }

    /* collect the secrets of enabled mirrors before touching the
     * signature: a repo with no mirrors must look exactly like a
     * repo that does not exist */
    char secs[8][200];
    int nsec = 0;
    if (repo_id) {
        sqlite3_stmt *st;
        sqlite3_prepare_v2(app->db,
            "SELECT secret FROM repo_mirrors"
            " WHERE repo_id=? AND enabled=1 AND secret<>''"
            " AND direction='pull'",
            -1, &st, NULL);
        sqlite3_bind_int64(st, 1, repo_id);
        while (sqlite3_step(st) == SQLITE_ROW && nsec < 8) {
            const char *s = (const char *)sqlite3_column_text(st, 0);
            snprintf(secs[nsec++], sizeof secs[0], "%s",
                     s ? s : "");
        }
        sqlite3_finalize(st);
    }
    if (!repo_id || !nsec) {
        http_error(r, 404, "no such repository");
        return;
    }

    /* req_header values are not NUL-terminated — the value ends at
     * \r or \n.  Validate exactly "sha256=" + 64 hex digits. */
    const char *sig = req_header(r, "x-git-trace-signature");
    int sigok = sig && strncmp(sig, "sha256=", 7) == 0;
    if (sigok) {
        for (int i = 7; i < 71 && sigok; i++)
            sigok = isxdigit((unsigned char)sig[i]) != 0;
        sigok = sigok && (sig[71] == '\r' || sig[71] == '\n' ||
                          sig[71] == '\0');
    }
    if (!sigok) {
        http_error(r, 403, "bad signature");
        return;
    }
    size_t blen = 0;
    char *body = gt_read_form(r, 65536, &blen);
    if (!body) {
        http_error(r, 400, "bad request");
        return;
    }
    int ok = 0;
    for (int i = 0; i < nsec && !ok; i++) {
        uint8_t mac[32];
        char hex[65];
        gt_hmac_sha256((const uint8_t *)secs[i], strlen(secs[i]),
                       (const uint8_t *)body, blen, mac);
        to_hex(mac, 32, hex);
        if (strncasecmp(hex, sig + 7, 64) == 0)
            ok = 1;
    }
    free(body);
    if (!ok) {
        http_error(r, 403, "bad signature");
        return;
    }

    char arg[300];
    snprintf(arg, sizeof arg, "%s/%s", owner, name);
    gt_spawn_task_arg("mirror-sync", arg);
    db_audit(app->db, 0, "webhook", r->remote_ip, repo_id,
             "mirror_trigger", "%s/%s", owner, name);
    const char *msg = "sync queued\n";
    http_respond(r, 202, "text/plain", msg, strlen(msg));
}