(back) allow to disable checking unsafe mimetype on attachment upload

We added the possibility to scan all uploaded files with an anti malware
solution. Depending the backend used, we want to give the possibility to
check the file mimtype to determine if this one is tagged as unsafe or
not. To this you can set the environment variable
DOCUMENT_ATTACHMENT_CHECK_UNSAFE_MIME_TYPES_ENABLED to False. The
default value is True.
This commit is contained in:
Manuel Raynaud
2025-06-27 17:31:15 +02:00
committed by GitHub
parent 95a55e7805
commit 45bbffdf9f
5 changed files with 165 additions and 105 deletions

View File

@@ -15,6 +15,7 @@ and this project adheres to
- 📝(project) add system-requirement doc #1066 - 📝(project) add system-requirement doc #1066
- 🔧(front) configure x-frame-options to DENY in nginx conf #1084 - 🔧(front) configure x-frame-options to DENY in nginx conf #1084
- (doc) add documentation to install with compose #855 - (doc) add documentation to install with compose #855
- ✨(backend) allow to disable checking unsafe mimetype on attachment upload
### Changed ### Changed

View File

@@ -6,102 +6,103 @@ Here we describe all environment variables that can be set for the docs applicat
These are the environment variables you can set for the `impress-backend` container. These are the environment variables you can set for the `impress-backend` container.
| Option | Description | default | | Option | Description | default |
|-------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------| |------------------------------------------------- |----------------------------------------------------------------------------------------------------------------------------- |-------------------------------------------------------------------------|
| AI_ALLOW_REACH_FROM | Users that can use AI must be this level. options are "public", "authenticated", "restricted" | authenticated | | AI_ALLOW_REACH_FROM | Users that can use AI must be this level. options are "public", "authenticated", "restricted" | authenticated |
| AI_API_KEY | AI key to be used for AI Base url | | | AI_API_KEY | AI key to be used for AI Base url | |
| AI_BASE_URL | OpenAI compatible AI base url | | | AI_BASE_URL | OpenAI compatible AI base url | |
| AI_FEATURE_ENABLED | Enable AI options | false | | AI_FEATURE_ENABLED | Enable AI options | false |
| AI_MODEL | AI Model to use | | | AI_MODEL | AI Model to use | |
| ALLOW_LOGOUT_GET_METHOD | Allow get logout method | true | | ALLOW_LOGOUT_GET_METHOD | Allow get logout method | true |
| API_USERS_LIST_LIMIT | Limit on API users | 5 | | API_USERS_LIST_LIMIT | Limit on API users | 5 |
| API_USERS_LIST_THROTTLE_RATE_BURST | Throttle rate for api on burst | 30/minute | | API_USERS_LIST_THROTTLE_RATE_BURST | Throttle rate for api on burst | 30/minute |
| API_USERS_LIST_THROTTLE_RATE_SUSTAINED | Throttle rate for api | 180/hour | | API_USERS_LIST_THROTTLE_RATE_SUSTAINED | Throttle rate for api | 180/hour |
| AWS_S3_ACCESS_KEY_ID | Access id for s3 endpoint | | | AWS_S3_ACCESS_KEY_ID | Access id for s3 endpoint | |
| AWS_S3_ENDPOINT_URL | S3 endpoint | | | AWS_S3_ENDPOINT_URL | S3 endpoint | |
| AWS_S3_REGION_NAME | Region name for s3 endpoint | | | AWS_S3_REGION_NAME | Region name for s3 endpoint | |
| AWS_S3_SECRET_ACCESS_KEY | Access key for s3 endpoint | | | AWS_S3_SECRET_ACCESS_KEY | Access key for s3 endpoint | |
| AWS_STORAGE_BUCKET_NAME | Bucket name for s3 endpoint | impress-media-storage | | AWS_STORAGE_BUCKET_NAME | Bucket name for s3 endpoint | impress-media-storage |
| CACHES_DEFAULT_TIMEOUT | Cache default timeout | 30 | | CACHES_DEFAULT_TIMEOUT | Cache default timeout | 30 |
| CACHES_KEY_PREFIX | The prefix used to every cache keys. | docs | | CACHES_KEY_PREFIX | The prefix used to every cache keys. | docs |
| COLLABORATION_API_URL | Collaboration api host | | | COLLABORATION_API_URL | Collaboration api host | |
| COLLABORATION_SERVER_SECRET | Collaboration api secret | | | COLLABORATION_SERVER_SECRET | Collaboration api secret | |
| COLLABORATION_WS_NOT_CONNECTED_READY_ONLY | Users not connected to the collaboration server cannot edit | false | | COLLABORATION_WS_NOT_CONNECTED_READY_ONLY | Users not connected to the collaboration server cannot edit | false |
| COLLABORATION_WS_URL | Collaboration websocket url | | | COLLABORATION_WS_URL | Collaboration websocket url | |
| CONVERSION_API_CONTENT_FIELD | Conversion api content field | content | | CONVERSION_API_CONTENT_FIELD | Conversion api content field | content |
| CONVERSION_API_ENDPOINT | Conversion API endpoint | convert-markdown | | CONVERSION_API_ENDPOINT | Conversion API endpoint | convert-markdown |
| CONVERSION_API_SECURE | Require secure conversion api | false | | CONVERSION_API_SECURE | Require secure conversion api | false |
| CONVERSION_API_TIMEOUT | Conversion api timeout | 30 | | CONVERSION_API_TIMEOUT | Conversion api timeout | 30 |
| CRISP_WEBSITE_ID | Crisp website id for support | | | CRISP_WEBSITE_ID | Crisp website id for support | |
| DB_ENGINE | Engine to use for database connections | django.db.backends.postgresql_psycopg2 | | DB_ENGINE | Engine to use for database connections | django.db.backends.postgresql_psycopg2 |
| DB_HOST | Host of the database | localhost | | DB_HOST | Host of the database | localhost |
| DB_NAME | Name of the database | impress | | DB_NAME | Name of the database | impress |
| DB_PASSWORD | Password to authenticate with | pass | | DB_PASSWORD | Password to authenticate with | pass |
| DB_PORT | Port of the database | 5432 | | DB_PORT | Port of the database | 5432 |
| DB_USER | User to authenticate with | dinum | | DB_USER | User to authenticate with | dinum |
| DJANGO_ALLOWED_HOSTS | Allowed hosts | [] | | DJANGO_ALLOWED_HOSTS | Allowed hosts | [] |
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | Celery broker transport options | {} | | DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | Celery broker transport options | {} |
| DJANGO_CELERY_BROKER_URL | Celery broker url | redis://redis:6379/0 | | DJANGO_CELERY_BROKER_URL | Celery broker url | redis://redis:6379/0 |
| DJANGO_CORS_ALLOW_ALL_ORIGINS | Allow all CORS origins | false | | DJANGO_CORS_ALLOW_ALL_ORIGINS | Allow all CORS origins | false |
| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | List of origins allowed for CORS using regulair expressions | [] | | DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | List of origins allowed for CORS using regulair expressions | [] |
| DJANGO_CORS_ALLOWED_ORIGINS | List of origins allowed for CORS | [] | | DJANGO_CORS_ALLOWED_ORIGINS | List of origins allowed for CORS | [] |
| DJANGO_CSRF_TRUSTED_ORIGINS | CSRF trusted origins | [] | | DJANGO_CSRF_TRUSTED_ORIGINS | CSRF trusted origins | [] |
| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend | | DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend |
| DJANGO_EMAIL_BRAND_NAME | Brand name for email | | | DJANGO_EMAIL_BRAND_NAME | Brand name for email | |
| DJANGO_EMAIL_FROM | Email address used as sender | from@example.com | | DJANGO_EMAIL_FROM | Email address used as sender | from@example.com |
| DJANGO_EMAIL_HOST | Hostname of email | | | DJANGO_EMAIL_HOST | Hostname of email | |
| DJANGO_EMAIL_HOST_PASSWORD | Password to authenticate with on the email host | | | DJANGO_EMAIL_HOST_PASSWORD | Password to authenticate with on the email host | |
| DJANGO_EMAIL_HOST_USER | User to authenticate with on the email host | | | DJANGO_EMAIL_HOST_USER | User to authenticate with on the email host | |
| DJANGO_EMAIL_LOGO_IMG | Logo for the email | | | DJANGO_EMAIL_LOGO_IMG | Logo for the email | |
| DJANGO_EMAIL_PORT | Port used to connect to email host | | | DJANGO_EMAIL_PORT | Port used to connect to email host | |
| DJANGO_EMAIL_USE_SSL | Use ssl for email host connection | false | | DJANGO_EMAIL_USE_SSL | Use ssl for email host connection | false |
| DJANGO_EMAIL_USE_TLS | Use tls for email host connection | false | | DJANGO_EMAIL_USE_TLS | Use tls for email host connection | false |
| DJANGO_SECRET_KEY | Secret key | | | DJANGO_SECRET_KEY | Secret key | |
| DJANGO_SERVER_TO_SERVER_API_TOKENS | | [] | | DJANGO_SERVER_TO_SERVER_API_TOKENS | | [] |
| DOCUMENT_IMAGE_MAX_SIZE | Maximum size of document in bytes | 10485760 | | DOCUMENT_ATTACHMENT_CHECK_UNSAFE_MIME_TYPES_ENABLED | Check mime type extension to determine if a file is unsafe or not. Can be disable if an antivirus is used in the MALWARE_DETECTION_BACKEND | True |
| FRONTEND_CSS_URL | To add a external css file to the app | | | DOCUMENT_IMAGE_MAX_SIZE | Maximum size of document in bytes | 10485760 |
| FRONTEND_HOMEPAGE_FEATURE_ENABLED | Frontend feature flag to display the homepage | false | | FRONTEND_CSS_URL | To add a external css file to the app | |
| FRONTEND_THEME | Frontend theme to use | | | FRONTEND_HOMEPAGE_FEATURE_ENABLED | Frontend feature flag to display the homepage | false |
| LANGUAGE_CODE | Default language | en-us | | FRONTEND_THEME | Frontend theme to use | |
| LOGGING_LEVEL_LOGGERS_APP | Application logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO | | LANGUAGE_CODE | Default language | en-us |
| LOGGING_LEVEL_LOGGERS_ROOT | Default logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO | | LOGGING_LEVEL_LOGGERS_APP | Application logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |
| LOGIN_REDIRECT_URL | Login redirect url | | | LOGGING_LEVEL_LOGGERS_ROOT | Default logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |
| LOGIN_REDIRECT_URL_FAILURE | Login redirect url on failure | | | LOGIN_REDIRECT_URL | Login redirect url | |
| LOGOUT_REDIRECT_URL | Logout redirect url | | | LOGIN_REDIRECT_URL_FAILURE | Login redirect url on failure | |
| MALWARE_DETECTION_BACKEND | The malware detection backend use from the django-lasuite package | lasuite.malware_detection.backends.dummy.DummyBackend | | LOGOUT_REDIRECT_URL | Logout redirect url | |
| MALWARE_DETECTION_PARAMETERS | A dict containing all the parameters to initiate the malware detection backend | {"callback_path": "core.malware_detection.malware_detection_callback",} | | MALWARE_DETECTION_BACKEND | The malware detection backend use from the django-lasuite package | lasuite.malware_detection.backends.dummy.DummyBackend |
| MEDIA_BASE_URL | | | | MALWARE_DETECTION_PARAMETERS | A dict containing all the parameters to initiate the malware detection backend | {"callback_path": "core.malware_detection.malware_detection_callback",} |
| OIDC_ALLOW_DUPLICATE_EMAILS | Allow duplicate emails | false | | MEDIA_BASE_URL | | |
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | OIDC extra auth parameters | {} | | OIDC_ALLOW_DUPLICATE_EMAILS | Allow duplicate emails | false |
| OIDC_CREATE_USER | Create used on OIDC | false | | OIDC_AUTH_REQUEST_EXTRA_PARAMS | OIDC extra auth parameters | {} |
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to email for identification | true | | OIDC_CREATE_USER | Create used on OIDC | false |
| OIDC_OP_AUTHORIZATION_ENDPOINT | Authorization endpoint for OIDC | | | OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to email for identification | true |
| OIDC_OP_JWKS_ENDPOINT | JWKS endpoint for OIDC | | | OIDC_OP_AUTHORIZATION_ENDPOINT | Authorization endpoint for OIDC | |
| OIDC_OP_LOGOUT_ENDPOINT | Logout endpoint for OIDC | | | OIDC_OP_JWKS_ENDPOINT | JWKS endpoint for OIDC | |
| OIDC_OP_TOKEN_ENDPOINT | Token endpoint for OIDC | | | OIDC_OP_LOGOUT_ENDPOINT | Logout endpoint for OIDC | |
| OIDC_OP_USER_ENDPOINT | User endpoint for OIDC | | | OIDC_OP_TOKEN_ENDPOINT | Token endpoint for OIDC | |
| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed hosts for OIDC redirect url | [] | | OIDC_OP_USER_ENDPOINT | User endpoint for OIDC | |
| OIDC_REDIRECT_REQUIRE_HTTPS | Require https for OIDC redirect url | false | | OIDC_REDIRECT_ALLOWED_HOSTS | Allowed hosts for OIDC redirect url | [] |
| OIDC_RP_CLIENT_ID | Client id used for OIDC | impress | | OIDC_REDIRECT_REQUIRE_HTTPS | Require https for OIDC redirect url | false |
| OIDC_RP_CLIENT_SECRET | Client secret used for OIDC | | | OIDC_RP_CLIENT_ID | Client id used for OIDC | impress |
| OIDC_RP_SCOPES | Scopes requested for OIDC | openid email | | OIDC_RP_CLIENT_SECRET | Client secret used for OIDC | |
| OIDC_RP_SIGN_ALGO | verification algorithm used OIDC tokens | RS256 | | OIDC_RP_SCOPES | Scopes requested for OIDC | openid email |
| OIDC_STORE_ID_TOKEN | Store OIDC token | true | | OIDC_RP_SIGN_ALGO | verification algorithm used OIDC tokens | RS256 |
| OIDC_USE_NONCE | Use nonce for OIDC | true | | OIDC_STORE_ID_TOKEN | Store OIDC token | true |
| OIDC_USERINFO_FULLNAME_FIELDS | OIDC token claims to create full name | ["first_name", "last_name"] | | OIDC_USE_NONCE | Use nonce for OIDC | true |
| OIDC_USERINFO_SHORTNAME_FIELD | OIDC token claims to create shortname | first_name | | OIDC_USERINFO_FULLNAME_FIELDS | OIDC token claims to create full name | ["first_name", "last_name"] |
| POSTHOG_KEY | Posthog key for analytics | | | OIDC_USERINFO_SHORTNAME_FIELD | OIDC token claims to create shortname | first_name |
| REDIS_URL | Cache url | redis://redis:6379/1 | | POSTHOG_KEY | Posthog key for analytics | |
| SENTRY_DSN | Sentry host | | | REDIS_URL | Cache url | redis://redis:6379/1 |
| SESSION_COOKIE_AGE | duration of the cookie session | 60*60*12 | | SENTRY_DSN | Sentry host | |
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | | false | | SESSION_COOKIE_AGE | duration of the cookie session | 60*60*12 |
| STORAGES_STATICFILES_BACKEND | | whitenoise.storage.CompressedManifestStaticFilesStorage | | SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | | false |
| THEME_CUSTOMIZATION_CACHE_TIMEOUT | Cache duration for the customization settings | 86400 | | STORAGES_STATICFILES_BACKEND | | whitenoise.storage.CompressedManifestStaticFilesStorage |
| THEME_CUSTOMIZATION_FILE_PATH | Full path to the file customizing the theme. An example is provided in src/backend/impress/configuration/theme/default.json | BASE_DIR/impress/configuration/theme/default.json | | THEME_CUSTOMIZATION_CACHE_TIMEOUT | Cache duration for the customization settings | 86400 |
| TRASHBIN_CUTOFF_DAYS | Trashbin cutoff | 30 | | THEME_CUSTOMIZATION_FILE_PATH | Full path to the file customizing the theme. An example is provided in src/backend/impress/configuration/theme/default.json | BASE_DIR/impress/configuration/theme/default.json |
| USER_OIDC_ESSENTIAL_CLAIMS | Essential claims in OIDC token | [] | | TRASHBIN_CUTOFF_DAYS | Trashbin cutoff | 30 |
| Y_PROVIDER_API_BASE_URL | Y Provider url | | | USER_OIDC_ESSENTIAL_CLAIMS | Essential claims in OIDC token | [] |
| Y_PROVIDER_API_KEY | Y provider API key | | | Y_PROVIDER_API_BASE_URL | Y Provider url | |
| Y_PROVIDER_API_KEY | Y provider API key | |
## impress-frontend image ## impress-frontend image

