Repository: openfun/marsha

Issue:
Manage HTML entities in transcripts
## Bug Report

**Expected behavior/code**

When I upload a transcript containig HTML entities (like `&lt;`) I want to see them interpreted in the interface (`<` for the same example).

**Actual Behavior**

The HTML is not interpreted and display directly. I see `&lt;` instead of `<`

![image](https://user-images.githubusercontent.com/767834/65783041-38049080-e14f-11e9-9dbe-6fa8a66c6c83.png)


**Steps to Reproduce**
1. Upload a transcript containing HTML entities
2. Display it
3. You will see the raw HTML entity and not the wanted character

**Possible Solution**

We would like to sanitize every uploaded transcript and then use the [dangerouslySetInnerHTML](https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml) in `TranscriptSentence` component.
To not introduce a security risk or double escape an already escaped character we will follow the steps:

- "Interpret" HTML entities to have the wanted character in the sentence
- Sanitize the interpreted sentence to escape possible attack
- save it

We need to found a good js library to do this job in our AWS lambda.



Discussion:

Resolving patch:
diff --git a/src/backend/marsha/core/migrations/0016_auto_20191001_0812.py b/src/backend/marsha/core/migrations/0016_auto_20191001_0812.py
new file mode 100644
--- /dev/null
+++ b/src/backend/marsha/core/migrations/0016_auto_20191001_0812.py
@@ -0,0 +1,77 @@
+# Generated by Django 2.2.5 on 2019-10-01 08:12
+
+import json
+import re
+
+from django.conf import settings
+from django.db import migrations
+
+import boto3
+
+
+def escape_timed_text_tracks(apps, schema_editor):
+    """Migration escaping existing timed text tracks.
+
+    In this migration we are looking for all existing timed text tracks already uploaded
+    on the S3 source bucket and invoke the marsha-encode lambda on them in order to escape every
+    sentences.
+    """
+    if not settings.SHOULD_RUN_TIMEDTEXT_TRACKS_ESCAPING_MIGRATION:
+        """Abort the migration if this setting is set to False."""
+        return
+
+    marsha_s3 = boto3.client(
+        "s3",
+        region_name=settings.AWS_S3_REGION_NAME,
+        aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
+        aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
+    )
+    marsha_lambda = boto3.client(
+        "lambda",
+        region_name=settings.AWS_S3_REGION_NAME,
+        aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
+        aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
+    )
+    paginator = marsha_s3.get_paginator("list_objects")
+    response_iterator = paginator.paginate(Bucket=settings.AWS_SOURCE_BUCKET_NAME)
+
+    regex = re.compile(r"^.*\/timedtexttrack\/.*$")
+
+    for response in response_iterator:
+        for ttt in response.get("Contents"):
+            if regex.match(ttt.get("Key")):
+                marsha_lambda.invoke(
+                    InvocationType="Event",
+                    FunctionName=settings.AWS_LAMBDA_ENCODE_NAME,
+                    Payload=json.dumps(
+                        {
+                            "Records": [
+                                {
+                                    "s3": {
+                                        "object": {"key": ttt.get("Key")},
+                                        "bucket": {
+                                            "name": settings.AWS_SOURCE_BUCKET_NAME
+                                        },
+                                    }
+                                }
+                            ]
+                        }
+                    ),
+                )
+
+
+def escape_timed_text_tracks_reverse(apps, schema_editor):
+    """In order to accept rollback we have to create a reverse function.
+
+    This function does nothing, we can't undo the escape operation and there is
+    no real reason to do that.
+    """
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [("core", "0015_auto_20190904_1234")]
+
+    operations = [
+        migrations.RunPython(escape_timed_text_tracks, escape_timed_text_tracks_reverse)
+    ]
diff --git a/src/backend/marsha/settings.py b/src/backend/marsha/settings.py
--- a/src/backend/marsha/settings.py
+++ b/src/backend/marsha/settings.py
@@ -196,7 +196,7 @@ class Base(Configuration):
     AWS_SECRET_ACCESS_KEY = values.SecretValue()
     AWS_S3_REGION_NAME = values.Value("eu-west-1")
     AWS_S3_URL_PROTOCOL = values.Value("https")
-    AWS_SOURCE_BUCKET_NAME = values.Value()
+    AWS_BASE_NAME = values.Value()
     UPDATE_STATE_SHARED_SECRETS = values.ListValue()
 
     # Cloud Front key pair for signed urls
@@ -214,6 +214,31 @@ class Base(Configuration):
     # Cache
     APP_DATA_CACHE_DURATION = values.Value(60)  # 60 secondes
 
+    # This setting determines if migration 0016 should be run
+    # This migration will look for every uploaded timed text track in the
+    # S3 source bucket and then trigger the lambda to escape them
+    # By default this setting is false but overriden to true in production,
+    # staging and preprod
+    SHOULD_RUN_TIMEDTEXT_TRACKS_ESCAPING_MIGRATION = values.BooleanValue(True)
+
+    # pylint: disable=invalid-name
+    @property
+    def AWS_SOURCE_BUCKET_NAME(self):
+        """Source bucket name.
+
+        If this setting is set in an environment variable we use it. Otherwise
+        the value is computed with the AWS_BASE_NAME value.
+        """
+        return os.environ.get(
+            "DJANGO_AWS_SOURCE_BUCKET_NAME", f"{self.AWS_BASE_NAME}-marsha-source"
+        )
+
+    # pylint: disable=invalid-name
+    @property
+    def AWS_LAMBDA_ENCODE_NAME(self):
+        """Lambda encode name."""
+        return f"{self.AWS_BASE_NAME}-marsha-encode"
+
     # pylint: disable=invalid-name
     @property
     def SIMPLE_JWT(self):
@@ -266,7 +291,7 @@ class Development(Base):
     """
 
     ALLOWED_HOSTS = ["*"]
-    AWS_SOURCE_BUCKET_NAME = values.Value("development-marsha-source")
+    AWS_BASE_NAME = values.Value("development")
     DEBUG = values.BooleanValue(True)
     CLOUDFRONT_SIGNED_URLS_ACTIVE = values.BooleanValue(False)
     CACHES = {"default": {"BACKEND": "django.core.cache.backends.dummy.DummyCache"}}
@@ -287,12 +312,15 @@ class Development(Base):
         }
     )
 
+    SHOULD_RUN_TIMEDTEXT_TRACKS_ESCAPING_MIGRATION = values.BooleanValue(False)
+
 
 class Test(Base):
     """Test environment settings."""
 
     CLOUDFRONT_SIGNED_URLS_ACTIVE = False
-    AWS_SOURCE_BUCKET_NAME = values.Value("test-marsha-source")
+    AWS_BASE_NAME = values.Value("test")
+    SHOULD_RUN_TIMEDTEXT_TRACKS_ESCAPING_MIGRATION = values.BooleanValue(False)
 
 
 class Production(Base):
@@ -305,7 +333,6 @@ class Production(Base):
     """
 
     ALLOWED_HOSTS = values.ListValue(None)
-    AWS_SOURCE_BUCKET_NAME = values.Value("production-marsha-source")
 
     # For static files in production, we want to use a backend that includes a hash in
     # the filename, that is calculated from the file content, so that browsers always
@@ -325,7 +352,7 @@ class Production(Base):
         "Expires": "Thu, 31 Dec 2099 20:00:00 GMT",
         "CacheControl": "max-age=94608000",
     }
