Files
cellxgene/server/eb/app.py
Severiano Badajoz a23aaa131d regenerate hash and fix url (#1742)
The script hash had a typo in it and was incorrectly generated.  The URL in the `img-src` directive also did not need to be encased in single-quotes.

Reviewers please double-check my hash generation against the inline-script here: https://github.com/chanzuckerberg/cellxgene/blob/main/client/configuration/webpack/obsoleteHTMLTemplate.html
2020-08-13 11:17:13 -07:00

240 lines
8.6 KiB
Python

"""cellxgene AWS elastic beanstalk application"""
import sys
import os
import hashlib
import base64
from flask import json
import logging
from flask_talisman import Talisman
import boto3
if os.path.isdir("/opt/python/log"):
# This is the standard location where Amazon EC2 instances store the application logs.
logging.basicConfig(
filename="/opt/python/log/app.log",
level=logging.INFO,
format="%(asctime)s.%(msecs)03d %(levelname)s %(module)s - %(funcName)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
SERVERDIR = os.path.dirname(os.path.realpath(__file__))
sys.path.append(SERVERDIR)
try:
from server.common.app_config import AppConfig
from server.app.app import Server
from server.common.data_locator import DataLocator, discover_s3_region_name
except Exception:
logging.critical("Exception importing server modules", exc_info=True)
sys.exit(1)
def get_secret_key(region_name, secret_name):
session = boto3.session.Session()
client = session.client(service_name="secretsmanager", region_name=region_name)
try:
get_secret_value_response = client.get_secret_value(SecretId=secret_name)
if "SecretString" in get_secret_value_response:
var = get_secret_value_response["SecretString"]
secret = json.loads(var)
return secret
except Exception:
logging.critical("Caught exception during get_secret_key", exc_info=True)
sys.exit(1)
return None
def handle_config_from_secret(app_config):
"""Update configuration from the secret manager"""
secret_name = os.getenv("CXG_AWS_SECRET_NAME")
if not secret_name:
return
# need to find the secret manager region.
# 1. from CXG_AWS_SECRET_REGION_NAME
# 2. discover from dataroot location (if on s3)
# 3. discover from config file location (if on s3)
secret_region_name = os.getenv("CXG_AWS_SECRET_REGION_NAME")
if secret_region_name is None:
secret_region_name = discover_s3_region_name(app_config.multi_dataset__dataroot)
if not secret_region_name:
secret_region_name = discover_s3_region_name(config_file)
if not secret_region_name:
logging.error("Could not determine the AWS Secret Manager region")
sys.exit(1)
secrets = get_secret_key(secret_region_name, secret_name)
if not secrets:
return
keyattrs = (
("flask_secret_key", "app__flask_secret_key"),
("oauth_client_secret", "authentication__params_oauth__client_secret")
)
for key, attr in keyattrs:
curval = getattr(app_config.server_config, attr)
if curval:
continue
# replace the attr with the secret if it is not set
val = secrets.get(key)
if val:
logging.error(f"set {attr} from secret")
app_config.update_server_config(**{attr : val})
class WSGIServer(Server):
def __init__(self, app_config):
super().__init__(app_config)
@staticmethod
def _before_adding_routes(app, app_config):
script_hashes = WSGIServer.get_csp_hashes(app, app_config)
server_config = app_config.server_config
# This hash should be in sync with the script within
# `client/configuration/webpack/obsoleteHTMLTemplate.html`
obsolete_browser_script_hash = ["'sha256-wl4OlniJEAFM1/VNFG/LBDQyPRkO7P/5kfQWf3+fPWE='"]
csp = {
"default-src": ["'self'"],
"connect-src": ["'self'"],
"script-src": ["'self'", "'unsafe-eval'", "'unsafe-inline'"]
+ obsolete_browser_script_hash + script_hashes,
"style-src": ["'self'", "'unsafe-inline'"],
"img-src": ["'self'", "https://cellxgene.cziscience.com", "data:"],
"object-src": ["'none'"],
"base-uri": ["'none'"],
"frame-ancestors": ["'none'"],
}
if not app.debug:
csp["upgrade-insecure-requests"] = ""
if server_config.app__csp_directives:
for k, v in server_config.app__csp_directives.items():
if not isinstance(v, list):
v = [v]
csp[k] = csp.get(k, []) + v
Talisman(
app, force_https=server_config.app__force_https, frame_options="DENY", content_security_policy=csp,
)
@staticmethod
def load_static_csp_hashes(app):
csp_hashes = None
try:
with app.open_resource("../common/web/csp-hashes.json") as f:
csp_hashes = json.load(f)
except FileNotFoundError:
pass
if not isinstance(csp_hashes, dict):
csp_hashes = {}
script_hashes = [f"'{hash}'" for hash in csp_hashes.get("script-hashes", [])]
if len(script_hashes) == 0:
logging.error("Content security policy hashes are missing, falling back to unsafe-inline policy")
return (script_hashes)
@staticmethod
def compute_inline_csp_hashes(app, app_config):
dataset_configs = [app_config.default_dataset_config] + list(app_config.dataroot_config.values())
hashes = []
for dataset_config in dataset_configs:
inline_scripts = dataset_config.app__inline_scripts
for script in inline_scripts:
with app.open_resource(f"../common/web/templates/{script}") as f:
content = f.read()
# we use jinja2 template include, which trims final newline if present.
if content[-1] == 0x0A:
content = content[0:-1]
hash = base64.b64encode(hashlib.sha256(content).digest())
hashes.append(f"'sha256-{hash.decode('utf-8')}'")
return hashes
@staticmethod
def get_csp_hashes(app, app_config):
script_hashes = WSGIServer.load_static_csp_hashes(app)
script_hashes += WSGIServer.compute_inline_csp_hashes(app, app_config)
return script_hashes
try:
app_config = AppConfig()
has_config = False
# config file: look first for "config.yaml" in the current working directory
config_file = "config.yaml"
config_location = DataLocator(config_file)
if config_location.exists():
with config_location.local_handle() as lh:
logging.info(f"Configuration from {config_file}")
app_config.update_from_config_file(lh)
has_config = True
else:
# config file: second, use the CXG_CONFIG_FILE
config_file = os.getenv("CXG_CONFIG_FILE")
if config_file:
region_name = discover_s3_region_name(config_file)
config_location = DataLocator(config_file, region_name)
if config_location.exists():
with config_location.local_handle() as lh:
logging.info(f"Configuration from {config_file}")
app_config.update_from_config_file(lh)
has_config = True
else:
logging.critical(f"Configuration file not found {config_file}")
sys.exit(1)
if not has_config:
logging.critical("No config file found")
sys.exit(1)
dataroot = os.getenv("CXG_DATAROOT")
if dataroot:
logging.info("Configuration from CXG_DATAROOT")
app_config.update_server_config(multi_dataset__dataroot=dataroot)
# update from secret manager
handle_config_from_secret(app_config)
# features are unsupported in the current hosted server
app_config.update_default_dataset_config(
user_annotations__enable=False, embeddings__enable_reembedding=False,
)
app_config.update_server_config(multi_dataset__allowed_matrix_types=["cxg"],)
app_config.complete_config(logging.info)
if not app_config.server_config.app__flask_secret_key:
logging.critical(
"flask_secret_key is not provided. Either set in config file, CXG_SECRET_KEY environment variable, "
"or in AWS Secret Manager"
)
sys.exit(1)
server = WSGIServer(app_config)
debug = False
application = server.app
except Exception:
logging.critical("Caught exception during initialization", exc_info=True)
sys.exit(1)
if app_config.is_multi_dataset():
logging.info(f"starting server with multi_dataset__dataroot={app_config.server_config.multi_dataset__dataroot}")
else:
logging.info(f"starting server with single_dataset__datapath={app_config.server_config.single_dataset__datapath}")
if __name__ == "__main__":
try:
application.run(debug=debug, threaded=not debug, use_debugger=False)
except Exception:
logging.critical("Caught exception during initialization", exc_info=True)
sys.exit(1)