git-trace

romele / git-trace public

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

src/httpd.c

#include "httpd.h"

#include <arpa/inet.h>
#include <ctype.h>
#include <errno.h>
#include <netdb.h>
#include <netinet/in.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <unistd.h>

void http_write_all(int fd, const void *p, size_t n)
{
    const char *c = p;
    while (n > 0) {
        ssize_t w = write(fd, c, n);
        if (w < 0) {
            if (errno == EINTR)
                continue;
            return;
        }
        c += w;
        n -= (size_t)w;
    }
}

/* ---------------- request parsing ---------------- */

static char *find_eoh(char *buf, size_t len)
{
    for (size_t i = 0; i + 3 < len; i++)
        if (buf[i] == '\r' && buf[i+1] == '\n' &&
            buf[i+2] == '\r' && buf[i+3] == '\n')
            return buf + i;
    return NULL;
}

static int read_request(int fd, struct request *r)
{
    char *buf = xmalloc(HDR_MAX + 1);
    size_t len = 0;
    char *eoh = NULL;

    while (!eoh && len < HDR_MAX) {
        ssize_t n = read(fd, buf + len, HDR_MAX - len);
        if (n <= 0) {
            free(buf);
            return -1;
        }
        len += (size_t)n;
        buf[len] = '\0';
        eoh = find_eoh(buf, len);
    }
    if (!eoh) {
        free(buf);
        return -1; /* header too large */
    }

    size_t hdrlen = (size_t)(eoh - buf) + 4;
    size_t over  = len - hdrlen;

    /* request line: METHOD SP URI SP HTTP/1.x */
    char *sp1 = memchr(buf, ' ', eoh - buf);
    if (!sp1 || (size_t)(sp1 - buf) >= sizeof r->method)
        goto bad;
    memcpy(r->method, buf, (size_t)(sp1 - buf));
    r->method[sp1 - buf] = '\0';

    char *uri = sp1 + 1;
    char *sp2 = memchr(uri, ' ', (size_t)(eoh - uri));
    if (!sp2)
        goto bad;
    size_t urilen = (size_t)(sp2 - uri);
    if (urilen == 0 || urilen >= URI_MAX)
        goto bad;
    char uridec[URI_MAX];
    {
        char *d = url_decode(uri, urilen);
        snprintf(uridec, sizeof uridec, "%s", d);
        free(d);
    }

    char *q = strchr(uridec, '?');
    if (q) {
        *q = '\0';
        snprintf(r->query, sizeof r->query, "%s", q + 1);
    } else {
        r->query[0] = '\0';
    }
    if (uridec[0] != '/' || strlen(uridec) >= sizeof r->path)
        goto bad;
    strcpy(r->path, uridec);

    /* header block: copy lines after the first */
    char *hline = strchr(buf, '\n');
    hline = hline ? hline + 1 : eoh;
    size_t hsize = (size_t)(eoh - hline);
    r->hdrs = xmalloc(hsize + 1);
    memcpy(r->hdrs, hline, hsize);
    r->hdrs[hsize] = '\0';

    /* body overread */
    if (over) {
        r->over = xmalloc(over);
        memcpy(r->over, eoh + 4, over);
        r->over_len = over;
    }
    free(buf);

    r->content_length = -1;
    const char *cl = req_header(r, "content-length");
    if (cl)
        r->content_length = strtol(cl, NULL, 10);
    r->body_left = r->content_length > 0 ? r->content_length : 0;
    const char *te = req_header(r, "transfer-encoding");
    if (te && strcasestr(te, "chunked"))
        r->chunked = 1;
    return 0;

bad:
    free(buf);
    return -1;
}

