// calogHttp.c -- calog HTTP client library (see calogHttp.h). A self-contained HTTP/1.1 // client: parse the URL, connect (getaddrinfo + socket + connect), optionally wrap the socket // in OpenSSL TLS for https, send the request with "Connection: close", read the entire // response to EOF, and decode it (status line, lowercased headers into a map, and a body that // honors Transfer-Encoding: chunked / Content-Length / read-to-close). No shared state: every // native is a self-contained connection, so the natives are INLINE and there is nothing to // shut down. https verifies the server certificate + hostname against the system CA store by // default; httpRequest can pass insecure=true to skip verification (self-signed / dev). #define _GNU_SOURCE #include "calogHttp.h" #include "calogInternal.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #define HTTP_BUF_INITIAL 256 #define HTTP_READ_CHUNK 16384 #define HTTP_MAX_RESPONSE (64 * 1024 * 1024) #define HTTP_HOST_MAX 256 #define HTTP_PORT_MAX 16 #define HTTP_PATH_MAX 4096 // Connect, read, and write are all bounded by this single deadline (seconds): a stalled or // blackholed peer must not hang the calling script thread (or a shutdown joining it) forever. #define HTTP_TIMEOUT_SEC 30 #define HTTP_PORT_HTTP "80" #define HTTP_PORT_HTTPS "443" // Follow 3xx redirects, but stop after this many hops so a redirect loop cannot spin forever. #define HTTP_MAX_REDIRECTS 16 #define HTTP_METHOD_MAX 16 #define HTTP_URL_MAX (HTTP_HOST_MAX + HTTP_PORT_MAX + HTTP_PATH_MAX + 32) // A growable output byte buffer for the request and for decoded bodies. typedef struct HttpBufT { char *bytes; size_t length; size_t cap; } HttpBufT; // A parsed absolute URL. host/port/path are NUL-terminated so they double as C strings. ipv6 is // true when the URL wrote the host as a bracketed literal ("[::1]"); host itself is stored // WITHOUT the brackets (getaddrinfo/SNI/certificate-hostname all want the bare address), and // the Host header re-adds them. typedef struct HttpUrlT { bool https; bool ipv6; char host[HTTP_HOST_MAX]; char port[HTTP_PORT_MAX]; char path[HTTP_PATH_MAX]; } HttpUrlT; // One connection: a plain socket, or a TLS session layered over it when ssl != NULL. verify // requests certificate + hostname authentication for an https connection (default; an https // request can opt out with insecure=true). typedef struct HttpConnT { int fd; SSL *ssl; SSL_CTX *ctx; bool verify; } HttpConnT; static CalogValueT *httpAggField(CalogAggT *agg, const char *name); static int32_t httpAppendHeaders(HttpBufT *buf, const CalogAggT *headers); static int32_t httpBufEnsure(HttpBufT *buf, size_t extra); static void httpBufFree(HttpBufT *buf); static int32_t httpBufPutBytes(HttpBufT *buf, const char *bytes, size_t length); static int32_t httpBufPutStr(HttpBufT *buf, const char *s); static int32_t httpBuildResult(const char *raw, size_t rawLen, CalogValueT *result); static void httpConnClose(HttpConnT *conn); static bool httpConnectTimeout(int fd, const struct sockaddr *addr, socklen_t addrLen, int timeoutSec); static int32_t httpConnOpen(HttpConnT *conn, const HttpUrlT *url, CalogValueT *result); static ssize_t httpConnRead(HttpConnT *conn, char *buf, size_t length); static int32_t httpConnWriteAll(HttpConnT *conn, const char *bytes, size_t length); static bool httpContainsChunked(const char *bytes, size_t length); static bool httpCopyField(char *dst, size_t cap, const char *src, size_t length); static int32_t httpDechunk(const char *bytes, size_t length, HttpBufT *out); static bool httpDefaultPort(const HttpUrlT *url); static CalogAggT *httpFilterHeaders(const CalogAggT *headers, bool dropCreds, bool dropContent); static int32_t httpFollow(const char *method, const char *urlBytes, int64_t urlLen, const CalogAggT *headers, const char *body, int64_t bodyLen, bool verify, int32_t maxRedirects, CalogValueT *result); static int32_t httpGetNative(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData); static bool httpHasBadByte(const char *bytes, size_t length, bool rejectSpace); static bool httpHeaderIn(const char *name, int64_t len, const char *const *list); static int32_t httpHexVal(char c); static bool httpIsRedirect(int32_t code); static char httpLower(char c); static int32_t httpMapSetAgg(CalogAggT *map, const char *key, CalogAggT *inner); static bool httpMethodHasBody(const char *method); static CalogValueT *httpOptGet(CalogAggT *opts, const char *name, int32_t *status); static int64_t httpParseInt(const char *bytes, size_t length); static int32_t httpParseStatus(const char *line, size_t length, int32_t *codeOut); static int32_t httpParseUrl(const char *url, int64_t urlLen, HttpUrlT *out, CalogValueT *result); static int32_t httpPerform(const char *method, const char *urlBytes, int64_t urlLen, const CalogAggT *headers, const char *body, int64_t bodyLen, bool verify, CalogValueT *result); static int32_t httpReadResponse(HttpConnT *conn, HttpBufT *raw); static int32_t httpRequestNative(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData); static bool httpResolveLocation(const HttpUrlT *base, const char *loc, char *out, size_t cap); static const CalogValueT *httpResultHeader(const CalogValueT *result, const char *name); static int32_t httpResultStatus(const CalogValueT *result); static bool httpSameOrigin(const HttpUrlT *a, const HttpUrlT *b); static void httpSetTimeouts(int fd, int timeoutSec); static int32_t httpTlsHandshake(HttpConnT *conn, const HttpUrlT *url, CalogValueT *result); static int httpWriteAuthority(char *out, size_t cap, const char *scheme, const HttpUrlT *base); int32_t calogHttpRegister(CalogT *calog) { // SSL_write/SSL_shutdown (and plain send()) can hit a broken pipe if the peer resets or // closes mid-write; with the default disposition that raises SIGPIPE and kills the whole // broker process. The plain path already passes MSG_NOSIGNAL, but OpenSSL's socket BIO does // not, so ignore SIGPIPE process-wide (idempotent; safe to call from every ...Register). signal(SIGPIPE, SIG_IGN); calogRegisterInline(calog, "httpGet", httpGetNative, NULL); calogRegisterInline(calog, "httpRequest", httpRequestNative, NULL); return calogOkE; } // Look up a string-keyed field in an aggregate with a stack key (no allocation). The returned // pointer aliases the aggregate and is valid only until it is freed or mutated. static CalogValueT *httpAggField(CalogAggT *agg, const char *name) { CalogValueT key; calogValueNil(&key); key.type = calogStringE; key.as.s.bytes = (char *)name; key.as.s.length = (int64_t)strlen(name); return calogAggGet(agg, &key); } static int32_t httpAppendHeaders(HttpBufT *buf, const CalogAggT *headers) { int64_t index; int32_t status; // Only string:string pairs become request headers; anything else is skipped. The bytes are // binary-safe (length-carried), so a header value may hold arbitrary octets. for (index = 0; index < headers->pairCount; index++) { const CalogValueT *key; const CalogValueT *val; key = &headers->pairs[index].key; val = &headers->pairs[index].value; if (key->type != calogStringE || val->type != calogStringE) { continue; } status = httpBufPutBytes(buf, key->as.s.bytes, (size_t)key->as.s.length); if (status == calogOkE) { status = httpBufPutStr(buf, ": "); } if (status == calogOkE) { status = httpBufPutBytes(buf, val->as.s.bytes, (size_t)val->as.s.length); } if (status == calogOkE) { status = httpBufPutStr(buf, "\r\n"); } if (status != calogOkE) { return status; } } return calogOkE; } static int32_t httpBufEnsure(HttpBufT *buf, size_t extra) { size_t need; size_t cap; char *grown; need = buf->length + extra; if (need <= buf->cap) { return calogOkE; } cap = (buf->cap == 0) ? HTTP_BUF_INITIAL : buf->cap; while (cap < need) { cap *= CALOG_GROWTH_FACTOR; } grown = (char *)realloc(buf->bytes, cap); if (grown == NULL) { return calogErrOomE; } buf->bytes = grown; buf->cap = cap; return calogOkE; } static void httpBufFree(HttpBufT *buf) { free(buf->bytes); buf->bytes = NULL; buf->length = 0; buf->cap = 0; } static int32_t httpBufPutBytes(HttpBufT *buf, const char *bytes, size_t length) { int32_t status; if (length == 0) { return calogOkE; } status = httpBufEnsure(buf, length); if (status != calogOkE) { return status; } memcpy(buf->bytes + buf->length, bytes, length); buf->length += length; return calogOkE; } static int32_t httpBufPutStr(HttpBufT *buf, const char *s) { return httpBufPutBytes(buf, s, strlen(s)); } static int32_t httpBuildResult(const char *raw, size_t rawLen, CalogValueT *result) { const char *sep; const char *headerBlock; const char *bodyRaw; const char *statusEnd; const char *cursor; const char *headerEnd; const char *finalBody; HttpBufT dechunked; CalogAggT *map; CalogAggT *headersMap; size_t headerLen; size_t bodyRawLen; size_t statusLen; size_t finalLen; int64_t contentLength; int32_t code; int32_t status; bool haveContentLength; bool chunked; calogValueNil(result); dechunked.bytes = NULL; dechunked.length = 0; dechunked.cap = 0; contentLength = 0; haveContentLength = false; chunked = false; // Split the head from the body at the first blank line. sep = (const char *)memmem(raw, rawLen, "\r\n\r\n", 4); if (sep == NULL) { return calogFail(result, calogErrArgE, "http: malformed response (no header terminator)"); } headerBlock = raw; headerLen = (size_t)(sep - raw); bodyRaw = sep + 4; bodyRawLen = rawLen - headerLen - 4; // The status line is the first CRLF-terminated line of the head. statusEnd = (const char *)memmem(headerBlock, headerLen, "\r\n", 2); if (statusEnd == NULL) { statusLen = headerLen; cursor = headerBlock + headerLen; } else { statusLen = (size_t)(statusEnd - headerBlock); cursor = statusEnd + 2; } headerEnd = headerBlock + headerLen; status = httpParseStatus(headerBlock, statusLen, &code); if (status != calogOkE) { return calogFail(result, calogErrArgE, "http: malformed status line"); } // Header fields -> map keyed by lowercased name; capture the body-framing headers. status = calogAggCreate(&headersMap, calogMapE); if (status != calogOkE) { return calogFail(result, status, "http: out of memory"); } while (cursor < headerEnd) { const char *lineEnd; const char *colon; size_t lineLen; lineEnd = (const char *)memmem(cursor, (size_t)(headerEnd - cursor), "\r\n", 2); if (lineEnd == NULL) { lineEnd = headerEnd; } lineLen = (size_t)(lineEnd - cursor); colon = (lineLen > 0) ? (const char *)memchr(cursor, ':', lineLen) : NULL; if (colon != NULL) { const char *nameStart; const char *valStart; char *lower; size_t nameLen; size_t valLen; size_t i; nameStart = cursor; nameLen = (size_t)(colon - cursor); valStart = colon + 1; valLen = (size_t)(lineEnd - valStart); while (valLen > 0 && (*valStart == ' ' || *valStart == '\t')) { valStart++; valLen--; } while (valLen > 0 && (valStart[valLen - 1] == ' ' || valStart[valLen - 1] == '\t')) { valLen--; } lower = (char *)malloc(nameLen + 1); if (lower == NULL) { calogAggFree(headersMap); return calogFail(result, calogErrOomE, "http: out of memory"); } for (i = 0; i < nameLen; i++) { lower[i] = httpLower(nameStart[i]); } lower[nameLen] = '\0'; status = calogMapSetStr(headersMap, lower, valStart, (int64_t)valLen); if (status != calogOkE) { free(lower); calogAggFree(headersMap); return calogFail(result, status, "http: out of memory"); } if (strcmp(lower, "content-length") == 0) { contentLength = httpParseInt(valStart, valLen); if (contentLength >= 0) { haveContentLength = true; } } else if (strcmp(lower, "transfer-encoding") == 0) { if (httpContainsChunked(valStart, valLen)) { chunked = true; } } free(lower); } if (lineEnd >= headerEnd) { break; } cursor = lineEnd + 2; } // Decode the body. Chunked takes precedence over Content-Length; absent both, read-to-close // means the raw remainder is the body. if (chunked) { status = httpDechunk(bodyRaw, bodyRawLen, &dechunked); if (status != calogOkE) { httpBufFree(&dechunked); calogAggFree(headersMap); return calogFail(result, status, "http: malformed chunked body"); } finalBody = dechunked.bytes; finalLen = dechunked.length; } else if (haveContentLength) { // A body shorter than the promised Content-Length means a read error (connection reset, // unclean TLS close) was silently mapped to EOF upstream -- surface it as a failure // rather than delivering a truncated body as a status-200 success. if (bodyRawLen < (size_t)contentLength) { httpBufFree(&dechunked); calogAggFree(headersMap); return calogFail(result, calogErrArgE, "http: response body shorter than Content-Length"); } finalBody = bodyRaw; finalLen = (size_t)contentLength; } else { finalBody = bodyRaw; finalLen = bodyRawLen; } // Assemble the outer { status, body, headers } map. status = calogAggCreate(&map, calogMapE); if (status != calogOkE) { httpBufFree(&dechunked); calogAggFree(headersMap); return calogFail(result, status, "http: out of memory"); } status = calogMapSetInt(map, "status", (int64_t)code); if (status != calogOkE) { httpBufFree(&dechunked); calogAggFree(map); calogAggFree(headersMap); return calogFail(result, status, "http: out of memory"); } status = calogMapSetStr(map, "body", (finalBody != NULL) ? finalBody : "", (int64_t)finalLen); if (status != calogOkE) { httpBufFree(&dechunked); calogAggFree(map); calogAggFree(headersMap); return calogFail(result, status, "http: out of memory"); } status = httpMapSetAgg(map, "headers", headersMap); if (status != calogOkE) { httpBufFree(&dechunked); calogAggFree(map); calogAggFree(headersMap); return calogFail(result, status, "http: out of memory"); } // headersMap is now owned by map; only our transient decode buffer remains to free. httpBufFree(&dechunked); calogValueAgg(result, map); return calogOkE; } static void httpConnClose(HttpConnT *conn) { if (conn->ssl != NULL) { SSL_shutdown(conn->ssl); SSL_free(conn->ssl); conn->ssl = NULL; } if (conn->ctx != NULL) { SSL_CTX_free(conn->ctx); conn->ctx = NULL; } // SSL_set_fd installs a BIO_NOCLOSE socket BIO, so SSL_free never closes the fd -- we own it. if (conn->fd >= 0) { close(conn->fd); conn->fd = -1; } } static bool httpConnectTimeout(int fd, const struct sockaddr *addr, socklen_t addrLen, int timeoutSec) { int flags; int rc; int err; socklen_t errLen; struct pollfd pfd; flags = fcntl(fd, F_GETFL, 0); if (flags < 0 || fcntl(fd, F_SETFL, flags | O_NONBLOCK) < 0) { return connect(fd, addr, addrLen) == 0; // best effort: no bounded-connect fallback } rc = connect(fd, addr, addrLen); if (rc == 0) { fcntl(fd, F_SETFL, flags); return true; } if (errno != EINPROGRESS) { fcntl(fd, F_SETFL, flags); return false; } pfd.fd = fd; pfd.events = POLLOUT; rc = poll(&pfd, 1, timeoutSec * 1000); if (rc <= 0) { fcntl(fd, F_SETFL, flags); return false; // timeout or poll error } errLen = sizeof(err); if (getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &errLen) < 0 || err != 0) { fcntl(fd, F_SETFL, flags); return false; } fcntl(fd, F_SETFL, flags); return true; } static int32_t httpConnOpen(HttpConnT *conn, const HttpUrlT *url, CalogValueT *result) { struct addrinfo hints; struct addrinfo *res; struct addrinfo *rp; int fd; int rc; conn->fd = -1; conn->ssl = NULL; conn->ctx = NULL; memset(&hints, 0, sizeof(hints)); hints.ai_family = AF_UNSPEC; hints.ai_socktype = SOCK_STREAM; rc = getaddrinfo(url->host, url->port, &hints, &res); if (rc != 0) { return calogFail(result, calogErrArgE, gai_strerror(rc)); } fd = -1; for (rp = res; rp != NULL; rp = rp->ai_next) { fd = socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol); if (fd < 0) { continue; } httpSetTimeouts(fd, HTTP_TIMEOUT_SEC); if (httpConnectTimeout(fd, rp->ai_addr, rp->ai_addrlen, HTTP_TIMEOUT_SEC)) { break; } close(fd); fd = -1; } freeaddrinfo(res); if (fd < 0) { return calogFail(result, calogErrArgE, "http: could not connect to host"); } conn->fd = fd; if (url->https) { int32_t status; status = httpTlsHandshake(conn, url, result); if (status != calogOkE) { httpConnClose(conn); return status; } } return calogOkE; } static ssize_t httpConnRead(HttpConnT *conn, char *buf, size_t length) { if (conn->ssl != NULL) { int chunk; int r; int err; chunk = (length > (size_t)INT_MAX) ? INT_MAX : (int)length; r = SSL_read(conn->ssl, buf, chunk); if (r > 0) { return (ssize_t)r; } // Distinguish a clean TLS close_notify (end of response, like a plain-socket EOF) from // any other error (reset, protocol violation, truncated shutdown) -- the latter must NOT // be treated as a successful end of response. err = SSL_get_error(conn->ssl, r); if (err == SSL_ERROR_ZERO_RETURN) { return 0; } return -1; } for (;;) { ssize_t r; r = recv(conn->fd, buf, length, 0); if (r < 0 && errno == EINTR) { continue; } return r; } } static int32_t httpConnWriteAll(HttpConnT *conn, const char *bytes, size_t length) { size_t sent; sent = 0; while (sent < length) { if (conn->ssl != NULL) { int chunk; int w; chunk = (length - sent > (size_t)INT_MAX) ? INT_MAX : (int)(length - sent); w = SSL_write(conn->ssl, bytes + sent, chunk); if (w <= 0) { return calogErrUnsupportedE; } sent += (size_t)w; } else { ssize_t w; w = send(conn->fd, bytes + sent, length - sent, MSG_NOSIGNAL); if (w < 0) { if (errno == EINTR) { continue; } return calogErrArgE; } sent += (size_t)w; } } return calogOkE; } static bool httpContainsChunked(const char *bytes, size_t length) { static const char needle[] = "chunked"; size_t nlen; size_t i; nlen = sizeof(needle) - 1; if (length < nlen) { return false; } for (i = 0; i + nlen <= length; i++) { size_t j; bool match; match = true; for (j = 0; j < nlen; j++) { if (httpLower(bytes[i + j]) != needle[j]) { match = false; break; } } if (match) { return true; } } return false; } static bool httpCopyField(char *dst, size_t cap, const char *src, size_t length) { if (length + 1 > cap) { return false; } memcpy(dst, src, length); dst[length] = '\0'; return true; } static int32_t httpDechunk(const char *bytes, size_t length, HttpBufT *out) { size_t pos; pos = 0; while (pos < length) { size_t size; bool any; int32_t status; size = 0; any = false; while (pos < length) { int32_t digit; digit = httpHexVal(bytes[pos]); if (digit < 0) { break; } // Reject a chunk size that would overflow size_t (a hostile server can send a huge // hex length to wrap the bounds check below). if (size > (SIZE_MAX - (size_t)digit) / 16) { return calogErrArgE; } size = size * 16 + (size_t)digit; any = true; pos++; } if (!any) { return calogErrArgE; } // Skip any chunk extension (";name=value") through the CRLF that ends the size line. while (pos < length && bytes[pos] != '\n') { pos++; } if (pos >= length) { return calogErrArgE; } pos++; if (size == 0) { break; } if (size > length - pos) { // pos <= length here, so no overflow return calogErrArgE; } status = httpBufPutBytes(out, bytes + pos, size); if (status != calogOkE) { return status; } pos += size; if (pos < length && bytes[pos] == '\r') { pos++; } if (pos < length && bytes[pos] == '\n') { pos++; } } return calogOkE; } static bool httpDefaultPort(const HttpUrlT *url) { if (url->https) { return strcmp(url->port, HTTP_PORT_HTTPS) == 0; } return strcmp(url->port, HTTP_PORT_HTTP) == 0; } // Copy headers, dropping credential headers when dropCreds (a redirect crossed to another origin) // and/or body-describing headers when dropContent (a redirect dropped the request body). Returns // an owned aggregate, or NULL on out of memory. static CalogAggT *httpFilterHeaders(const CalogAggT *headers, bool dropCreds, bool dropContent) { static const char *const creds[] = { "authorization", "cookie", "proxy-authorization", NULL }; static const char *const content[] = { "content-type", "content-length", "content-encoding", "transfer-encoding", NULL }; CalogAggT *out; int64_t index; if (calogAggCreate(&out, calogMapE) != calogOkE) { return NULL; } for (index = 0; index < headers->pairCount; index++) { const CalogValueT *key; CalogValueT keyCopy; CalogValueT valCopy; key = &headers->pairs[index].key; if (key->type == calogStringE && ((dropCreds && httpHeaderIn(key->as.s.bytes, key->as.s.length, creds)) || (dropContent && httpHeaderIn(key->as.s.bytes, key->as.s.length, content)))) { continue; } if (calogValueCopy(&keyCopy, &headers->pairs[index].key) != calogOkE) { calogAggFree(out); return NULL; } if (calogValueCopy(&valCopy, &headers->pairs[index].value) != calogOkE) { calogValueFree(&keyCopy); calogAggFree(out); return NULL; } if (calogAggSet(out, &keyCopy, &valCopy) != calogOkE) { calogValueFree(&keyCopy); calogValueFree(&valCopy); calogAggFree(out); return NULL; } } return out; } // Perform a request and follow up to maxRedirects 3xx redirects (0 disables following, handing // back the raw 3xx). Each hop reissues httpPerform against the resolved Location; the response // map of every non-final hop is freed before the next. Method/body follow the usual rules: // 303 (and 301/302 for a body-bearing method) drop to GET with no body, 307/308 are preserved. static int32_t httpFollow(const char *method, const char *urlBytes, int64_t urlLen, const CalogAggT *headers, const char *body, int64_t bodyLen, bool verify, int32_t maxRedirects, CalogValueT *result) { char curUrl[HTTP_URL_MAX]; char curMethod[HTTP_METHOD_MAX]; const char *curBody; const CalogAggT *curHeaders; CalogAggT *filtered; // owned; headers with credential/content headers stripped, or NULL HttpUrlT origin; // scheme+host+port of the ORIGINAL request, for cross-origin checks int64_t curBodyLen; int32_t redirects; int32_t status; bool bodyDropped; calogValueNil(result); filtered = NULL; curHeaders = headers; bodyDropped = false; if (urlLen < 0 || (size_t)urlLen >= sizeof(curUrl)) { return calogFail(result, calogErrArgE, "http: URL too long"); } if (strlen(method) >= sizeof(curMethod)) { return calogFail(result, calogErrArgE, "http: method too long"); } memcpy(curUrl, urlBytes, (size_t)urlLen); curUrl[urlLen] = '\0'; strcpy(curMethod, method); curBody = body; curBodyLen = bodyLen; status = httpParseUrl(curUrl, (int64_t)strlen(curUrl), &origin, result); if (status != calogOkE) { return status; } for (redirects = 0; ; redirects++) { int32_t code; const CalogValueT *location; HttpUrlT base; HttpUrlT target; char locBuf[HTTP_URL_MAX]; char next[HTTP_URL_MAX]; size_t locLen; status = httpPerform(curMethod, curUrl, (int64_t)strlen(curUrl), curHeaders, curBody, curBodyLen, verify, result); if (status != calogOkE) { goto done; } code = httpResultStatus(result); if (maxRedirects <= 0 || !httpIsRedirect(code)) { goto done; // redirects disabled, or a final (non-3xx) response } location = httpResultHeader(result, "location"); if (location == NULL || location->type != calogStringE || location->as.s.length == 0 || memchr(location->as.s.bytes, '\0', (size_t)location->as.s.length) != NULL) { goto done; // a 3xx with no usable Location (missing, empty, or containing a NUL) } if (redirects >= maxRedirects) { calogValueFree(result); status = calogFail(result, calogErrArgE, "http: too many redirects"); goto done; } // Copy the Location out of the response map, then free the map (which the pointer aliases). locLen = (size_t)location->as.s.length; if (locLen >= sizeof(locBuf)) { calogValueFree(result); status = calogFail(result, calogErrArgE, "http: redirect Location too long"); goto done; } memcpy(locBuf, location->as.s.bytes, locLen); locBuf[locLen] = '\0'; calogValueFree(result); // Resolve the (possibly relative) Location against the URL just fetched. status = httpParseUrl(curUrl, (int64_t)strlen(curUrl), &base, result); if (status != calogOkE) { goto done; } if (!httpResolveLocation(&base, locBuf, next, sizeof(next))) { status = calogFail(result, calogErrArgE, "http: could not resolve redirect Location"); goto done; } status = httpParseUrl(next, (int64_t)strlen(next), &target, result); if (status != calogOkE) { goto done; } // 303 (and 301/302 for a body-bearing method) -> GET with no body; 307/308 keep both. if (code == 303 || ((code == 301 || code == 302) && httpMethodHasBody(curMethod))) { strcpy(curMethod, "GET"); curBody = NULL; curBodyLen = 0; bodyDropped = true; } // Choose the headers for the next hop: drop credential headers (Authorization/Cookie/...) // when the target is a different origin -- so a secret is never resent to another host, // including over an https->http downgrade -- and drop Content-* once the body is gone. if (headers != NULL) { bool dropCreds; dropCreds = !httpSameOrigin(&origin, &target); if (dropCreds || bodyDropped) { if (filtered != NULL) { calogAggFree(filtered); } filtered = httpFilterHeaders(headers, dropCreds, bodyDropped); if (filtered == NULL) { status = calogFail(result, calogErrOomE, "http: out of memory"); goto done; } curHeaders = filtered; } else { curHeaders = headers; } } strcpy(curUrl, next); } done: if (filtered != NULL) { calogAggFree(filtered); } return status; } static int32_t httpGetNative(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData) { (void)userData; calogValueNil(result); if (argCount != 1 || args[0].type != calogStringE) { return calogFail(result, calogErrArgE, "httpGet expects (url)"); } return httpFollow("GET", args[0].as.s.bytes, args[0].as.s.length, NULL, NULL, 0, true, HTTP_MAX_REDIRECTS, result); } static bool httpHasBadByte(const char *bytes, size_t length, bool rejectSpace) { size_t i; // CR/LF would let a raw byte string smuggle extra header lines (or a second request) into // the request line; a method additionally cannot carry a bare space. for (i = 0; i < length; i++) { if (bytes[i] == '\r' || bytes[i] == '\n') { return true; } if (rejectSpace && bytes[i] == ' ') { return true; } } return false; } // Case-insensitive membership test of a (length-carried) header name in a NULL-terminated list // of lowercase names. static bool httpHeaderIn(const char *name, int64_t len, const char *const *list) { size_t which; for (which = 0; list[which] != NULL; which++) { size_t nameLen; size_t index; bool match; nameLen = strlen(list[which]); if ((size_t)len != nameLen) { continue; } match = true; for (index = 0; index < nameLen; index++) { if (httpLower(name[index]) != list[which][index]) { match = false; break; } } if (match) { return true; } } return false; } static int32_t httpHexVal(char c) { if (c >= '0' && c <= '9') { return (int32_t)(c - '0'); } if (c >= 'a' && c <= 'f') { return (int32_t)(c - 'a' + 10); } if (c >= 'A' && c <= 'F') { return (int32_t)(c - 'A' + 10); } return -1; } // The 3xx codes that carry a Location this client follows (301/302/303 and 307/308). static bool httpIsRedirect(int32_t code) { return code == 301 || code == 302 || code == 303 || code == 307 || code == 308; } static char httpLower(char c) { if (c >= 'A' && c <= 'Z') { return (char)(c - 'A' + 'a'); } return c; } static int32_t httpMapSetAgg(CalogAggT *map, const char *key, CalogAggT *inner) { CalogValueT keyValue; CalogValueT aggValue; int32_t status; status = calogValueString(&keyValue, key, (int64_t)strlen(key)); if (status != calogOkE) { return status; } calogValueAgg(&aggValue, inner); status = calogAggSet(map, &keyValue, &aggValue); if (status != calogOkE) { // calogAggSet did not take ownership; free the key but leave `inner` (which aggValue // only wraps) for the caller to free. calogValueFree(&keyValue); } return status; } static bool httpMethodHasBody(const char *method) { // RFC 9110 8.6: a request with a method that commonly carries content SHOULD send // Content-Length even when the body is empty; strict servers/proxies otherwise answer // 411 Length Required. GET/HEAD never carry content, so they alone are excluded. return strcasecmp(method, "GET") != 0 && strcasecmp(method, "HEAD") != 0; } static CalogValueT *httpOptGet(CalogAggT *opts, const char *name, int32_t *status) { CalogValueT keyValue; CalogValueT *value; *status = calogValueString(&keyValue, name, (int64_t)strlen(name)); if (*status != calogOkE) { return NULL; } value = calogAggGet(opts, &keyValue); calogValueFree(&keyValue); return value; } static int64_t httpParseInt(const char *bytes, size_t length) { int64_t value; size_t i; bool any; value = 0; any = false; for (i = 0; i < length; i++) { if (bytes[i] < '0' || bytes[i] > '9') { break; } value = value * 10 + (int64_t)(bytes[i] - '0'); any = true; if (value > (int64_t)HTTP_MAX_RESPONSE) { break; } } if (!any) { return -1; } return value; } static int32_t httpParseStatus(const char *line, size_t length, int32_t *codeOut) { int32_t code; size_t i; size_t digits; bool any; i = 0; // Skip the "HTTP/x.y" token and the space(s) before the status code. while (i < length && line[i] != ' ') { i++; } while (i < length && line[i] == ' ') { i++; } code = 0; any = false; digits = 0; // RFC 7230 status codes are exactly 3 digits; cap accumulation there so a hostile/garbage // status line (e.g. 11+ digits) cannot signed-overflow code (matches httpParseInt's guard). while (i < length && line[i] >= '0' && line[i] <= '9') { if (digits < 3) { code = code * 10 + (int32_t)(line[i] - '0'); digits++; } any = true; i++; } if (!any) { return calogErrArgE; } *codeOut = code; return calogOkE; } static int32_t httpParseUrl(const char *url, int64_t urlLen, HttpUrlT *out, CalogValueT *result) { size_t len; size_t pos; size_t hostStart; size_t hostEnd; len = (size_t)urlLen; if (len >= 7 && memcmp(url, "http://", 7) == 0) { out->https = false; pos = 7; } else if (len >= 8 && memcmp(url, "https://", 8) == 0) { out->https = true; pos = 8; } else { return calogFail(result, calogErrArgE, "http: URL must start with http:// or https://"); } hostStart = pos; if (pos < len && url[pos] == '[') { // IPv6 literal host, e.g. "[::1]:8080" -- scan to the matching ']' instead of stopping // at the first ':' (an IPv6 address is full of colons). pos++; hostStart = pos; while (pos < len && url[pos] != ']') { pos++; } if (pos >= len) { return calogFail(result, calogErrArgE, "http: URL has an unterminated IPv6 host literal"); } hostEnd = pos; pos++; // consume ']' out->ipv6 = true; } else { while (pos < len && url[pos] != ':' && url[pos] != '/') { pos++; } hostEnd = pos; out->ipv6 = false; } if (hostEnd == hostStart) { return calogFail(result, calogErrArgE, "http: URL has an empty host"); } if (httpHasBadByte(url + hostStart, hostEnd - hostStart, false)) { return calogFail(result, calogErrArgE, "http: URL host contains invalid characters"); } if (!httpCopyField(out->host, sizeof(out->host), url + hostStart, hostEnd - hostStart)) { return calogFail(result, calogErrArgE, "http: URL host too long"); } if (pos < len && url[pos] == ':') { size_t portStart; pos++; portStart = pos; while (pos < len && url[pos] != '/') { pos++; } if (!httpCopyField(out->port, sizeof(out->port), url + portStart, pos - portStart)) { return calogFail(result, calogErrArgE, "http: URL port too long"); } if (out->port[0] == '\0') { return calogFail(result, calogErrArgE, "http: URL has an empty port"); } } else { strcpy(out->port, out->https ? HTTP_PORT_HTTPS : HTTP_PORT_HTTP); } if (pos < len) { if (httpHasBadByte(url + pos, len - pos, false)) { return calogFail(result, calogErrArgE, "http: URL path contains invalid characters"); } if (!httpCopyField(out->path, sizeof(out->path), url + pos, len - pos)) { return calogFail(result, calogErrArgE, "http: URL path too long"); } } else { strcpy(out->path, "/"); } return calogOkE; } static int32_t httpPerform(const char *method, const char *urlBytes, int64_t urlLen, const CalogAggT *headers, const char *body, int64_t bodyLen, bool verify, CalogValueT *result) { HttpUrlT url; HttpConnT conn; HttpBufT request; HttpBufT raw; int32_t status; calogValueNil(result); request.bytes = NULL; request.length = 0; request.cap = 0; raw.bytes = NULL; raw.length = 0; raw.cap = 0; status = httpParseUrl(urlBytes, urlLen, &url, result); if (status != calogOkE) { return status; } // Request line + Host + Connection: close. status = httpBufPutStr(&request, method); if (status == calogOkE) { status = httpBufPutStr(&request, " "); } if (status == calogOkE) { status = httpBufPutStr(&request, url.path); } if (status == calogOkE) { status = httpBufPutStr(&request, " HTTP/1.1\r\nHost: "); } if (status == calogOkE && url.ipv6) { status = httpBufPutStr(&request, "["); } if (status == calogOkE) { status = httpBufPutStr(&request, url.host); } if (status == calogOkE && url.ipv6) { status = httpBufPutStr(&request, "]"); } if (status == calogOkE && !httpDefaultPort(&url)) { status = httpBufPutStr(&request, ":"); if (status == calogOkE) { status = httpBufPutStr(&request, url.port); } } if (status == calogOkE) { status = httpBufPutStr(&request, "\r\nConnection: close\r\n"); } if (status != calogOkE) { httpBufFree(&request); return calogFail(result, status, "http: out of memory"); } // Caller-supplied headers. if (headers != NULL) { status = httpAppendHeaders(&request, headers); if (status != calogOkE) { httpBufFree(&request); return calogFail(result, status, "http: out of memory"); } } // Content-Length + the blank line + body. A method that commonly carries content still gets // an explicit Content-Length: 0 even with an empty/absent body (see httpMethodHasBody); // otherwise strict servers/proxies may answer 411 Length Required. if (bodyLen > 0 || httpMethodHasBody(method)) { char lengthLine[64]; int n; n = snprintf(lengthLine, sizeof(lengthLine), "Content-Length: %lld\r\n", (long long)bodyLen); status = httpBufPutBytes(&request, lengthLine, (size_t)n); if (status != calogOkE) { httpBufFree(&request); return calogFail(result, status, "http: out of memory"); } } status = httpBufPutStr(&request, "\r\n"); if (status == calogOkE && bodyLen > 0) { status = httpBufPutBytes(&request, body, (size_t)bodyLen); } if (status != calogOkE) { httpBufFree(&request); return calogFail(result, status, "http: out of memory"); } conn.verify = verify; status = httpConnOpen(&conn, &url, result); if (status != calogOkE) { httpBufFree(&request); return status; } status = httpConnWriteAll(&conn, request.bytes, request.length); httpBufFree(&request); if (status != calogOkE) { httpConnClose(&conn); return calogFail(result, status, "http: failed to send request"); } status = httpReadResponse(&conn, &raw); httpConnClose(&conn); if (status != calogOkE) { httpBufFree(&raw); return calogFail(result, status, "http: failed to read response"); } status = httpBuildResult((raw.bytes != NULL) ? raw.bytes : "", raw.length, result); httpBufFree(&raw); return status; } static int32_t httpReadResponse(HttpConnT *conn, HttpBufT *raw) { char chunk[HTTP_READ_CHUNK]; // "Connection: close" means the server closes at end of message, so reading to EOF yields // the entire response (head + body). for (;;) { ssize_t n; int32_t status; n = httpConnRead(conn, chunk, sizeof(chunk)); if (n < 0) { // A genuine read error (reset, unclean TLS shutdown) is not the same as a clean EOF // -- do not hand a truncated response to the script as a success. return calogErrArgE; } if (n == 0) { break; } if (raw->length + (size_t)n > (size_t)HTTP_MAX_RESPONSE) { return calogErrRangeE; } status = httpBufPutBytes(raw, chunk, (size_t)n); if (status != calogOkE) { return status; } } return calogOkE; } static int32_t httpRequestNative(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData) { CalogAggT *opts; CalogValueT *urlValue; CalogValueT *methodValue; CalogValueT *headersValue; CalogValueT *bodyValue; CalogValueT *insecureValue; CalogValueT *redirectValue; const CalogAggT *headers; const char *method; const char *body; int64_t bodyLen; int32_t status; int32_t maxRedirects; bool verify; (void)userData; calogValueNil(result); if (argCount != 1 || args[0].type != calogAggE) { return calogFail(result, calogErrArgE, "httpRequest expects (options)"); } opts = args[0].as.agg; // url (required). status = calogOkE; urlValue = httpOptGet(opts, "url", &status); if (status != calogOkE) { return calogFail(result, status, "httpRequest: out of memory"); } if (urlValue == NULL || urlValue->type != calogStringE) { return calogFail(result, calogErrArgE, "httpRequest: options.url must be a string"); } // method (optional; default GET). method = "GET"; status = calogOkE; methodValue = httpOptGet(opts, "method", &status); if (status != calogOkE) { return calogFail(result, status, "httpRequest: out of memory"); } if (methodValue != NULL && methodValue->type == calogStringE) { method = methodValue->as.s.bytes; } if (httpHasBadByte(method, strlen(method), true)) { return calogFail(result, calogErrArgE, "httpRequest: options.method contains invalid characters"); } // headers (optional). headers = NULL; status = calogOkE; headersValue = httpOptGet(opts, "headers", &status); if (status != calogOkE) { return calogFail(result, status, "httpRequest: out of memory"); } if (headersValue != NULL && headersValue->type == calogAggE) { headers = headersValue->as.agg; } // body (optional). body = NULL; bodyLen = 0; status = calogOkE; bodyValue = httpOptGet(opts, "body", &status); if (status != calogOkE) { return calogFail(result, status, "httpRequest: out of memory"); } if (bodyValue != NULL && bodyValue->type == calogStringE) { body = bodyValue->as.s.bytes; bodyLen = bodyValue->as.s.length; } // insecure (optional): true skips certificate + hostname verification for an https request. verify = true; status = calogOkE; insecureValue = httpOptGet(opts, "insecure", &status); if (status != calogOkE) { return calogFail(result, status, "httpRequest: out of memory"); } if (insecureValue != NULL && insecureValue->type == calogBoolE && insecureValue->as.b) { verify = false; } // maxRedirects (optional): how many 3xx redirects to follow. Default 16; 0 (or negative) // disables following and returns the raw 3xx. Values above 16 are capped, so a caller can // never widen the loop guard. maxRedirects = HTTP_MAX_REDIRECTS; status = calogOkE; redirectValue = httpOptGet(opts, "maxRedirects", &status); if (status != calogOkE) { return calogFail(result, status, "httpRequest: out of memory"); } if (redirectValue != NULL && redirectValue->type == calogIntE) { if (redirectValue->as.i < 0) { maxRedirects = 0; } else if (redirectValue->as.i < HTTP_MAX_REDIRECTS) { maxRedirects = (int32_t)redirectValue->as.i; } } return httpFollow(method, urlValue->as.s.bytes, urlValue->as.s.length, headers, body, bodyLen, verify, maxRedirects, result); } // Resolve a redirect Location against the URL just fetched, writing an absolute URL into out. // Handles an absolute URL, a protocol-relative "//host/path", an absolute path "/path", and a // relative path (merged against the base path's directory). Leading whitespace is skipped; // dot-segments (../) are NOT normalized. Returns false on overflow. static bool httpResolveLocation(const HttpUrlT *base, const char *loc, char *out, size_t cap) { const char *scheme; int authority; while (*loc == ' ' || *loc == '\t') { loc++; } if (strncmp(loc, "http://", 7) == 0 || strncmp(loc, "https://", 8) == 0) { if (strlen(loc) >= cap) { return false; } strcpy(out, loc); return true; } scheme = base->https ? "https" : "http"; if (loc[0] == '/' && loc[1] == '/') { int n; n = snprintf(out, cap, "%s:%s", scheme, loc); return n > 0 && (size_t)n < cap; } authority = httpWriteAuthority(out, cap, scheme, base); if (authority < 0) { return false; } if (loc[0] == '/') { if ((size_t)authority + strlen(loc) >= cap) { return false; } strcpy(out + authority, loc); return true; } if (loc[0] == '?' || loc[0] == '#') { // Query- or fragment-only reference (RFC 3986 5.3): keep the whole base path, replace the // rest -- NOT the directory-merge below, which would drop the base path's last segment. size_t basePathLen; basePathLen = strcspn(base->path, "?#"); if ((size_t)authority + basePathLen + strlen(loc) >= cap) { return false; } memcpy(out + authority, base->path, basePathLen); strcpy(out + (size_t)authority + basePathLen, loc); return true; } // A relative path: append the base path's directory (up to and including its last '/', within // the path part before any query/fragment), then loc. { size_t pathLen; size_t dirLen; size_t index; pathLen = strcspn(base->path, "?#"); dirLen = 1; // base->path always begins with '/' for (index = 0; index < pathLen; index++) { if (base->path[index] == '/') { dirLen = index + 1; } } if ((size_t)authority + dirLen + strlen(loc) >= cap) { return false; } memcpy(out + authority, base->path, dirLen); strcpy(out + (size_t)authority + dirLen, loc); return true; } } // A response header value (name lowercase, as httpBuildResult keys them) from a built result // map, or NULL. Aliases the map; valid until it is freed. static const CalogValueT *httpResultHeader(const CalogValueT *result, const char *name) { CalogValueT *headers; if (result->type != calogAggE) { return NULL; } headers = httpAggField(result->as.agg, "headers"); if (headers == NULL || headers->type != calogAggE) { return NULL; } return httpAggField(headers->as.agg, name); } // The integer status code from a built result map, or -1 if absent/malformed. static int32_t httpResultStatus(const CalogValueT *result) { CalogValueT *value; if (result->type != calogAggE) { return -1; } value = httpAggField(result->as.agg, "status"); if (value == NULL || value->type != calogIntE) { return -1; } return (int32_t)value->as.i; } // Same origin = same scheme, host, and port -- the boundary at which credential headers are // dropped from a followed redirect. static bool httpSameOrigin(const HttpUrlT *a, const HttpUrlT *b) { return a->https == b->https && strcmp(a->host, b->host) == 0 && strcmp(a->port, b->port) == 0; } static void httpSetTimeouts(int fd, int timeoutSec) { struct timeval tv; tv.tv_sec = timeoutSec; tv.tv_usec = 0; // Best effort: if the kernel rejects these (unusual), the connection just falls back to no // read/write deadline rather than failing the request outright. (void)setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); (void)setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); } static int32_t httpTlsHandshake(HttpConnT *conn, const HttpUrlT *url, CalogValueT *result) { conn->ctx = SSL_CTX_new(TLS_client_method()); if (conn->ctx == NULL) { return calogFail(result, calogErrUnsupportedE, "http: TLS context creation failed"); } if (conn->verify) { // Authenticate the server: require a certificate chain to a trusted CA. Load the system // CA store plus OpenSSL's compiled-in defaults; with no trust anchors the handshake // fails closed rather than open. (An https request can pass insecure=true to skip this.) SSL_CTX_set_verify(conn->ctx, SSL_VERIFY_PEER, NULL); (void)SSL_CTX_set_default_verify_paths(conn->ctx); (void)SSL_CTX_load_verify_locations(conn->ctx, "/etc/ssl/certs/ca-certificates.crt", "/etc/ssl/certs"); } conn->ssl = SSL_new(conn->ctx); if (conn->ssl == NULL) { return calogFail(result, calogErrUnsupportedE, "http: TLS session creation failed"); } if (SSL_set_fd(conn->ssl, conn->fd) != 1) { return calogFail(result, calogErrUnsupportedE, "http: TLS SSL_set_fd failed"); } // SNI so name-based virtual hosts serve the right certificate. (void)SSL_set_tlsext_host_name(conn->ssl, url->host); if (conn->verify) { // Bind verification to the requested hostname, so a valid certificate issued for a // DIFFERENT host is still rejected. SSL_set_hostflags(conn->ssl, X509_CHECK_FLAG_NO_PARTIAL_WILDCARDS); if (SSL_set1_host(conn->ssl, url->host) != 1) { return calogFail(result, calogErrUnsupportedE, "http: could not set TLS verification hostname"); } } if (SSL_connect(conn->ssl) != 1) { return calogFail(result, calogErrUnsupportedE, conn->verify ? "http: TLS handshake or certificate verification failed" : "http: TLS handshake failed"); } return calogOkE; } // Write "scheme://host" (bracketing an IPv6 host, and adding ":port" when non-default) into out. // Returns the number of bytes written (excluding the NUL), or -1 if it does not fit. static int httpWriteAuthority(char *out, size_t cap, const char *scheme, const HttpUrlT *base) { int n; if (base->ipv6 && !httpDefaultPort(base)) { n = snprintf(out, cap, "%s://[%s]:%s", scheme, base->host, base->port); } else if (base->ipv6) { n = snprintf(out, cap, "%s://[%s]", scheme, base->host); } else if (!httpDefaultPort(base)) { n = snprintf(out, cap, "%s://%s:%s", scheme, base->host, base->port); } else { n = snprintf(out, cap, "%s://%s", scheme, base->host); } return (n > 0 && (size_t)n < cap) ? n : -1; }