Repository: fossasia/open-event-server

Issue:
Disallow attendee edit to change ticket, order, event, user
Under no circumstances should a user be able to edit ticket, order, event or user of an attendee.

This is a MASSIVE security issue in context of the server


Discussion:

Resolving patch:
diff --git a/app/api/attendees.py b/app/api/attendees.py
--- a/app/api/attendees.py
+++ b/app/api/attendees.py
@@ -2,6 +2,7 @@
 
 from flask_jwt_extended import current_user
 from flask_rest_jsonapi import ResourceDetail, ResourceList, ResourceRelationship
+from flask_rest_jsonapi.schema import get_relationships
 from sqlalchemy import and_, or_
 
 from app.api.bootstrap import api
@@ -13,6 +14,7 @@
 from app.api.helpers.utilities import require_relationship
 from app.api.schema.attendees import AttendeeSchema
 from app.models import db
+from app.models.custom_form import CustomForms
 from app.models.order import Order
 from app.models.ticket import Ticket
 from app.models.ticket_holder import TicketHolder
@@ -164,6 +166,27 @@ def query(self, view_kwargs):
     }
 
 
+def validate_custom_form_constraints(self, obj, data):
+    relationship_fields = get_relationships(self.resource.schema)
+    for key, value in data.items():
+        if hasattr(obj, key) and key not in relationship_fields:
+            setattr(obj, key, value)
+
+    required_form_fields = CustomForms.query.filter_by(
+        event_id=obj.event_id, is_included=True, is_required=True
+    )
+    missing_required_fields = []
+    for field in required_form_fields.all():
+        if not getattr(obj, field.identifier):
+            missing_required_fields.append(field.identifier)
+
+    if len(missing_required_fields) > 0:
+        raise UnprocessableEntityError(
+            {'pointer': '/data/attributes'},
+            f'Missing required fields {missing_required_fields}',
+        )
+
+
 class AttendeeDetail(ResourceDetail):
     """
     Attendee detail by id
@@ -205,20 +228,6 @@ def before_update_object(self, obj, data, kwargs):
         :param kwargs:
         :return:
         """
-        #         if not has_access('is_registrar', event_id=obj.event_id):
-        #         raise ForbiddenError({'source': 'User'}, 'You are not authorized to access this.')
-
-        if 'ticket' in data:
-            ticket = (
-                db.session.query(Ticket)
-                .filter_by(id=int(data['ticket']), deleted_at=None)
-                .first()
-            )
-            if ticket is None:
-                raise UnprocessableEntityError(
-                    {'pointer': '/data/relationships/ticket'}, "Invalid Ticket"
-                )
-
         if 'device_name_checkin' in data:
             if 'checkin_times' not in data or data['checkin_times'] is None:
                 raise UnprocessableEntityError(
@@ -288,6 +297,8 @@ def before_update_object(self, obj, data, kwargs):
                     obj.attendee_notes, data['attendee_notes']
                 )
 
+        validate_custom_form_constraints(self, obj, data)
+
     decorators = (jwt_required,)
     schema = AttendeeSchema
     data_layer = {
diff --git a/app/api/schema/attendees.py b/app/api/schema/attendees.py
--- a/app/api/schema/attendees.py
+++ b/app/api/schema/attendees.py
@@ -72,6 +72,7 @@ def validate_json(self, data, original_data):
         related_view_kwargs={'attendee_id': '<id>'},
         schema='EventSchema',
         type_='event',
+        dump_only=True,
     )
     user = Relationship(
         attribute='user',
@@ -81,6 +82,7 @@ def validate_json(self, data, original_data):
         related_view_kwargs={'attendee_id': '<id>'},
         schema='UserSchemaPublic',
         type_='user',
+        dump_only=True,
     )
 
 
@@ -107,6 +109,7 @@ class Meta:
         related_view_kwargs={'attendee_id': '<id>'},
         schema='TicketSchemaPublic',
         type_='ticket',
+        dump_only=True,
     )
     order = Relationship(
         self_view='v1.attendee_order',
@@ -115,4 +118,5 @@ class Meta:
         related_view_kwargs={'attendee_id': '<id>'},
         schema='OrderSchema',
         type_='order',
+        dump_only=True,
     )
diff --git a/app/models/custom_form.py b/app/models/custom_form.py
--- a/app/models/custom_form.py
+++ b/app/models/custom_form.py
@@ -1,4 +1,5 @@
 import json
+import re
 
 from sqlalchemy.event import listens_for
 from sqlalchemy.schema import UniqueConstraint
