Repository: freedomofpress/securedrop

Issue:
Error 500 on source interface and journalist interface after 1.7.0 upgrade
## Description

Initially reported by forum user jonas.franzen in https://forum.securedrop.org/t/issues-after-update-to-1-7-0/1391

Some due to a missing variable in config.py
```
Jan 27 12:48:07 applicationserver python[1136]: AttributeError: module ‘config’ has no attribute ‘SESSION_EXPIRATION_MINUTES’
```


## Steps to reproduce

TBD


## Comments
Forum user dachary reports that simply adding the value to config.py resolves


Discussion:
This looks to be the result of recent changes in `securedrop/sdconfig.py` , specifically in how missing configuration values in `config.py` are handled. Previously all attempts to set values to their `config.py` values were wrapped in try/except blocks, but this was removed as part of work to support type annotation - probably with the assumption that these values would always be present, which isn't the case for older instances or instances which have an older `config.py` restored from a backup.

Editing `config.py` in live instances via an application update is risky - we don't want to overwrite cryptography values generated at install time! - so instead the safest approach is to restore the old try/except logic for now.

A point release will be required to address this issue and will be forthcoming ASAP.

Resolving patch:
diff --git a/securedrop/sdconfig.py b/securedrop/sdconfig.py
--- a/securedrop/sdconfig.py
+++ b/securedrop/sdconfig.py
@@ -10,42 +10,107 @@
 
 class SDConfig:
     def __init__(self) -> None:
-        self.JOURNALIST_APP_FLASK_CONFIG_CLS = \
-            _config.JournalistInterfaceFlaskConfig  # type: Type
-
-        self.SOURCE_APP_FLASK_CONFIG_CLS = \
-            _config.SourceInterfaceFlaskConfig  # type: Type
-
-        self.DATABASE_ENGINE = _config.DATABASE_ENGINE  # type: str
-        self.DATABASE_FILE = _config.DATABASE_FILE  # type: str
+        try:
+            self.JOURNALIST_APP_FLASK_CONFIG_CLS = (
+                _config.JournalistInterfaceFlaskConfig
+            )  # type: Type
+        except AttributeError:
+            pass
+
+        try:
+            self.SOURCE_APP_FLASK_CONFIG_CLS = _config.SourceInterfaceFlaskConfig  # type: Type
+        except AttributeError:
+            pass
+
+        try:
+            self.DATABASE_ENGINE = _config.DATABASE_ENGINE  # type: str
+        except AttributeError:
+            pass
+
+        try:
+            self.DATABASE_FILE = _config.DATABASE_FILE  # type: str
+        except AttributeError:
+            pass
 
         self.DATABASE_USERNAME = getattr(_config, "DATABASE_USERNAME", None)  # type: Optional[str]
         self.DATABASE_PASSWORD = getattr(_config, "DATABASE_PASSWORD", None)  # type: Optional[str]
         self.DATABASE_HOST = getattr(_config, "DATABASE_HOST", None)  # type: Optional[str]
         self.DATABASE_NAME = getattr(_config, "DATABASE_NAME", None)  # type: Optional[str]
 
