Fix JS files served as raw JSON envelope instead of JavaScript
Dev — Build & local smoke test / build-smoke (pull_request) Failing after 1m36s
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:
+91
-30
@@ -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 40–50 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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user