paivana

HTTP paywall reverse proxy
Log | Files | Refs | Submodules | README | LICENSE

commit 526d63c925ee11f34a3ee96ea923144a4a039e67
parent 1b754fe8da30ea906304931948adc7c8e1a16bae
Author: Christian Grothoff <christian@grothoff.org>
Date:   Thu,  6 Aug 2026 23:23:48 +0200

escape string literals for JS and set CSP policy

Diffstat:
Msrc/backend/paivana-httpd_templates.c | 179+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/frontend/paywall.en.must.j2 | 13+++++++++----
2 files changed, 188 insertions(+), 4 deletions(-)

diff --git a/src/backend/paivana-httpd_templates.c b/src/backend/paivana-httpd_templates.c @@ -351,6 +351,152 @@ make_taler_pay_template_uri (const char *merchant_base_url, /** + * Render @a s as a complete, double-quoted JavaScript string literal. + * + * The paywall page carries its context in `const' declarations inside a + * <script> element, and mustache's default escaping is the wrong + * escaping there twice over: it escapes exactly '<', '>', '&' and '"' + * (mustach-wrap.c), which leaves the apostrophe free to close a + * single-quoted literal and let arbitrary JavaScript follow, and the + * entity references it does produce are never decoded, because the HTML + * parser does not decode them inside a raw-text element -- an '&' in the + * merchant base URL reached the script as the literal text "&amp;". + * + * So the value is escaped for the context it actually lands in, and + * interpolated with the unescaped {{{ }}} since it arrives complete with + * its quotes. '<', '>' and '&' are still escaped, as \\uXXXX rather + * than as entities: without that a value containing "</script>" would + * end the element regardless of how well the string literal itself is + * quoted. + * + * @param s string to render, must be valid UTF-8 + * @return JavaScript literal including the surrounding quotes, + * to be freed by the caller + */ +static char * +js_string_literal (const char *s) +{ + struct GNUNET_Buffer buf = { 0 }; + + GNUNET_buffer_write_str (&buf, + "\""); + for (const unsigned char *p = (const unsigned char *) s; + '\0' != *p; + p++) + { + switch (*p) + { + case '"': + GNUNET_buffer_write_str (&buf, + "\\\""); + break; + case '\\': + GNUNET_buffer_write_str (&buf, + "\\\\"); + break; + default: + if ( (*p < 0x20) || + (0x7F == *p) || + ('<' == *p) || + ('>' == *p) || + ('&' == *p) ) + GNUNET_buffer_write_fstr (&buf, + "\\u%04x", + (unsigned int) *p); + else + GNUNET_buffer_write_fstr (&buf, + "%c", + (char) *p); + break; + } + } + GNUNET_buffer_write_str (&buf, + "\""); + return GNUNET_buffer_reap_str (&buf); +} + + +/** + * The `Content-Security-Policy' for the paywall page, built once from + * #PH_merchant_base_url. NULL until first needed. + */ +static char *paywall_csp; + + +/** + * Return the `Content-Security-Policy' for the paywall page. + * + * The page is entirely self-contained -- every stylesheet, script and + * image is inline -- so everything but the merchant backend it polls + * can be denied outright. That is the part worth having: with + * `default-src' at 'none' and `connect-src' naming exactly two origins, + * script that does run cannot reach an attacker's host to report what + * it found, and `frame-ancestors' keeps the taler:// link from being + * framed and clicked by proxy. + * + * `script-src' still has to permit inline script: the page carries three + * inline <script> elements and one `onclick' attribute, and neither + * hashes nor a nonce survive our response cache, which serves one + * rendered body to every client for five minutes. Removing + * 'unsafe-inline' means moving that handler into paywall.js and hashing + * each block after rendering; worth doing, but it is not what makes the + * injection this policy backs up impossible -- js_string_literal() is. + * + * @return the policy, owned by this module, or NULL if the merchant + * base URL cannot be parsed + */ +static const char * +get_paywall_csp (void) +{ + struct GNUNET_Buffer buf = { 0 }; + struct GNUNET_Uri uri; + char *url; + + if (NULL != paywall_csp) + return paywall_csp; + url = GNUNET_strdup (PH_merchant_base_url); + if ( (-1 == GNUNET_uri_parse (&uri, + url)) || + (NULL == uri.scheme) || + (NULL == uri.host) ) + { + /* Cannot name the backend, and a policy that omits it would break + the polling the page exists to do. Serve without one rather than + with a broken one; the URL is checked at startup, so this is the + unreachable arm. */ + GNUNET_break (0); + GNUNET_free (url); + return NULL; + } + GNUNET_buffer_write_str (&buf, + "default-src 'none'; " + "script-src 'unsafe-inline'; " + "style-src 'unsafe-inline'; " + /* the QR code is drawn to a canvas and + handed to an <img> as a data: URL */ + "img-src data:; " + "connect-src 'self' "); + GNUNET_buffer_write_str (&buf, + uri.scheme); + GNUNET_buffer_write_str (&buf, + "://"); + GNUNET_buffer_write_str (&buf, + uri.host); + if (0 != uri.port) + GNUNET_buffer_write_fstr (&buf, + ":%u", + (unsigned int) uri.port); + GNUNET_buffer_write_str (&buf, + "; frame-ancestors 'none'" + "; base-uri 'none'" + "; form-action 'none'"); + GNUNET_free (url); + paywall_csp = GNUNET_buffer_reap_str (&buf); + return paywall_csp; +} + + +/** * Try to initialize the paywall response. * * @param conn connection to create the response for @@ -400,11 +546,22 @@ load_paywall (struct MHD_Connection *conn, { enum GNUNET_GenericReturnValue ret; json_t *data; + char *tid_js = js_string_literal (t->template_id); + char *mb_js = js_string_literal (PH_merchant_base_url); data = GNUNET_JSON_PACK ( GNUNET_JSON_pack_string ( "template_id", t->template_id), + /* The `_js' variants are complete JavaScript string literals, + quotes included, for the <script> block; the plain ones are for + the HTML body, where mustache's own escaping is correct. */ + GNUNET_JSON_pack_string ( + "template_id_js", + tid_js), + GNUNET_JSON_pack_string ( + "merchant_backend_js", + mb_js), GNUNET_JSON_pack_allow_null ( GNUNET_JSON_pack_string ( "summary", @@ -429,6 +586,8 @@ load_paywall (struct MHD_Connection *conn, GNUNET_JSON_pack_string ( "merchant_backend", PH_merchant_base_url)); + GNUNET_free (tid_js); + GNUNET_free (mb_js); ret = TALER_TEMPLATING_build ( conn, &http_status, @@ -483,6 +642,25 @@ load_paywall (struct MHD_Connection *conn, MHD_HTTP_HEADER_CACHE_CONTROL, "public, max-age=300")); { + const char *csp = get_paywall_csp (); + + if (NULL != csp) + GNUNET_break (MHD_YES == + MHD_add_response_header (reply, + MHD_HTTP_HEADER_CONTENT_SECURITY_POLICY, + csp)); + } + /* frame-ancestors covers this for anything current; X-Frame-Options + is for the user agents that do not implement it. */ + GNUNET_break (MHD_YES == + MHD_add_response_header (reply, + MHD_HTTP_HEADER_X_FRAME_OPTIONS, + "DENY")); + GNUNET_break (MHD_YES == + MHD_add_response_header (reply, + MHD_HTTP_HEADER_X_CONTENT_TYPE_OPTIONS, + "nosniff")); + { char *uri; uri = make_taler_pay_template_uri (PH_merchant_base_url, @@ -987,4 +1165,5 @@ PAIVANA_HTTPD_unload_templates () TALER_MERCHANT_get_private_templates_cancel (gpt); gpt = NULL; } + GNUNET_free (paywall_csp); } diff --git a/src/frontend/paywall.en.must.j2 b/src/frontend/paywall.en.must.j2 @@ -14,11 +14,16 @@ const I18N_PAYMENT_CONFIRMED_ERROR = 'Could not reach the server!'; const I18N_PAYMENT_CONFIRMED_NO_ORDER = 'Unexpected 200 OK response without an order_id. Trying again.'; const I18N_PAYMENT_NETWORK_PROBLEM = 'Network error! Retrying...'; - const MERCHANT_BACKEND = '{{ merchant_backend }}'; - const MERCHANT_TEMPLATE_ID = '{{ template_id }}'; + // The _js values arrive as complete JavaScript string literals, + // quotes included, so they are interpolated unescaped: mustache's + // HTML escaping is the wrong escaping inside a <script> element + // and does not cover the apostrophe. See js_string_literal(). + const MERCHANT_BACKEND = {{{ merchant_backend_js }}}; + const MERCHANT_TEMPLATE_ID = {{{ template_id_js }}}; const MAX_PICKUP_DELAY = {{ max_pickup_delay }}; // in seconds - const TEMPLATE_SUMMARY = '{{ summary }}'; - const TEMPLATE_CHOICES = '{{{ choices }}}'; + // TODO: these are not used YET, but we might want to use them in the future: + // const TEMPLATE_SUMMARY = '{{ summary }}'; + // const TEMPLATE_CHOICES = '{{{ choices }}}'; const POLL_WAIT_MS = 30000; </script> <script>