@@ -149,13 +150,17 @@ class CustomForms(SoftDeletionModel):
     type = db.Column(db.String, nullable=False)
     name = db.Column(db.String, nullable=False)
     description = db.Column(db.String, nullable=True)
-    is_required = db.Column(db.Boolean)
-    is_included = db.Column(db.Boolean)
-    is_fixed = db.Column(db.Boolean)
+    is_required = db.Column(db.Boolean, default=False)
+    is_included = db.Column(db.Boolean, default=False)
+    is_fixed = db.Column(db.Boolean, default=False)
     is_complex = db.Column(db.Boolean, nullable=False, default=False)
     event_id = db.Column(db.Integer, db.ForeignKey('events.id', ondelete='CASCADE'))
     custom_form_options = db.relationship('CustomFormOptions', backref="custom_form")
 
+    @property
+    def identifier(self):
+        return re.sub('([A-Z]+)', r'_\1', self.field_identifier).lower()
+
     def __repr__(self):
         return '<CustomForm %r>' % self.id
 


Test patch:
diff --git a/tests/all/integration/api/attendee/__init__.py b/tests/all/integration/api/attendee/__init__.py
new file mode 100644
diff --git a/tests/all/integration/api/attendee/test_attendee_api.py b/tests/all/integration/api/attendee/test_attendee_api.py
new file mode 100644
--- /dev/null
+++ b/tests/all/integration/api/attendee/test_attendee_api.py
@@ -0,0 +1,195 @@
+import json
+
+from app.models.custom_form import CustomForms
+from tests.factories.attendee import AttendeeOrderTicketSubFactory
+from tests.factories.order import OrderSubFactory
+from tests.factories.ticket import TicketSubFactory
+
+
+def get_minimal_attendee(db):
+    attendee = AttendeeOrderTicketSubFactory(
+        email=None, address=None, city=None, state=None, country=None
+    )
+    db.session.commit()
+
+    return attendee
+
+
+def test_edit_attendee_minimum_fields(db, client, jwt):
+    attendee = get_minimal_attendee(db)
+
+    data = json.dumps(
+        {
+            'data': {
+                'type': 'attendee',
+                'id': str(attendee.id),
+                "attributes": {"firstname": "Areeb", "lastname": "Jamal"},
+            }
+        }
+    )
+
+    client.patch(
+        f'/v1/attendees/{attendee.id}',
+        content_type='application/vnd.api+json',
+        headers=jwt,
+        data=data,
+    )
+
+    db.session.refresh(attendee)
+
+    assert attendee.firstname == 'Areeb'
+    assert attendee.lastname == 'Jamal'
+
+
+def test_edit_attendee_required_fields(db, client, jwt):
+    attendee = get_minimal_attendee(db)
+    CustomForms(
+        event=attendee.event,
+        form='attendee',
+        field_identifier='email',
+        type='text',
+        is_included=True,
+        is_required=True,
+    )
+    CustomForms(
+        event=attendee.event,
+        form='attendee',
+        field_identifier='taxBusinessInfo',
+        type='text',
+        is_included=True,
+        is_required=True,
+    )
+    db.session.commit()
+
+    data = json.dumps(
+        {
+            'data': {
+                'type': 'attendee',
+                'id': str(attendee.id),
+                "attributes": {"firstname": "Areeb", "lastname": "Jamal"},
+            }
+        }
+    )
+
+    response = client.patch(
+        f'/v1/attendees/{attendee.id}',
+        content_type='application/vnd.api+json',
+        headers=jwt,
+        data=data,
+    )
+
+    db.session.refresh(attendee)
+
+    assert response.status_code == 422
+    assert json.loads(response.data) == {
+        'errors': [
+            {
+                'detail': "Missing required fields ['email', 'tax_business_info']",
+                'source': {'pointer': '/data/attributes'},
+                'status': 422,
+                'title': 'Unprocessable Entity',
+            }
+        ],
+        'jsonapi': {'version': '1.0'},
+    }
+
+    assert attendee.firstname == 'Areeb'
+    assert attendee.lastname == 'Jamal'
+    assert attendee.email is None
+
+    data = json.dumps(
+        {
+            'data': {
+                'type': 'attendee',
+                'id': str(attendee.id),
+                "attributes": {
+                    "firstname": "Areeb",
+                    "lastname": "Jamal",
+                    "email": "test@test.org",
+                    "tax_business_info": "Hello",
+                },
+            }
+        }
+    )
+
+    response = client.patch(
+        f'/v1/attendees/{attendee.id}',
+        content_type='application/vnd.api+json',
+        headers=jwt,
+        data=data,
+    )
+
+    db.session.refresh(attendee)
+
+    assert response.status_code == 200
+
+    assert attendee.firstname == 'Areeb'
+    assert attendee.lastname == 'Jamal'
+    assert attendee.email == 'test@test.org'
+    assert attendee.tax_business_info == 'Hello'
+
+
+def test_edit_attendee_ticket(db, client, jwt):
+    attendee = AttendeeOrderTicketSubFactory()
+    ticket = TicketSubFactory(event=attendee.event)
+    db.session.commit()
+
+    attendee_ticket = attendee.ticket
+
+    data = json.dumps(
+        {
+            'data': {
+                'type': 'attendee',
+                'id': str(attendee.id),
+                "relationships": {
+                    "ticket": {"data": {"id": str(ticket.id), "type": "ticket"}}
+                },
+            }
+        }
+    )
+
+    client.patch(
+        f'/v1/attendees/{attendee.id}',
+        content_type='application/vnd.api+json',
+        headers=jwt,
+        data=data,
+    )
+
+    db.session.refresh(attendee)
+
+    # Ticket should not be updated
+    assert attendee.ticket.id != ticket.id
+    assert attendee.ticket.id == attendee_ticket.id
+
+
+def test_edit_attendee_order(db, client, jwt):
+    attendee = AttendeeOrderTicketSubFactory()
+    order = OrderSubFactory(event=attendee.event)
+    db.session.commit()
+
+    attendee_order = attendee.order
+
+    data = json.dumps(
+        {
+            'data': {
+                'type': 'attendee',
+                'id': str(attendee.id),
+                "relationships": {
+                    "order": {"data": {"id": str(order.id), "type": "order"}}
+                },
+            }
+        }
+    )
+
+    client.patch(
+        f'/v1/attendees/{attendee.id}',
+        content_type='application/vnd.api+json',
+        headers=jwt,
+        data=data,
+    )
+
+    db.session.refresh(attendee)
+
+    # Order should not be updated
+    assert attendee.order.id != order.id
+    assert attendee.order.id == attendee_order.id
diff --git a/tests/all/integration/api/helpers/test_csv_jobs_util.py b/tests/all/integration/api/helpers/test_csv_jobs_util.py
--- a/tests/all/integration/api/helpers/test_csv_jobs_util.py
+++ b/tests/all/integration/api/helpers/test_csv_jobs_util.py
@@ -30,7 +30,7 @@ def test_export_attendees_csv(self):
             test_attendee = AttendeeFactory()
             field_data = export_attendees_csv([test_attendee])
             self.assertEqual(field_data[1][3], common.string_)
