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 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. 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 :<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** 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 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 # 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/>. # <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)) ignore_unpaid = bool(self.request.data.get('ignore_unpaid', False))
nonce = self.request.data.get('nonce') 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: if 'datetime' in self.request.data:
dt = DateTimeField().to_internal_value(self.request.data.get('datetime')) dt = DateTimeField().to_internal_value(self.request.data.get('datetime'))
else: else:
@@ -429,7 +434,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
try: try:
queryset = self.get_queryset(ignore_status=True, ignore_products=True) 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'])) op = queryset.get(Q(pk=self.kwargs['pk']) | Q(secret=self.kwargs['pk']))
else: else:
# In application/x-www-form-urlencoded, you can encodes space ' ' with '+' instead of '%20'. # In application/x-www-form-urlencoded, you can encodes space ' ' with '+' instead of '%20'.

View File

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

View File

@@ -1199,7 +1199,6 @@ def test_redeem_unknown_legacy_device_bug(device, device_client, organizer, clis
), { ), {
'force': True 'force': True
}, format='json') }, format='json')
print(resp.data)
assert resp.status_code == 400 assert resp.status_code == 400
assert resp.data["status"] == "error" assert resp.data["status"] == "error"
assert resp.data["reason"] == "already_redeemed" 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" assert resp.data["reason"] == "invalid"
with scopes_disabled(): with scopes_disabled():
assert not Checkin.objects.last() 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