diff --git a/runtime/el_runtime.c b/runtime/el_runtime.c index 9697867..4d2874e 100644 --- a/runtime/el_runtime.c +++ b/runtime/el_runtime.c @@ -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; } diff --git a/src/main.el b/src/main.el index 305b7df..2a2e8e6 100644 --- a/src/main.el +++ b/src/main.el @@ -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