-        self.ADJECTIVES = _config.ADJECTIVES  # type: str
-        self.NOUNS = _config.NOUNS  # type: str
-
-        self.GPG_KEY_DIR = _config.GPG_KEY_DIR  # type: str
-
-        self.JOURNALIST_KEY = _config.JOURNALIST_KEY  # type: str
-        self.JOURNALIST_TEMPLATES_DIR = _config.JOURNALIST_TEMPLATES_DIR  # type: str
-
-        self.SCRYPT_GPG_PEPPER = _config.SCRYPT_GPG_PEPPER  # type: str
-        self.SCRYPT_ID_PEPPER = _config.SCRYPT_ID_PEPPER  # type: str
-        self.SCRYPT_PARAMS = _config.SCRYPT_PARAMS  # type: Dict[str, int]
-
-        self.SECUREDROP_DATA_ROOT = _config.SECUREDROP_DATA_ROOT  # type: str
-        self.SECUREDROP_ROOT = _config.SECUREDROP_ROOT  # type: str
-
-        self.SESSION_EXPIRATION_MINUTES = _config.SESSION_EXPIRATION_MINUTES  # type: int
-
-        self.SOURCE_TEMPLATES_DIR = _config.SOURCE_TEMPLATES_DIR  # type: str
-        self.TEMP_DIR = _config.TEMP_DIR  # type: str
-        self.STORE_DIR = _config.STORE_DIR  # type: str
-
-        self.WORKER_PIDFILE = _config.WORKER_PIDFILE  # type: str
+        try:
+            self.ADJECTIVES = _config.ADJECTIVES  # type: str
+        except AttributeError:
+            pass
+
+        try:
+            self.NOUNS = _config.NOUNS  # type: str
+        except AttributeError:
+            pass
+
+        try:
+            self.GPG_KEY_DIR = _config.GPG_KEY_DIR  # type: str
+        except AttributeError:
+            pass
+
+        try:
+            self.JOURNALIST_KEY = _config.JOURNALIST_KEY  # type: str
+        except AttributeError:
+            pass
+
+        try:
+            self.JOURNALIST_TEMPLATES_DIR = _config.JOURNALIST_TEMPLATES_DIR  # type: str
+        except AttributeError:
+            pass
+
+        try:
+            self.SCRYPT_GPG_PEPPER = _config.SCRYPT_GPG_PEPPER  # type: str
+        except AttributeError:
+            pass
+
+        try:
+            self.SCRYPT_ID_PEPPER = _config.SCRYPT_ID_PEPPER  # type: str
+        except AttributeError:
+            pass
+
+        try:
+            self.SCRYPT_PARAMS = _config.SCRYPT_PARAMS  # type: Dict[str, int]
+        except AttributeError:
+            pass
+
+        try:
+            self.SECUREDROP_DATA_ROOT = _config.SECUREDROP_DATA_ROOT  # type: str
+        except AttributeError:
+            pass
+
+        try:
+            self.SECUREDROP_ROOT = _config.SECUREDROP_ROOT  # type: str
+        except AttributeError:
+            pass
+
+        try:
+            self.SESSION_EXPIRATION_MINUTES = _config.SESSION_EXPIRATION_MINUTES  # type: int
+        except AttributeError:
+            pass
+
+        try:
+            self.SOURCE_TEMPLATES_DIR = _config.SOURCE_TEMPLATES_DIR  # type: str
+        except AttributeError:
+            pass
+
+        try:
+            self.TEMP_DIR = _config.TEMP_DIR  # type: str
+        except AttributeError:
+            pass
+
+        try:
+            self.STORE_DIR = _config.STORE_DIR  # type: str
+        except AttributeError:
+            pass
+
+        try:
+            self.WORKER_PIDFILE = _config.WORKER_PIDFILE  # type: str
+        except AttributeError:
+            pass
 
         self.env = getattr(_config, 'env', 'prod')  # type: str
         if self.env == 'test':
@@ -54,7 +119,7 @@ def __init__(self) -> None:
             self.RQ_WORKER_NAME = 'default'
 
         # Config entries used by i18n.py
-        # Use en_US as the default local if the key is not defined in _config
+        # Use en_US as the default locale if the key is not defined in _config
         self.DEFAULT_LOCALE = getattr(
             _config, "DEFAULT_LOCALE", "en_US"
         )  # type: str
@@ -68,7 +133,10 @@ def __init__(self) -> None:
         if translation_dirs_in_conf:
             self.TRANSLATION_DIRS = Path(translation_dirs_in_conf)  # type: Path
         else:
-            self.TRANSLATION_DIRS = Path(_config.SECUREDROP_ROOT) / "translations"
+            try:
+                self.TRANSLATION_DIRS = Path(_config.SECUREDROP_ROOT) / "translations"
+            except AttributeError:
+                pass
 
     @property
     def DATABASE_URI(self) -> str:


Test patch:
diff --git a/securedrop/tests/test_config.py b/securedrop/tests/test_config.py
new file mode 100644
--- /dev/null
+++ b/securedrop/tests/test_config.py
@@ -0,0 +1,47 @@
+import importlib
+
+import config as _config
+
+
+def test_missing_config_attribute_is_handled():
+    """
+    Test handling of incomplete configurations.
+
+    Long-running SecureDrop instances might not have ever updated
+    config.py, so could be missing newer settings. This tests that
+    sdconfig.SDConfig can be initialized without error with such a
+    configuration.
+    """
+    attributes_to_test = (
+        "JournalistInterfaceFlaskConfig",
+        "SourceInterfaceFlaskConfig",
+        "DATABASE_ENGINE",
+        "DATABASE_FILE",
+        "ADJECTIVES",
+        "NOUNS",
+        "GPG_KEY_DIR",
+        "JOURNALIST_KEY",
+        "JOURNALIST_TEMPLATES_DIR",
+        "SCRYPT_GPG_PEPPER",
+        "SCRYPT_ID_PEPPER",
+        "SCRYPT_PARAMS",
+        "SECUREDROP_DATA_ROOT",
+        "SECUREDROP_ROOT",
+        "SESSION_EXPIRATION_MINUTES",
+        "SOURCE_TEMPLATES_DIR",
+        "TEMP_DIR",
+        "STORE_DIR",
+        "WORKER_PIDFILE",
+    )
+
+    try:
+        importlib.reload(_config)
+
+        for a in attributes_to_test:
+            delattr(_config, a)
+
+        from sdconfig import SDConfig
+
+        SDConfig()
+    finally:
+        importlib.reload(_config)