const char *req_header(struct request *r, const char *name)
{
    if (!r->hdrs)
        return NULL;
    size_t nl = strlen(name);
    const char *p = r->hdrs;
    while (*p) {
        const char *eol = strchr(p, '\n');
        size_t linelen = eol ? (size_t)(eol - p) : strlen(p);
        if (linelen > nl + 1 &&
            strncasecmp(p, name, nl) == 0 && p[nl] == ':') {
            const char *v = p + nl + 1;
            while (*v == ' ' || *v == '\t')
                v++;
            return v; /* points into hdrs; value ends at \r or \n */
        }
        p = eol ? eol + 1 : p + linelen;
    }
    return NULL;
}

char *req_header_dup(struct request *r, const char *name)
{
    const char *v = req_header(r, name);
    if (!v)
        return NULL;
    const char *e = v;
    while (*e && *e != '\r' && *e != '\n')
        e++;
    while (e > v && (e[-1] == ' ' || e[-1] == '\t'))
        e--;
    size_t n = (size_t)(e - v);
    char *out = xmalloc(n + 1);
    memcpy(out, v, n);
    out[n] = '\0';
    return out;
}

int req_query(struct request *r, const char *key, char *out, size_t outlen)
{
    size_t kl = strlen(key);
    const char *p = r->query;
    while (*p) {
        const char *amp = strchr(p, '&');
        size_t plen = amp ? (size_t)(amp - p) : strlen(p);
        const char *eq = memchr(p, '=', plen);
        if (eq && (size_t)(eq - p) == kl && strncmp(p, key, kl) == 0) {
            char *d = url_decode(eq + 1, plen - (size_t)(eq - p) - 1);
            int rc = snprintf(out, outlen, "%s", d);
            free(d);
            return (rc < 0 || (size_t)rc >= outlen) ? -1 : 0;
        }
        p = amp ? amp + 1 : p + plen;
    }
    return -1;
}

/* ---------------- chunked request bodies ---------------- */

/* raw stream for the dechunker: buffered overread first, then socket,
 * unbounded (body_left is meaningless for chunked input) */
static ssize_t raw_read(struct request *r, void *buf, size_t n)
{
    size_t avail = r->over_len - r->over_off;
    if (avail) {
        size_t take = avail < n ? avail : n;
        memcpy(buf, r->over + r->over_off, take);
        r->over_off += take;
        return (ssize_t)take;
    }
    return read(r->fd, buf, n);
}

/* one CRLF-terminated line from the raw stream (chunk headers and
 * trailers are tiny); returns line length without CRLF, -1 on error */
static ssize_t raw_line(struct request *r, char *out, size_t cap)
{
    size_t n = 0;
    for (;;) {
        char c;
        if (raw_read(r, &c, 1) <= 0)
            return -1;
        if (c == '\r') {
            char lf;
            if (raw_read(r, &lf, 1) <= 0 || lf != '\n')
                return -1;
            break;
        }
        if (c == '\n')
            break;
        if (n + 1 >= cap)
            return -1;
        out[n++] = c;
    }
    out[n] = '\0';
    return (ssize_t)n;
}

static long body_max(void)
{
    const char *e = getenv("GIT_TRACE_BODY_MAX");
    long v = e ? strtol(e, NULL, 10) : 0;
    return v > 0 ? v : 256L * 1024 * 1024;
}

/* Decode a chunked body eagerly: raw framing comes from over+socket,
 * decoded bytes replace r->over and become the plain Content-Length
 * body every downstream consumer already understands.
 * Returns 0, -1 bad framing, -2 over GIT_TRACE_BODY_MAX. */
