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:
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 "&".
+ *
+ * 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>