Compare commits

...

2 Commits

Author SHA1 Message Date
Raphael Michel
fb31d899c2 Bump to 4.10.1 2022-07-05 14:46:22 +02:00
Raphael Michel
03a60fb561 [SECURITY] Add untrusted_input flag to ticket redemption API 2022-07-05 14:46:18 +02:00
5 changed files with 62 additions and 10 deletions

View File

@@ -611,8 +611,12 @@ Order position endpoints
Tries to redeem an order position, identified by its internal ID, i.e. checks the attendee in. This endpoint
accepts a number of optional requests in the body.
**Tip:** Instead of an ID, you can also use the ``secret`` field as the lookup parameter.
**Tip:** Instead of an ID, you can also use the ``secret`` field as the lookup parameter. In this case, you should
always set ``untrusted_input=true`` as a query parameter to avoid security issues.
:query boolean untrusted_input: If set to true, the lookup parameter is **always** interpreted as a ``secret``, never
as an ``id``. This should be always set if you are passing through untrusted, scanned
data to avoid guessing of ticket IDs.
:<json boolean questions_supported: When this parameter is set to ``true``, handling of questions is supported. If
you do not implement question handling in your user interface, you **must**
set this to ``false``. In that case, questions will just be ignored. Defaults

View File

@@ -19,4 +19,4 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
__version__ = "4.10.0"
__version__ = "4.10.1"

View File

@@ -409,6 +409,11 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
ignore_unpaid = bool(self.request.data.get('ignore_unpaid', False))
nonce = self.request.data.get('nonce')
untrusted_input = (
self.request.GET.get('untrusted_input', '') not in ('0', 'false', 'False', '')
or (isinstance(self.request.auth, Device) and 'pretixscan' in (self.request.auth.software_brand or '').lower())
)
if 'datetime' in self.request.data:
dt = DateTimeField().to_internal_value(self.request.data.get('datetime'))
else:
@@ -429,7 +434,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
try:
queryset = self.get_queryset(ignore_status=True, ignore_products=True)
if self.kwargs['pk'].isnumeric():
if self.kwargs['pk'].isnumeric() and not untrusted_input:
op = queryset.get(Q(pk=self.kwargs['pk']) | Q(secret=self.kwargs['pk']))
else:
# In application/x-www-form-urlencoded, you can encodes space ' ' with '+' instead of '%20'.

View File

@@ -128,7 +128,7 @@
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary pull-right" @click="check(checkResult.position.secret, true, false, false)">
<button type="button" class="btn btn-primary pull-right" @click="check(checkResult.position.secret, true, false, false, true)">
{{ $root.strings['modal.continue'] }}
</button>
<button type="button" class="btn btn-default" @click="showUnpaidModal = false">
@@ -188,7 +188,7 @@
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary pull-right" @click="check(checkResult.position.secret, true, true)">
<button type="button" class="btn btn-primary pull-right" @click="check(checkResult.position.secret, true, true, true)">
{{ $root.strings['modal.continue'] }}
</button>
<button type="button" class="btn btn-default" @click="showQuestionsModal = false">
@@ -296,7 +296,7 @@ export default {
},
methods: {
selectResult(res) {
this.check(res.id, false, false, false)
this.check(res.id, false, false, false, false)
},
answerSetM(qid, opid, checked) {
let arr = this.answers[qid] ? this.answers[qid].split(',') : [];
@@ -320,7 +320,7 @@ export default {
this.showQuestionsModal = false
this.answers = {}
},
check(id, ignoreUnpaid, keepAnswers, fallbackToSearch) {
check(id, ignoreUnpaid, keepAnswers, fallbackToSearch, untrusted) {
if (!keepAnswers) {
this.answers = {}
} else if (this.showQuestionsModal) {
@@ -339,7 +339,11 @@ export default {
this.$refs.input.blur()
})
fetch(this.$root.api.lists + this.checkinlist.id + '/positions/' + encodeURIComponent(id) + '/redeem/?expand=item&expand=subevent&expand=variation', {
let url = this.$root.api.lists + this.checkinlist.id + '/positions/' + encodeURIComponent(id) + '/redeem/?expand=item&expand=subevent&expand=variation'
if (untrusted) {
url += '&untrusted_input=true'
}
fetch(url, {
method: 'POST',
headers: {
'X-CSRFToken': document.querySelector("input[name=csrfmiddlewaretoken]").value,
@@ -439,7 +443,7 @@ export default {
startSearch(fallbackToScan) {
if (this.query.length >= 32 && fallbackToScan) {
// likely a secret, not a search result
this.check(this.query, false, false, true)
this.check(this.query, false, false, true, true)
return
}

View File

@@ -1199,7 +1199,6 @@ def test_redeem_unknown_legacy_device_bug(device, device_client, organizer, clis
), {
'force': True
}, format='json')
print(resp.data)
assert resp.status_code == 400
assert resp.data["status"] == "error"
assert resp.data["reason"] == "already_redeemed"
@@ -1219,3 +1218,43 @@ def test_redeem_unknown_legacy_device_bug(device, device_client, organizer, clis
assert resp.data["reason"] == "invalid"
with scopes_disabled():
assert not Checkin.objects.last()
@pytest.mark.django_db
def test_redeem_by_id_not_allowed_if_pretixscan(device, device_client, organizer, clist, event, order):
with scopes_disabled():
p = order.positions.first()
device.software_brand = "pretixSCAN"
device.software_version = "1.14.2"
device.save()
resp = device_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/{}/redeem/'.format(
organizer.slug, event.slug, clist.pk, p.pk
), {
'force': True
}, format='json')
print(resp.data)
assert resp.status_code == 404
resp = device_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/{}/redeem/'.format(
organizer.slug, event.slug, clist.pk, p.secret
), {
'force': True
}, format='json')
assert resp.status_code == 201
@pytest.mark.django_db
def test_redeem_by_id_not_allowed_if_untrusted(device, device_client, organizer, clist, event, order):
with scopes_disabled():
p = order.positions.first()
resp = device_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/{}/redeem/?untrusted_input=true'.format(
organizer.slug, event.slug, clist.pk, p.pk
), {
'force': True
}, format='json')
assert resp.status_code == 404
resp = device_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/{}/redeem/?untrusted_input=true'.format(
organizer.slug, event.slug, clist.pk, p.secret
), {
'force': True
}, format='json')
assert resp.status_code == 201