View File

@@ -517,16 +517,17 @@ class FileUploadSerializer(serializers.Serializer):
mime = magic.Magic(mime=True) mime = magic.Magic(mime=True)
magic_mime_type = mime.from_buffer(file.read(1024)) magic_mime_type = mime.from_buffer(file.read(1024))
file.seek(0) # Reset file pointer to the beginning after reading file.seek(0) # Reset file pointer to the beginning after reading
self.context["is_unsafe"] = False
if settings.DOCUMENT_ATTACHMENT_CHECK_UNSAFE_MIME_TYPES_ENABLED:
self.context["is_unsafe"] = (
magic_mime_type in settings.DOCUMENT_UNSAFE_MIME_TYPES
)
self.context["is_unsafe"] = ( extension_mime_type, _ = mimetypes.guess_type(file.name)
magic_mime_type in settings.DOCUMENT_UNSAFE_MIME_TYPES
)
extension_mime_type, _ = mimetypes.guess_type(file.name) # Try guessing a coherent extension from the mimetype
if extension_mime_type != magic_mime_type:
# Try guessing a coherent extension from the mimetype self.context["is_unsafe"] = True
if extension_mime_type != magic_mime_type:
self.context["is_unsafe"] = True
guessed_ext = mimetypes.guess_extension(magic_mime_type) guessed_ext = mimetypes.guess_extension(magic_mime_type)
# Missing extensions or extensions longer than 5 characters (it's as long as an extension # Missing extensions or extensions longer than 5 characters (it's as long as an extension