static int body_dechunk(struct request *r)
{
    long max = body_max();
    struct buf out;
    buf_init(&out);
    char line[128];
    for (;;) {
        ssize_t ln = raw_line(r, line, sizeof line);
        if (ln <= 0)
            goto bad;
        char *end;
        unsigned long sz = strtoul(line, &end, 16);
        if (end == line || (*end != '\0' && *end != ';'))
            goto bad;
        if (sz == 0) {
            for (;;) {              /* trailers: drain to empty line */
                ln = raw_line(r, line, sizeof line);
                if (ln < 0)
                    goto bad;
                if (ln == 0)
                    break;
            }
            break;
        }
        if (sz > (unsigned long)max ||
            out.len > (size_t)max - (size_t)sz)
            goto big;
        while (sz > 0) {
            char tmp[8192];
            size_t take = sz < sizeof tmp ? (size_t)sz : sizeof tmp;
            ssize_t got = raw_read(r, tmp, take);
            if (got <= 0)
                goto bad;
            buf_put(&out, tmp, (size_t)got);
            sz -= (unsigned long)got;
        }
        if (raw_line(r, line, sizeof line) != 0)
            goto bad;               /* chunk data must end with CRLF */
    }
    free(r->over);
    r->over = out.data ? out.data : xmalloc(1);
    r->over_len = out.len;
    r->over_off = 0;
    r->content_length = (long)out.len;
    r->body_left = (long)out.len;
    return 0;
big:
    buf_free(&out);
    return -2;
bad:
    buf_free(&out);
    return -1;
}

long body_read(struct request *r, void *buf, size_t n)
{
    if (r->body_left <= 0)
        return 0;
    size_t avail = r->over_len - r->over_off;
    if (avail) {
        size_t take = avail < n ? avail : n;
        if ((long)take > r->body_left)
            take = (size_t)r->body_left;
        memcpy(buf, r->over + r->over_off, take);
        r->over_off += take;
        r->body_left -= (long)take;
        return (long)take;
    }
    if ((long)n > r->body_left)
        n = (size_t)r->body_left;
    ssize_t got = read(r->fd, buf, n);
    if (got <= 0)
        return got;
    r->body_left -= got;
    return got;
}

/* ---------------- responses ---------------- */

void http_write_head(int fd, int code)
{
    char head[128];
    int n = snprintf(head, sizeof head, "HTTP/1.1 %d %s\r\n", code,
                     status_text(code));
    http_write_all(fd, head, (size_t)n);
}

/* baseline headers on every response: no sniffing, no framing,
 * no referrer leakage, and a CSP that only allows our inline styles */
#define SEC_HEADERS \
    "X-Content-Type-Options: nosniff\r\n" \
    "X-Frame-Options: DENY\r\n" \
    "Referrer-Policy: no-referrer\r\n" \
    "Content-Security-Policy: default-src 'none'; " \
    "style-src 'unsafe-inline'\r\n"

void http_respond_csp(struct request *r, int code, const char *ctype,
                      const char *csp, const char *body, size_t n)
{
    char head[1024];
    int hn = snprintf(head, sizeof head,
        "HTTP/1.1 %d %s\r\nContent-Type: %s\r\nContent-Length: %zu\r\n"
        "X-Content-Type-Options: nosniff\r\n"
        "X-Frame-Options: DENY\r\n"
        "Referrer-Policy: no-referrer\r\n"
        "Content-Security-Policy: %s\r\n"
        "Connection: close\r\n\r\n",
        code, status_text(code), ctype, n,
        csp ? csp : "default-src 'none'; style-src 'unsafe-inline'");
    http_write_all(r->fd, head, (size_t)hn);
    if (n)
        http_write_all(r->fd, body, n);
}

void http_respond(struct request *r, int code, const char *ctype,
                  const char *body, size_t n)
{
    http_respond_csp(r, code, ctype, NULL, body, n);
}

void http_error(struct request *r, int code, const char *msg)
{
    char body[1024];
    int n = snprintf(body, sizeof body, "%d %s: %s\n",
                     code, status_text(code), msg ? msg : "");
    http_respond(r, code, "text/plain; charset=utf-8", body, (size_t)n);
}

void http_unauthorized(struct request *r, const char *msg)
{
    char body[512];
    int bn = snprintf(body, sizeof body, "401 Unauthorized: %s\n", msg);
    char head[768];
    int hn = snprintf(head, sizeof head,
        "HTTP/1.1 401 Unauthorized\r\n"
        "WWW-Authenticate: Basic realm=\"git-trace\", charset=\"UTF-8\"\r\n"
        "Content-Type: text/plain; charset=utf-8\r\n"
        SEC_HEADERS
        "Content-Length: %d\r\nConnection: close\r\n\r\n", bn);
    http_write_all(r->fd, head, (size_t)hn);
    http_write_all(r->fd, body, (size_t)bn);
}