-            self.assertEqual(field_data[1][5], 'user0@example.com')
+            self.assertEqual(field_data[1][5], test_attendee.email)
 
     def _test_export_session_csv(self, test_session=None):
         with self.app.test_request_context():
diff --git a/tests/all/integration/models/test_custom_forms.py b/tests/all/integration/models/test_custom_forms.py
--- a/tests/all/integration/models/test_custom_forms.py
+++ b/tests/all/integration/models/test_custom_forms.py
@@ -73,7 +73,11 @@ def test_assigning_old_form_name(db):
         event=event, field_identifier='longAbstract', form='session', type='text'
     )
     none_custom_form = CustomForms(
-        event=event, field_identifier='showNumber', name='Show Number', form='attendee', type='text'
+        event=event,
+        field_identifier='showNumber',
+        name='Show Number',
+        form='attendee',
+        type='text',
     )
     db.session.commit()
 
diff --git a/tests/factories/attendee.py b/tests/factories/attendee.py
--- a/tests/factories/attendee.py
+++ b/tests/factories/attendee.py
@@ -5,7 +5,7 @@
 from tests.factories.base import BaseFactory
 from tests.factories.event import EventFactoryBasic
 from tests.factories.order import OrderFactory, OrderSubFactory
-from tests.factories.ticket import TicketFactory
+from tests.factories.ticket import TicketFactory, TicketSubFactory
 
 
 class AttendeeFactoryBase(BaseFactory):
@@ -34,6 +34,10 @@ class AttendeeOrderSubFactory(AttendeeSubFactory):
     order = factory.SubFactory(OrderSubFactory)
 
 
+class AttendeeOrderTicketSubFactory(AttendeeOrderSubFactory):
+    ticket = factory.SubFactory(TicketSubFactory)
+
+
 class AttendeeFactory(AttendeeFactoryBase):
     event = factory.RelatedFactory(EventFactoryBasic)
     ticket = factory.RelatedFactory(TicketFactory)