View File

@@ -439,3 +439,56 @@ def test_api_documents_attachment_upload_unsafe():
"application/octet-stream", "application/octet-stream",
] ]
assert file_head["ContentDisposition"] == 'attachment; filename="script.exe"' assert file_head["ContentDisposition"] == 'attachment; filename="script.exe"'
def test_api_documents_attachment_upload_unsafe_mime_types_disabled(settings):
"""A file with an unsafe mime type but checking disabled should not be tagged as unsafe."""
settings.DOCUMENT_ATTACHMENT_CHECK_UNSAFE_MIME_TYPES_ENABLED = False
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "owner")])
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
file = SimpleUploadedFile(
name="script.exe", content=b"\x4d\x5a\x90\x00\x03\x00\x00\x00"
)
with mock.patch.object(malware_detection, "analyse_file") as mock_analyse_file:
response = client.post(url, {"file": file}, format="multipart")
assert response.status_code == 201
pattern = re.compile(rf"^{document.id!s}/attachments/(.*)\.exe")
url_parsed = urlparse(response.json()["file"])
assert url_parsed.path == f"/api/v1.0/documents/{document.id!s}/media-check/"
query = parse_qs(url_parsed.query)
assert query["key"][0] is not None
file_path = query["key"][0]
match = pattern.search(file_path)
file_id = match.group(1)
document.refresh_from_db()
assert document.attachments == [f"{document.id!s}/attachments/{file_id!s}.exe"]
assert "-unsafe" not in file_id
# Validate that file_id is a valid UUID
uuid.UUID(file_id)
key = file_path.replace("/media/", "")
mock_analyse_file.assert_called_once_with(key, document_id=document.id)
# Now, check the metadata of the uploaded file
file_head = default_storage.connection.meta.client.head_object(
Bucket=default_storage.bucket_name, Key=key
)
assert file_head["Metadata"] == {
"owner": str(user.id),
"status": "processing",
}
# Depending the libmagic version, the content type may change.
assert file_head["ContentType"] in [
"application/x-dosexec",
"application/octet-stream",
]
assert file_head["ContentDisposition"] == 'attachment; filename="script.exe"'

View File

@@ -212,7 +212,11 @@ class Base(Configuration):
"application/x-msdownload", "application/x-msdownload",
"application/xml", "application/xml",
] ]
DOCUMENT_ATTACHMENT_CHECK_UNSAFE_MIME_TYPES_ENABLED = values.BooleanValue(
True,
environ_name="DOCUMENT_ATTACHMENT_CHECK_UNSAFE_MIME_TYPES_ENABLED",
environ_prefix=None,
)
# Document versions # Document versions
DOCUMENT_VERSIONS_PAGE_SIZE = 50 DOCUMENT_VERSIONS_PAGE_SIZE = 50