Fix JS files served as raw JSON envelope instead of JavaScript
Dev — Build & local smoke test / build-smoke (pull_request) Failing after 1m36s

http_parse_envelope() called json_parse() on the entire response envelope
(~47KB when body is obfuscated JS). The parser failed on large/complex content,
so is_envelope=0 and the raw JSON was sent — browsers got {"el_http_response":1,...}
instead of executable JavaScript, silently breaking all client-side code.

Fix: replace json_parse-of-full-envelope with a direct field scanner:
- "status" extracted via strtol
- "headers" object extracted via brace-depth scan, then json_parse only that
  small substring (always safe — headers are simple k/v string pairs < 1KB)
- "body" string extracted via jp_parse_string_raw — no intermediate allocation

Also: /js/* route now returns http_response(200, js_headers_json(), content)
with explicit Content-Type: application/javascript so the browser doesn't
apply the json-heuristic (obfuscated JS starting with '[' was detected as JSON,
which with X-Content-Type-Options: nosniff blocks script execution).
This commit is contained in:
2026-05-10 17:32:45 -05:00
parent 0263e51407
commit c99ca82302
2 changed files with 109 additions and 31 deletions
+91 -30
View File
@@ -1331,12 +1331,19 @@ static void http_emit_headers_from_map(JsonBuf* b, el_val_t headers_map,
/* Parse the envelope produced by http_response(). On success returns 1 and
* populates *out_status, *out_headers_map (an ElMap el_val_t caller must
* el_release), and *out_body (allocated). On failure returns 0.
* el_release via out_parsed_root), and *out_body (malloc'd, caller frees).
* On failure returns 0.
*
* Implementation: feeds the entire envelope through the recursive-descent
* JSON parser (which builds proper ElMap/ElList values), then pulls the
* three top-level fields by name. Avoids re-stringifying the headers map
* since json_stringify() does not support nested objects. */
* Implementation: manual field scanner does NOT run json_parse on the full
* envelope. Running the recursive-descent JSON parser on a 4050 KB envelope
* (common when the body contains minified/obfuscated JavaScript) fails because
* the parser allocates intermediate ElMap nodes for the whole structure.
* Instead we scan directly:
* "status" strtol scan
* "headers" brace-depth scan to extract the object literal, then
* json_parse only that small substring (always < 1 KB)
* "body" jp_parse_string_raw to unescape the JSON string in one pass,
* without building any intermediate data structures */
static int http_parse_envelope(const char* s, int* out_status,
el_val_t* out_headers_map, char** out_body,
el_val_t* out_parsed_root) {
@@ -1344,37 +1351,91 @@ static int http_parse_envelope(const char* s, int* out_status,
if (strncmp(s, EL_HTTP_RESPONSE_TAG,
sizeof(EL_HTTP_RESPONSE_TAG) - 1) != 0) return 0;
el_val_t parsed = json_parse(EL_STR(s));
if (parsed == EL_NULL) return 0;
int status = 200;
el_val_t hmap = 0;
char* body = NULL;
el_val_t sv = el_map_get(parsed, EL_STR("status"));
if (sv != 0) {
/* status comes back as an integer — el_val_t holds it directly. */
long sc = (long)sv;
if (sc >= 100 && sc <= 599) status = (int)sc;
/* ── status ──────────────────────────────────────────────────────────── */
int status = 200;
{
const char* sp = strstr(s, "\"status\":");
if (sp) {
const char* np = sp + 9;
while (*np == ' ' || *np == '\t') np++;
long sc = strtol(np, NULL, 10);
if (sc >= 100 && sc <= 599) status = (int)sc;
}
}
el_val_t hv = el_map_get(parsed, EL_STR("headers"));
if (hv != 0) {
ElMap* hm = (ElMap*)(uintptr_t)hv;
if (hm && hm->hdr.magic == EL_MAGIC_MAP) hmap = hv;
/* ── headers ─────────────────────────────────────────────────────────── */
el_val_t hmap = 0;
el_val_t parsed_hdrs = EL_NULL;
{
const char* hp = strstr(s, "\"headers\":");
if (hp) {
hp += 10;
while (*hp == ' ' || *hp == '\t') hp++;
if (*hp == '{') {
/* Scan for matching '}', honouring nested objects and strings */
const char* hobj_start = hp;
const char* cp = hp + 1;
int depth = 1, in_str = 0;
while (*cp && depth > 0) {
if (in_str) {
if (*cp == '\\' && *(cp + 1)) { cp += 2; continue; }
if (*cp == '"') in_str = 0;
} else {
if (*cp == '"') in_str = 1;
else if (*cp == '{') depth++;
else if (*cp == '}') { if (--depth == 0) break; }
}
cp++;
}
if (depth == 0) {
/* cp points at the closing '}'; extract the object literal */
size_t hlen = (size_t)(cp - hobj_start + 1);
char* hobj = malloc(hlen + 1);
if (hobj) {
memcpy(hobj, hobj_start, hlen);
hobj[hlen] = '\0';
/* Headers are always simple k/v string pairs — json_parse
* is safe on this small substring (typically < 1 KB). */
parsed_hdrs = json_parse(EL_STR(hobj));
free(hobj);
if (parsed_hdrs != EL_NULL) {
ElMap* hm = (ElMap*)(uintptr_t)parsed_hdrs;
if (hm && hm->hdr.magic == EL_MAGIC_MAP) hmap = parsed_hdrs;
}
}
}
}
}
}
el_val_t bv = el_map_get(parsed, EL_STR("body"));
if (bv != 0) {
const char* bs = EL_CSTR(bv);
if (bs) body = el_strdup(bs);
/* ── body ────────────────────────────────────────────────────────────── */
/* Search forward so we don't accidentally match "body": inside a header
* value. http_response() always appends the body field last. */
char* body = NULL;
{
const char* bp = strstr(s, "\"body\":");
if (bp) {
bp += 7;
while (*bp == ' ' || *bp == '\t') bp++;
if (*bp == '"') {
/* jp_parse_string_raw unescapes a JSON string in one pass,
* producing a plain malloc'd C string. Caller frees it. */
JsonParser jp = { .p = bp, .end = bp + strlen(bp), .err = 0 };
char* parsed = jp_parse_string_raw(&jp);
if (!jp.err) {
body = parsed;
} else {
free(parsed);
}
}
}
if (!body) body = strdup("");
}
if (!body) body = el_strdup("");
*out_status = status;
*out_headers_map = hmap;
*out_body = body;
*out_parsed_root = parsed; /* caller releases to free hmap + entries */
*out_status = status;
*out_headers_map = hmap;
*out_body = body;
*out_parsed_root = parsed_hdrs; /* caller el_release()s to free hmap */
return 1;
}
+18 -1
View File
@@ -907,6 +907,10 @@ fn handle_request_inner(method: String, path: String, body: String) -> String {
// Compiled client-side JS: /js/*
// Served from dist/js/ (compiled by elc --target=js at build time).
// LANDING_ROOT/js maps to the dist/js output directory in the image.
// Returns an http_response envelope with explicit Content-Type so the
// browser executes the file as JavaScript http_detect_content_type()
// mis-identifies minified/obfuscated JS as JSON because many obfuscated
// bundles start with '[' (which is also a JSON array opener).
if str_starts_with(path, "/js/") {
let rel: String = str_slice(path, 4, str_len(path))
let abs: String = src_dir + "/js/" + rel
@@ -914,7 +918,7 @@ fn handle_request_inner(method: String, path: String, body: String) -> String {
if str_eq(content, "") {
return "{\"__status__\":404,\"error\":\"not found\"}"
}
return content
return http_response(200, js_headers_json(), content)
}
// Brand assets: /brand/*
@@ -1936,6 +1940,19 @@ fn sec_headers_json() -> String {
+ "\"Content-Security-Policy\":\"default-src 'self'; script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com https://cdn.jsdelivr.net https://www.googletagmanager.com https://www.google-analytics.com; style-src 'self' 'unsafe-inline'; frame-src https://challenges.cloudflare.com; connect-src 'self' https://api.stripe.com https://*.supabase.co; img-src 'self' data: https:; font-src 'self' data:\"}"
}
// Headers for compiled JS assets. Explicitly sets Content-Type so the browser
// treats them as JavaScript regardless of what http_detect_content_type()
// infers from the content (minified/obfuscated JS can trip the JSON heuristic).
fn js_headers_json() -> String {
"{\"Content-Type\":\"application/javascript; charset=utf-8\","
+ "\"Cache-Control\":\"public, max-age=3600\","
+ "\"Strict-Transport-Security\":\"max-age=63072000; includeSubDomains; preload\","
+ "\"X-Content-Type-Options\":\"nosniff\","
+ "\"X-Frame-Options\":\"SAMEORIGIN\","
+ "\"Referrer-Policy\":\"strict-origin-when-cross-origin\","
+ "\"Permissions-Policy\":\"geolocation=(), microphone=(), camera=()\"}"
}
fn handle_request(method: String, path: String, body: String) -> String {
let inner_resp: String = handle_request_inner(method, path, body)
// Detect envelope already set by inner handler (starts with