void http_redirect(struct request *r, const char *location)
{
    http_redirect_cookie(r, location, NULL);
}

void http_redirect_cookie(struct request *r, const char *location,
                          const char *set_cookie)
{
    char head[1400];
    int n = snprintf(head, sizeof head,
        "HTTP/1.1 302 Found\r\nLocation: %s\r\n%s%s"
        SEC_HEADERS
        "Content-Length: 0\r\nConnection: close\r\n\r\n",
        location,
        set_cookie ? "Set-Cookie: " : "",
        set_cookie ? set_cookie : "");
    http_write_all(r->fd, head, (size_t)n);
}

/* ---------------- accept loop ---------------- */

struct conn_arg {
    int   fd;
    void *ctx;
    route_fn route;
    const char *alt_origin;         /* extra allowed Origin host */
    char  ip[64];
};

/* hard cap on in-flight connections: each is a thread + likely a
 * forked git child, so unbounded growth is a fork bomb on ourselves */
#define MAX_CONN 128
static _Atomic int active_conns;

/*
 * POST/PUT/DELETE with an Origin header must come from our own
 * origin — blocks cross-site form posts even if a CSRF token were
 * ever mishandled (also covers login CSRF).  No Origin header means
 * a non-browser client (curl, git) and is fine.
 */
static int origin_host_eq(const char *oh, size_t on, const char *h)
{
    return h && strlen(h) == on && strncasecmp(oh, h, on) == 0;
}

/* is "host[:port]" or "[v6]:port" a loopback address? */
static int host_is_loopback(const char *h, size_t n)
{
    char hb[80];
    if (!h || n == 0 || n >= sizeof hb)
        return 0;
    memcpy(hb, h, n);
    hb[n] = '\0';
    char *p = hb;
    if (*p == '[') {
        char *e = strchr(p, ']');
        if (!e)
            return 0;
        *e = '\0';
        p++;
    } else if (strchr(p, ':') && strchr(p, ':') == strrchr(p, ':')) {
        *strchr(p, ':') = '\0';      /* single colon: strip :port */
    }
    /* multiple colons = bare IPv6 literal — compared as-is below */
    return !strcasecmp(p, "localhost") || !strcmp(p, "127.0.0.1") ||
           !strcmp(p, "::1");
}

static int origin_ok(struct request *r, const char *alt)
{
    if (strcmp(r->method, "GET") == 0 || strcmp(r->method, "HEAD") == 0)
        return 1;
    char *origin = req_header_dup(r, "origin");
    if (!origin)
        return 1;
    char *host = req_header_dup(r, "host");
    int ok = 0;
    const char *oh = strstr(origin, "://");
    oh = oh ? oh + 3 : origin;
    const char *os = strchr(oh, '/');
    size_t on = os ? (size_t)(os - oh) : strlen(oh);
    if (host && origin_host_eq(oh, on, host))
        ok = 1;
    /* behind a reverse proxy Host != the browser's origin; the
     * configured public host is an equally valid origin */
    if (!ok && origin_host_eq(oh, on, alt))
        ok = 1;
    /* loopback-facing daemon (dev box, IDE preview proxies): accept
     * any loopback origin regardless of port.  A remote page cannot
     * mint a loopback Origin — the name stays the attacker's domain —
     * so this admits only processes already running locally. */
    if (!ok && host && host_is_loopback(host, strlen(host)) &&
        host_is_loopback(oh, on))
        ok = 1;
    /* "Origin: null" is sent by sandboxed embedders, privacy tools
     * that strip Origin (Tor, Brave), and cross-origin redirects —
     * our own www -> apex and http -> https Caddy redirects turn a
     * POST's Origin into "null".  Accepting it is safe everywhere:
     * every session-bound POST still requires the CSRF token, which
     * a cross-origin attacker cannot read — only the anonymous
     * routes (register/login) are reachable this way. */
    if (!ok && on == 4 && strncmp(oh, "null", 4) == 0)
        ok = 1;
    if (!ok)
        fprintf(stderr, "origin refused: origin='%.*s' host='%s'\n",
                (int)on, oh, host ? host : "-");
    free(origin);
    free(host);
    return ok;
}

