diff --git a/app/v2/broadcast/post_broadcast.py b/app/v2/broadcast/post_broadcast.py
index a1b483808..18fe37b64 100644
--- a/app/v2/broadcast/post_broadcast.py
+++ b/app/v2/broadcast/post_broadcast.py
@@ -8,6 +8,8 @@ from app.models import BROADCAST_TYPE, BroadcastMessage, BroadcastStatusType
from app.schema_validation import validate
from app.v2.broadcast import v2_broadcast_blueprint
from app.v2.broadcast.broadcast_schemas import post_broadcast_schema
+from app.v2.errors import BadRequestError
+from app.xml_schemas import validate_xml
@v2_broadcast_blueprint.route("", methods=['POST'])
@@ -21,7 +23,13 @@ def create_broadcast():
if request.content_type == 'application/json':
broadcast_json = request.get_json()
elif request.content_type == 'application/cap+xml':
- broadcast_json = cap_xml_to_dict(request.get_data(as_text=True))
+ cap_xml = request.get_data(as_text=True)
+ if not validate_xml(cap_xml, 'CAP-v1.2.xsd'):
+ raise BadRequestError(
+ message=f'Request data is not valid CAP XML',
+ status_code=400,
+ )
+ broadcast_json = cap_xml_to_dict(cap_xml)
else:
raise BadRequestError(
message=f'Content type {request.content_type} not supported',
diff --git a/app/xml_schemas/CAP-v1.2.xsd b/app/xml_schemas/CAP-v1.2.xsd
new file mode 100644
index 000000000..ed97952be
--- /dev/null
+++ b/app/xml_schemas/CAP-v1.2.xsd
@@ -0,0 +1,218 @@
+
+
+
+
+
+ CAP Alert Message (version 1.2)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/xml_schemas/__init__.py b/app/xml_schemas/__init__.py
new file mode 100644
index 000000000..4d8d11ef0
--- /dev/null
+++ b/app/xml_schemas/__init__.py
@@ -0,0 +1,19 @@
+from lxml import etree
+from pathlib import Path
+
+
+def validate_xml(document, schema_file_name):
+
+ path = Path(__file__).resolve().parent / schema_file_name
+ contents = path.read_text()
+
+ schema_root = etree.XML(contents.encode('utf-8'))
+ schema = etree.XMLSchema(schema_root)
+ parser = etree.XMLParser(schema=schema)
+
+ try:
+ etree.fromstring(document, parser)
+ except etree.XMLSyntaxError:
+ return False
+
+ return True
diff --git a/requirements-app.txt b/requirements-app.txt
index 8688b3922..cc017a729 100644
--- a/requirements-app.txt
+++ b/requirements-app.txt
@@ -24,6 +24,7 @@ strict-rfc3339==0.7
rfc3987==1.3.8
cachetools==4.2.0
beautifulsoup4==4.9.3
+lxml==4.6.2
notifications-python-client==5.7.1
diff --git a/requirements.txt b/requirements.txt
index bb5c70107..f9390d13e 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -26,6 +26,7 @@ strict-rfc3339==0.7
rfc3987==1.3.8
cachetools==4.2.0
beautifulsoup4==4.9.3
+lxml==4.6.2
notifications-python-client==5.7.1
@@ -39,18 +40,18 @@ prometheus-client==0.9.0
gds-metrics==0.2.4
## The following requirements were added by pip freeze:
-alembic==1.4.3
+alembic==1.5.0
amqp==1.4.9
anyjson==0.3.3
attrs==20.3.0
-awscli==1.18.215
+awscli==1.18.216
bcrypt==3.2.0
billiard==3.3.0.23
bleach==3.2.1
blinker==1.4
boto==2.49.0
-boto3==1.16.55
-botocore==1.19.55
+boto3==1.16.56
+botocore==1.19.56
certifi==2020.12.5
chardet==4.0.0
click==7.1.2
diff --git a/tests/app/v2/broadcast/test_post_broadcast.py b/tests/app/v2/broadcast/test_post_broadcast.py
index 587fe489b..e38918add 100644
--- a/tests/app/v2/broadcast/test_post_broadcast.py
+++ b/tests/app/v2/broadcast/test_post_broadcast.py
@@ -122,3 +122,25 @@ def test_valid_post_cap_xml_broadcast_returns_201(
assert response_json['template_name'] is None
assert response_json['template_version'] is None
assert response_json['updated_at'] is None
+
+
+def test_invalid_post_cap_xml_broadcast_returns_400(
+ client,
+ sample_broadcast_service,
+):
+ auth_header = create_authorization_header(service_id=sample_broadcast_service.id)
+
+ response = client.post(
+ path='/v2/broadcast',
+ data="Oh no",
+ headers=[('Content-Type', 'application/cap+xml'), auth_header],
+ )
+
+ assert response.status_code == 400
+ assert json.loads(response.get_data(as_text=True)) == {
+ 'errors': [{
+ 'error': 'BadRequestError',
+ 'message': 'Request data is not valid CAP XML'
+ }],
+ 'status_code': 400,
+ }