Bank transfer: Fix incorrect HTML escaping in QR Code (fix #4780) (#6201)

* Fix EPC QR beneficiary escaping

* Fix EPC QR script encoding

Keep EPC QR helper output as a plain string and serialize payment QR payloads as JSON script data before the QR replacement JavaScript parses them. This preserves apostrophes without relying on mark_safe in the helper.

Assisted-by: OpenAI GPT-5 <noreply@openai.com>

* "type safety"

---------

Co-authored-by: Puneet Dixit <236133619+puneetdixit200@users.noreply.github.com>
Co-authored-by: Raphael Michel <michel@rami.io>
This commit is contained in:
Puneet Dixit
2026-07-06 12:52:29 +02:00
committed by GitHub
co-authored by Puneet Dixit Raphael Michel
parent 3b285a89dd
commit 3270c4e583
5 changed files with 53 additions and 8 deletions
+14 -1
View File
@@ -25,6 +25,19 @@ import text_unidecode
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
EPC_QR_ALLOWED_CHARS = set(
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
"abcdefghijklmnopqrstuvwxyz"
"0123456789/-?:().,'+ "
)
def epc_qr_field(value):
return ''.join(
char for char in text_unidecode.unidecode(str(value or ''))
if char in EPC_QR_ALLOWED_CHARS
)
def dotdecimal(value):
return str(value).replace(",", ".")
@@ -77,7 +90,7 @@ def euro_epc_qr(
return {
"id": "girocode",
"label": "EPC-QR",
"qr_data": "\n".join(text_unidecode.unidecode(str(d or '')) for d in [
"qr_data": "\n".join(epc_qr_field(d) for d in [
"BCD", # Service Tag: BCD
"002", # Version: V2
"2", # Character set: ISO 8859-1
@@ -1,5 +1,6 @@
{% load i18n %}
{% load static %}
{% load escapejson %}
{% if payment_qr_codes %}
<div class="tabcontainer col-md-6 col-sm-6 hidden-xs text-center js-only blank-after">
@@ -14,7 +15,7 @@
{% if code_info.link %}<a aria-label="{{ code_info.link_aria_label }}" href="{{ code_info.link }}">{% endif %}
<div class="{{ code_info.css_class }}" role="figure" aria-labelledby="banktransfer_qrcodes_{{ code_info.id }}_tab banktransfer_qrcodes_label">
{{ code_info.html_prefix }}
<script type="text/plain" data-size="150" data-replace-with-qr data-desc="{% trans 'Scan this image with your banking apps QR-Reader to start the payment process.' %}">{{ code_info.qr_data }}</script>
<script type="application/json" data-size="150" data-replace-with-qr data-desc="{% trans 'Scan this image with your banking apps QR-Reader to start the payment process.' %}">{{ code_info.qr_data|escapejson_dumps }}</script>
</div>
{% if code_info.link %}</a>{% endif %}
</div>
@@ -41,4 +42,4 @@
<link rel="stylesheet" href="{% static "pretixplugins/banktransfer/swisscross.css" %}">
{% endif %}
{% endfor %}
{% endif %}
{% endif %}
@@ -666,10 +666,12 @@ var form_handlers = function (el) {
el.find("script[data-replace-with-qr]").each(function () {
var $div = $("<div>");
var qrText = this.getAttribute("type") === "application/json" && this.textContent.startsWith('"') ?
JSON.parse(this.textContent) : $(this).html();
$div.insertBefore($(this));
$div.qrcode(
{
text: $(this).html(),
text: qrText,
correctLevel: 0, // M
width: $(this).attr("data-size") ? parseInt($(this).attr("data-size")) : 256,
height: $(this).attr("data-size") ? parseInt($(this).attr("data-size")) : 256,
@@ -131,10 +131,12 @@ var form_handlers = function (el) {
el.find("script[data-replace-with-qr]").each(function () {
var $div = $("<div>");
var qrText = this.getAttribute("type") === "application/json" && this.textContent.startsWith('"') ?
JSON.parse(this.textContent) : $(this).html();
$div.insertBefore($(this));
$div.qrcode(
{
text: $(this).html(),
text: qrText,
correctLevel: 0, // M
width: $(this).attr("data-size") ? parseInt($(this).attr("data-size")) : 256,
height: $(this).attr("data-size") ? parseInt($(this).attr("data-size")) : 256,
@@ -345,7 +347,7 @@ function setup_basics(el) {
}).on('click', function (event) {
setCurrentTab(this);
});
var firstTab = tabs.first().get(0);
var lastTab = tabs.last().get(0);
setCurrentTab(tabs.filter('[aria-selected=true]').get(0));
@@ -658,7 +660,7 @@ $(function () {
var currentTimeDisplayParts = [];
timeFormatParts.forEach(function(format) {
currentTimeDisplayParts.push([format, $("<span></span>").appendTo(currentTimeDisplay)])
});
});
var duration = this.getAttribute("data-duration").split(":").reduce(function(previousValue, currentValue, currentIndex) {
return previousValue + (currentIndex ? parseInt(currentValue, 10) * 60 : parseInt(currentValue, 10) * 60 * 60);
}, 0);
@@ -671,7 +673,7 @@ $(function () {
currentTimeBar.remove();
return;
}
var offset = thisCalendar.querySelector("h3").getBoundingClientRect().width;
var dx = Math.round(offset + (thisCalendar.scrollWidth-offset)*(currentTimeDelta/duration));
currentTimeDisplayParts.forEach(function(part) {
+27
View File
@@ -23,6 +23,8 @@ from datetime import timedelta
from decimal import Decimal
import pytest
from django.template.loader import render_to_string
from django.utils.safestring import SafeData
from django.utils.timezone import now
from pretix.base.models import Event, Organizer
@@ -77,6 +79,31 @@ TESTVERANST-12345
'&bic=BYLADEM1MIL&amount=123%2C00&reason=TESTVERANST-12345&currency=EUR')
@pytest.mark.django_db
def test_payment_qr_codes_euro_keeps_allowed_apostrophe_unescaped(env):
o, event = env
codes = generate_payment_qr_codes(
event=event,
code='TESTVERANST-12345',
amount=Decimal('123.00'),
bank_details_sepa_bic='BYLADEM1MIL',
bank_details_sepa_iban='DE37796500000069799047',
bank_details_sepa_name='Bits\'n"Bugs',
)
qr_data = codes[0]['qr_data']
assert '\nBits\'nBugs\n' in qr_data
assert not isinstance(qr_data, SafeData)
html = render_to_string(
'pretixpresale/event/payment_qr_codes.html',
{'payment_qr_codes': codes},
)
assert 'type="application/json"' in html
assert "\\nBits'nBugs\\n" in html
assert '&#x27;' not in html
@pytest.mark.django_db
def test_payment_qr_codes_swiss(env):
o, event = env