static void *conn_thread(void *p)
{
    struct conn_arg *a = p;
    struct request r;
    memset(&r, 0, sizeof r);
    r.fd = a->fd;
    snprintf(r.remote_ip, sizeof r.remote_ip, "%s", a->ip);
    if (read_request(a->fd, &r) == 0) {
        /* git/curl hold big bodies until they see this */
        const char *exp = req_header(&r, "expect");
        if (exp && strcasestr(exp, "100-continue"))
            http_write_all(r.fd, "HTTP/1.1 100 Continue\r\n\r\n", 25);
        int drc = r.chunked ? body_dechunk(&r) : 0;
        if (drc == -2)
            http_error(&r, 413, "request body too large");
        else if (drc != 0)
            http_error(&r, 400, "malformed chunked body");
        else if (!origin_ok(&r, a->alt_origin))
            http_error(&r, 403, "cross-origin request refused");
        else
            a->route(a->ctx, &r);
    } else {
        http_error(&r, 400, "malformed request");
    }
    shutdown(a->fd, SHUT_RDWR);
    close(a->fd);
    free(r.hdrs);
    free(r.over);
    free(a);
    active_conns--;
    return NULL;
}

void httpd_run(int port, void *ctx, route_fn route,
               const char *alt_origin)
{
    int ls = socket(AF_INET6, SOCK_STREAM, 0);
    if (ls < 0)
        die("socket");
    int one = 1;
    setsockopt(ls, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one);
    int v6only = 0;
    setsockopt(ls, IPPROTO_IPV6, IPV6_V6ONLY, &v6only, sizeof v6only);

    struct sockaddr_in6 sa;
    memset(&sa, 0, sizeof sa);
    sa.sin6_family = AF_INET6;
    sa.sin6_addr = in6addr_any;
    sa.sin6_port = htons((uint16_t)port);
    if (bind(ls, (struct sockaddr *)&sa, sizeof sa) < 0)
        die("bind");
    if (listen(ls, 128) < 0)
        die("listen");

    fprintf(stderr, "git-trace: listening on port %d\n", port);
    for (;;) {
        struct sockaddr_storage ss;
        socklen_t sl = sizeof ss;
        int fd = accept(ls, (struct sockaddr *)&ss, &sl);
        if (fd < 0) {
            if (errno == EINTR)
                continue;
            perror("accept");
            continue;
        }
        if (active_conns >= MAX_CONN) {
            static const char busy[] =
                "HTTP/1.1 503 Service Unavailable\r\n"
                "Content-Length: 9\r\nConnection: close\r\n\r\noverloaded";
            http_write_all(fd, busy, sizeof busy - 1);
            close(fd);
            continue;
        }
        struct timeval rcv = { .tv_sec = 60 }, snd = { .tv_sec = 300 };
        setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &rcv, sizeof rcv);
        setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &snd, sizeof snd);

        struct conn_arg *a = xmalloc(sizeof *a);
        a->fd = fd;
        a->ctx = ctx;
        a->route = route;
        a->alt_origin = alt_origin;
        char host[64] = "?";
        getnameinfo((struct sockaddr *)&ss, sl, host, sizeof host,
                    NULL, 0, NI_NUMERICHOST);
        snprintf(a->ip, sizeof a->ip, "%s", host);
        active_conns++;
        pthread_t th;
        if (pthread_create(&th, NULL, conn_thread, a) != 0) {
            active_conns--;
            close(fd);
            free(a);
            continue;
        }
        pthread_detach(th);
    }
}