-    AWS_STATIC_BUCKET_NAME = values.Value("production-marsha-static")
+    AWS_BASE_NAME = values.Value("production")
 
     # folder where static will be stored. It matches the path_pattern used
     # in the cloudfront configuration.
@@ -344,16 +371,26 @@ def ABSOLUTE_STATIC_URL(self):
         """Compute the absolute static url used in the lti template."""
         return f"//{self.CLOUDFRONT_DOMAIN}{self.STATIC_URL}"
 
+    # pylint: disable=invalid-name
+    @property
+    def AWS_STATIC_BUCKET_NAME(self):
+        """AWS Static bucket name.
+
+        If this setting is set in an environment variable we use it. Otherwise
+        the value is computed with the AWS_BASE_NAME value.
+        """
+        return os.environ.get(
+            "DJANGO_AWS_STATIC_BUCKET_NAME", f"{self.AWS_BASE_NAME}-marsha-static"
+        )
+
 
 class Staging(Production):
     """Staging environment settings."""
 
-    AWS_SOURCE_BUCKET_NAME = values.Value("staging-marsha-source")
-    AWS_STATIC_BUCKET_NAME = values.Value("staging-marsha-static")
+    AWS_BASE_NAME = values.Value("staging")
 
 
 class PreProduction(Production):
     """Pre-production environment settings."""
 
-    AWS_SOURCE_BUCKET_NAME = values.Value("preprod-marsha-source")
-    AWS_STATIC_BUCKET_NAME = values.Value("preprod-marsha-static")
+    AWS_BASE_NAME = values.Value("preprod")


Test patch: