Files
neuron-web/dist/web_stubs.c
T
Will Anderson 46f93fd6eb
Deploy marketing to Cloud Run / deploy (push) Failing after 5s
security: replace denylist sanitize_share_html with allowlist el_html_sanitize
A real attacker probed /api/share earlier today with <script>alert(1),
<iframe src=evil>, <img onerror>, <a href="javascript:...">, and a
<form action="/steal"> payload. Nothing executed because the chat
bubble at /share/<id> renders the served HTML inside marked.js's
already-escaped output, but the prior denylist sanitizer was fragile:

  - It comment-wrapped dangerous tags ("<!--script>...-->") which a
    literal "-->" inside an attacker-supplied attribute value can close
    early, re-exposing the original payload.
  - It renamed on*= attributes to data-x-on*= which left attack
    indicators visible in the served HTML.
  - It was a denylist; every new attack vector required a code change.
  - It didn't validate <a href> URL schemes properly.

The replacement is a runtime-level state-machine allowlist parser
(foundation/el af480f6: el_html_sanitize). The product just specifies
the JSON allowlist of allowed tags + attributes; the runtime drops
everything else, validates href/src URL schemes (http/https/mailto/
fragment/relative only), and drops whole subtrees of script/style/
iframe/object/embed/form regardless of the allowlist.

Phase 4 of bl-dc55ae07: deletes sanitize_share_html (main.el) and
gal_sanitize_html (gallery.el); replaces 3 call sites with
el_html_sanitize(html, allowlist). Defines default_share_allowlist
in main.el and the identical gallery_share_allowlist in gallery.el
(separate bindings to avoid a forward-reference at build-concat
order — gallery is concatenated before main).

Phase 5: migrations/20260502185500_backfill_resanitize_share_cards.sql
nulls answer_html for any share_cards row older than 1 hour. Applied
via the Supabase Management API; 0 rows in scope (the column was
added today and existing rows pre-date its first write).

Also fixes an orthogonal duplicate-symbol bug: unix_timestamp() was
defined in both dist/web_stubs.c and the runtime (the latter is a
recent runtime addition picked up by the runtime sync). Removed the
stub.

Backlog: bl-dc55ae07
2026-05-02 12:56:33 -05:00

328 lines
13 KiB
C

