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
|
/* 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
|
* 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
|
* Implementation: manual field scanner — does NOT run json_parse on the full
|
||||||
* JSON parser (which builds proper ElMap/ElList values), then pulls the
|
* envelope. Running the recursive-descent JSON parser on a 40–50 KB envelope
|
||||||
* three top-level fields by name. Avoids re-stringifying the headers map
|
* (common when the body contains minified/obfuscated JavaScript) fails because
|
||||||
* since json_stringify() does not support nested objects. */
|
* 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,
|
static int http_parse_envelope(const char* s, int* out_status,
|
||||||
el_val_t* out_headers_map, char** out_body,
|
el_val_t* out_headers_map, char** out_body,
|
||||||
el_val_t* out_parsed_root) {
|
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,
|
if (strncmp(s, EL_HTTP_RESPONSE_TAG,
|
||||||
sizeof(EL_HTTP_RESPONSE_TAG) - 1) != 0) return 0;
|
sizeof(EL_HTTP_RESPONSE_TAG) - 1) != 0) return 0;
|
||||||
|
|
||||||
el_val_t parsed = json_parse(EL_STR(s));
|
/* ── status ──────────────────────────────────────────────────────────── */
|
||||||
if (parsed == EL_NULL) return 0;
|
int status = 200;
|
||||||
|
{
|
||||||
int status = 200;
|
const char* sp = strstr(s, "\"status\":");
|
||||||
el_val_t hmap = 0;
|
if (sp) {
|
||||||
char* body = NULL;
|
const char* np = sp + 9;
|
||||||
|
while (*np == ' ' || *np == '\t') np++;
|
||||||
el_val_t sv = el_map_get(parsed, EL_STR("status"));
|
long sc = strtol(np, NULL, 10);
|
||||||
if (sv != 0) {
|
if (sc >= 100 && sc <= 599) status = (int)sc;
|
||||||
/* status comes back as an integer — el_val_t holds it directly. */
|
}
|
||||||
long sc = (long)sv;
|
|
||||||
if (sc >= 100 && sc <= 599) status = (int)sc;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
el_val_t hv = el_map_get(parsed, EL_STR("headers"));
|
/* ── headers ─────────────────────────────────────────────────────────── */
|
||||||
if (hv != 0) {
|
el_val_t hmap = 0;
|
||||||
ElMap* hm = (ElMap*)(uintptr_t)hv;
|
el_val_t parsed_hdrs = EL_NULL;
|
||||||
if (hm && hm->hdr.magic == EL_MAGIC_MAP) hmap = hv;
|
{
|
||||||
|
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"));
|
/* ── body ────────────────────────────────────────────────────────────── */
|
||||||
if (bv != 0) {
|
/* Search forward so we don't accidentally match "body": inside a header
|
||||||
const char* bs = EL_CSTR(bv);
|
* value. http_response() always appends the body field last. */
|
||||||
if (bs) body = el_strdup(bs);
|
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_status = status;
|
||||||
*out_headers_map = hmap;
|
*out_headers_map = hmap;
|
||||||
*out_body = body;
|
*out_body = body;
|
||||||
*out_parsed_root = parsed; /* caller releases to free hmap + entries */
|
*out_parsed_root = parsed_hdrs; /* caller el_release()s to free hmap */
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+18
-1
@@ -907,6 +907,10 @@ fn handle_request_inner(method: String, path: String, body: String) -> String {
|
|||||||
// ── Compiled client-side JS: /js/* ───────────────────────────────────────
|
// ── Compiled client-side JS: /js/* ───────────────────────────────────────
|
||||||
// Served from dist/js/ (compiled by elc --target=js at build time).
|
// 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.
|
// 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/") {
|
if str_starts_with(path, "/js/") {
|
||||||
let rel: String = str_slice(path, 4, str_len(path))
|
let rel: String = str_slice(path, 4, str_len(path))
|
||||||
let abs: String = src_dir + "/js/" + rel
|
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, "") {
|
if str_eq(content, "") {
|
||||||
return "{\"__status__\":404,\"error\":\"not found\"}"
|
return "{\"__status__\":404,\"error\":\"not found\"}"
|
||||||
}
|
}
|
||||||
return content
|
return http_response(200, js_headers_json(), content)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Brand assets: /brand/* ────────────────────────────────────────────────
|
// ── 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:\"}"
|
+ "\"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 {
|
fn handle_request(method: String, path: String, body: String) -> String {
|
||||||
let inner_resp: String = handle_request_inner(method, path, body)
|
let inner_resp: String = handle_request_inner(method, path, body)
|
||||||
// Detect envelope already set by inner handler (starts with
|
// Detect envelope already set by inner handler (starts with
|
||||||
|
|||||||
Reference in New Issue
Block a user