#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <unistd.h>
#include "el_runtime.h"
el_val_t http_get_auth(el_val_t url, el_val_t tok) {
char bearer[2048]; snprintf(bearer, sizeof(bearer), "Bearer %s", EL_CSTR(tok));
el_val_t hdr_val = EL_STR(bearer);
el_val_t headers = el_map_new(1, "Authorization", hdr_val);
return http_get_with_headers(url, headers);
}
/* supabase_get — GET from Supabase REST API with both apikey and Bearer headers */
el_val_t supabase_get(el_val_t project_url, el_val_t service_key, el_val_t table_and_query) {
char bearer[2048]; snprintf(bearer, sizeof(bearer), "Bearer %s", EL_CSTR(service_key));
char url[2048]; snprintf(url, sizeof(url), "%s/rest/v1/%s", EL_CSTR(project_url), EL_CSTR(table_and_query));
el_val_t headers = el_map_new(3,
"Authorization", EL_STR(bearer),
"apikey", service_key,
"Accept", EL_STR("application/json"));
return http_get_with_headers(EL_STR(url), headers);
}
el_val_t http_post_auth(el_val_t url, el_val_t tok, el_val_t body) {
/* Bearer-only. No Content-Type — Stripe's REST API consumes
* application/x-www-form-urlencoded bodies and is the heaviest
* caller of this helper. JSON callers (Resend, Anthropic, etc.) must
* use http_post_auth_json below, which sets Content-Type properly.
* Mixing Stripe form-POST and Resend JSON-POST through one helper was
* the root cause of the silent-drop bug — Resend 422s with "Missing
* `to` field" if the body arrives without Content-Type:
* application/json (it parses as form-encoded otherwise). */
char bearer[2048]; snprintf(bearer, sizeof(bearer), "Bearer %s", EL_CSTR(tok));
el_val_t hdr_val = EL_STR(bearer);
el_val_t headers = el_map_new(1, "Authorization", hdr_val);
return http_post_with_headers(url, body, headers);
}
/* http_post_auth_json — Bearer + Content-Type: application/json.
* Use for any REST API that consumes JSON request bodies (Resend,
* Anthropic, OpenAI, etc.). Returns the response body, or "" on
* transport failure. HTTP 4xx/5xx are NOT swallowed — the body is
* still returned so callers can parse error JSON. */
el_val_t http_post_auth_json(el_val_t url, el_val_t tok, el_val_t body) {
char bearer[2048]; snprintf(bearer, sizeof(bearer), "Bearer %s", EL_CSTR(tok));
el_val_t headers = el_map_new(2,
"Authorization", EL_STR(bearer),
"Content-Type", EL_STR("application/json"));
return http_post_with_headers(url, body, headers);
}
/*
* http_delete_auth - DELETE with Bearer auth + apikey (Supabase-style).
* The runtime exposes http_delete(url) but no header variant, so we
* implement DELETE-with-headers via http_post_with_headers using a
* libcurl-backed helper; falling back to http_delete if no auth available.
*
* For Supabase REST DELETE we need both Authorization (user JWT) AND
* apikey (anon key) - the apikey is required to reach PostgREST at all,
* the Bearer JWT is what auth.uid() checks for the RLS policy. The
* runtime's http_post_with_headers takes a method-agnostic body; we
* use libcurl directly via a tiny shim.
*/
#include <curl/curl.h>
typedef struct { char *buf; size_t len; size_t cap; } _stub_resp_t;
static size_t _stub_write(void *ptr, size_t size, size_t nmemb, void *u) {
_stub_resp_t *r = (_stub_resp_t*)u;
size_t add = size * nmemb;
if (r->len + add + 1 > r->cap) {
size_t nc = (r->cap ? r->cap : 4096);
while (nc < r->len + add + 1) nc *= 2;
char *nb = realloc(r->buf, nc);
if (!nb) return 0;
r->buf = nb; r->cap = nc;
}
memcpy(r->buf + r->len, ptr, add);
r->len += add;
r->buf[r->len] = '\0';
return add;
}
el_val_t http_delete_auth(el_val_t url, el_val_t bearer_tok, el_val_t apikey) {
CURL *c = curl_easy_init();
if (!c) return EL_STR("");
char auth_hdr[2048]; snprintf(auth_hdr, sizeof(auth_hdr), "Authorization: Bearer %s", EL_CSTR(bearer_tok));
char api_hdr[2048]; snprintf(api_hdr, sizeof(api_hdr), "apikey: %s", EL_CSTR(apikey));
struct curl_slist *hdrs = NULL;
hdrs = curl_slist_append(hdrs, auth_hdr);
hdrs = curl_slist_append(hdrs, api_hdr);
hdrs = curl_slist_append(hdrs, "Accept: application/json");
_stub_resp_t r = {0};
curl_easy_setopt(c, CURLOPT_URL, EL_CSTR(url));
curl_easy_setopt(c, CURLOPT_CUSTOMREQUEST, "DELETE");
curl_easy_setopt(c, CURLOPT_HTTPHEADER, hdrs);
curl_easy_setopt(c, CURLOPT_FOLLOWLOCATION, 1L);
curl_easy_setopt(c, CURLOPT_TIMEOUT, 60L);
curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, _stub_write);
curl_easy_setopt(c, CURLOPT_WRITEDATA, &r);
CURLcode rc = curl_easy_perform(c);
long http_code = 0;
curl_easy_getinfo(c, CURLINFO_RESPONSE_CODE, &http_code);
curl_easy_cleanup(c);
curl_slist_free_all(hdrs);
if (rc != CURLE_OK) {
free(r.buf);
return EL_STR("");
}
if (http_code >= 400) {
free(r.buf);
return EL_STR("");
}
if (!r.buf) return EL_STR("");
/* Note: small leak by design - response lives until process exits.
* Mirrors the existing pattern in supabase_auth_user / gcs_get_token. */
return EL_STR(r.buf);
}
/*
* supabase_upsert_user - POST a row to a Supabase table on behalf of a
* logged-in user. Sends Authorization: Bearer <user-jwt> + apikey: <anon>
* + Prefer: resolution=merge-duplicates so RLS treats this as the user's
* own write and PostgREST upserts on the (composite) primary key.
*
* `table_and_query` is the path after /rest/v1/, e.g.
* "share_votes?on_conflict=share_id,user_id"
*
* Returns the response body (often "" with Prefer=return=minimal) or "" on
* non-2xx / network failure. Callers should re-fetch aggregate state via a
* separate GET to confirm the write landed.
*/
el_val_t supabase_upsert_user(el_val_t project_url, el_val_t anon_key,
el_val_t user_jwt, el_val_t table_and_query,
el_val_t row_json) {
char url[1024];
snprintf(url, sizeof(url), "%s/rest/v1/%s", EL_CSTR(project_url), EL_CSTR(table_and_query));
char bearer[2048];
snprintf(bearer, sizeof(bearer), "Bearer %s", EL_CSTR(user_jwt));
el_val_t headers = el_map_new(5,
"Authorization", EL_STR(bearer),
"apikey", anon_key,
"Content-Type", EL_STR("application/json"),
"Prefer", EL_STR("return=minimal,resolution=merge-duplicates"),
"Accept", EL_STR("application/json"));
return http_post_with_headers(EL_STR(url), row_json, headers);
}
/*
* supabase_auth_user — verify a Supabase user JWT and return the user object.
* Calls GET {project_url}/auth/v1/user with both apikey and Authorization headers.
* Returns the raw user JSON, or "" on failure.
*/
el_val_t supabase_auth_user(el_val_t project_url, el_val_t anon_key, el_val_t user_jwt) {
char bearer[2048];
snprintf(bearer, sizeof(bearer), "Bearer %s", EL_CSTR(user_jwt));
char url[512];
snprintf(url, sizeof(url), "%s/auth/v1/user", EL_CSTR(project_url));
el_val_t headers = el_map_new(3,
"Authorization", EL_STR(bearer),
"apikey", anon_key,
"Accept", EL_STR("application/json"));
el_val_t resp = http_get_with_headers(EL_STR(url), headers);
if (!resp) return EL_STR("");
return resp;
}
el_val_t cwd(void) {
static char buf[4096];
if (!getcwd(buf, sizeof(buf))) buf[0] = '\0';
return EL_STR(buf);
}
el_val_t color_bold(el_val_t s) { return s; }
/* unix_timestamp is now provided by el_runtime.c (back-compat shim).
* Keeping the stub here would produce a duplicate symbol at link time. */
/*
* supabase_insert — POST a JSON row to a Supabase table via the REST API.
* Requires both apikey and Authorization: Bearer headers (Supabase convention).
* Returns the response body, or "" on failure.
*/
el_val_t supabase_insert(el_val_t project_url, el_val_t service_key, el_val_t table, el_val_t row_json) {
char url[512];
snprintf(url, sizeof(url), "%s/rest/v1/%s", EL_CSTR(project_url), EL_CSTR(table));
char bearer[2048];
snprintf(bearer, sizeof(bearer), "Bearer %s", EL_CSTR(service_key));
el_val_t headers = el_map_new(4,
"Authorization", EL_STR(bearer),
"apikey", service_key,
"Content-Type", EL_STR("application/json"),
"Prefer", EL_STR("return=minimal,resolution=merge-duplicates"));
return http_post_with_headers(EL_STR(url), row_json, headers);
}
/*
* supabase_admin_invite — POST {project_url}/auth/v1/invite with the
* service-role key to create-or-find a user and email them a magic-link
* invitation. body_json should look like:
* {"email":"...","data":{"name":"..."},"redirect_to":"..."}
* Returns the JSON response (contains the new/existing user's id under "id").
*
* Used by the Stripe webhook to auto-provision a Supabase account whenever
* payment_intent.succeeded or setup_intent.succeeded fires - so every buyer
* lands on /account with an account waiting for them, regardless of whether
* they signed in before payment.
*/
el_val_t supabase_admin_invite(el_val_t project_url, el_val_t service_key, el_val_t body_json) {
char url[512];
snprintf(url, sizeof(url), "%s/auth/v1/invite", EL_CSTR(project_url));
char bearer[2048];
snprintf(bearer, sizeof(bearer), "Bearer %s", EL_CSTR(service_key));
el_val_t headers = el_map_new(3,
"Authorization", EL_STR(bearer),
"apikey", service_key,
"Content-Type", EL_STR("application/json"));
return http_post_with_headers(EL_STR(url), body_json, headers);
}
/*
* gcs_get_token — fetch an OAuth2 bearer token.
*
* Priority:
* 1. GOOGLE_ACCESS_TOKEN env var (local dev: `gcloud auth print-access-token`)
* 2. GCP metadata server (Cloud Run, automatic SA credentials)
*
* Returns the raw token string, or "" on failure.
*/
static el_val_t gcs_get_token(void) {
/* 1. Env override (local dev) */
const char *env_tok = getenv("GOOGLE_ACCESS_TOKEN");
if (env_tok && *env_tok) return EL_STR(env_tok);
/* 2. Metadata server */
el_val_t meta_url = EL_STR(
"http://metadata.google.internal/computeMetadata/v1/instance"
"/service-accounts/default/token");
el_val_t meta_headers = el_map_new(1, "Metadata-Flavor", EL_STR("Google"));
el_val_t resp = http_get_with_headers(meta_url, meta_headers);
if (!resp) return EL_STR("");
/* Parse access_token field from JSON response */
const char *s = EL_CSTR(resp);
const char *key = "\"access_token\":\"";
const char *p = strstr(s, key);
if (!p) return EL_STR("");
p += strlen(key);
const char *end = strchr(p, '"');
if (!end) return EL_STR("");
size_t len = (size_t)(end - p);
char *tok = malloc(len + 1);
memcpy(tok, p, len);
tok[len] = '\0';
el_val_t result = EL_STR(tok);
/* Note: intentional small leak — token lives for process lifetime */
return result;
}
/*
* gcs_write — upload content to a GCS object.
*
* Uses the GCS JSON upload API (POST, media upload type).
* Returns "ok" on success, "" on failure.
*/
el_val_t gcs_write(el_val_t bucket, el_val_t object_name, el_val_t content) {
el_val_t tok = gcs_get_token();
if (!tok || !*EL_CSTR(tok)) return EL_STR("");
/* URL-encode the object name (replace / with %2F) */
const char *obj = EL_CSTR(object_name);
char encoded[1024];
size_t ei = 0;
for (size_t i = 0; obj[i] && ei < sizeof(encoded) - 4; i++) {
if (obj[i] == '/') { encoded[ei++] = '%'; encoded[ei++] = '2'; encoded[ei++] = 'F'; }
else encoded[ei++] = obj[i];
}
encoded[ei] = '\0';
/* Build upload URL */
char url[2048];
snprintf(url, sizeof(url),
"https://storage.googleapis.com/upload/storage/v1/b/%s/o"
"?uploadType=media&name=%s",
EL_CSTR(bucket), encoded);
/* POST with Authorization + Content-Type headers */
char bearer[2048];
snprintf(bearer, sizeof(bearer), "Bearer %s", EL_CSTR(tok));
el_val_t headers = el_map_new(2,
"Authorization", EL_STR(bearer),
"Content-Type", EL_STR("application/json"));
el_val_t resp = http_post_with_headers(EL_STR(url), content, headers);
if (!resp) return EL_STR("");
/* GCS returns the object metadata JSON on success */
const char *r = EL_CSTR(resp);
if (strstr(r, "\"name\"")) return EL_STR("ok");
return EL_STR("");
}
/*
* gcs_read — download a GCS object's content.
*
* Returns the raw content, or "" if the object does not exist or on error.
*/
el_val_t gcs_read(el_val_t bucket, el_val_t object_name) {
el_val_t tok = gcs_get_token();
if (!tok || !*EL_CSTR(tok)) return EL_STR("");
/* URL-encode object name */
const char *obj = EL_CSTR(object_name);
char encoded[1024];
size_t ei = 0;
for (size_t i = 0; obj[i] && ei < sizeof(encoded) - 4; i++) {
if (obj[i] == '/') { encoded[ei++] = '%'; encoded[ei++] = '2'; encoded[ei++] = 'F'; }
else encoded[ei++] = obj[i];
}
encoded[ei] = '\0';
char url[2048];
snprintf(url, sizeof(url),
"https://storage.googleapis.com/download/storage/v1/b/%s/o/%s?alt=media",
EL_CSTR(bucket), encoded);
char bearer[2048];
snprintf(bearer, sizeof(bearer), "Bearer %s", EL_CSTR(tok));
el_val_t headers = el_map_new(1, "Authorization", EL_STR(bearer));
el_val_t resp = http_get_with_headers(EL_STR(url), headers);
if (!resp) return EL_STR("");
return resp;
}