diff --git a/auth/auth/auth.py b/auth/auth/auth.py index 7d22eeece38..c21ae32a144 100644 --- a/auth/auth/auth.py +++ b/auth/auth/auth.py @@ -38,7 +38,14 @@ from hailtop.config import get_deploy_config from hailtop.hail_logging import AccessLogger from hailtop.utils import secret_alnum_string -from web_common import render_template, set_message, setup_aiohttp_jinja2, setup_common_static_routes +from web_common import ( + api_security_headers, + render_template, + set_message, + setup_aiohttp_jinja2, + setup_common_static_routes, + web_security_headers, +) from .exceptions import ( AuthUserError, @@ -207,6 +214,7 @@ def validate_next_page_url(next_page): @routes.get('/healthcheck') +@api_security_headers async def get_healthcheck(_) -> web.Response: return web.Response() @@ -214,12 +222,14 @@ async def get_healthcheck(_) -> web.Response: @routes.get('') @routes.get('/') @auth.maybe_authenticated_user +@web_security_headers async def get_index(request: web.Request, userdata: Optional[UserData]) -> web.Response: return await render_template('auth', request, userdata, 'index.html', {}) @routes.get('/creating') @auth.maybe_authenticated_user +@web_security_headers async def creating_account(request: web.Request, userdata: Optional[UserData]) -> web.Response: db = request.app[AppKeys.DB] session = await aiohttp_session.get_session(request) @@ -258,6 +268,7 @@ async def creating_account(request: web.Request, userdata: Optional[UserData]) - @routes.get('/creating/wait') +@web_security_headers async def creating_account_wait(request): session = await aiohttp_session.get_session(request) if 'pending' not in session: @@ -298,6 +309,7 @@ async def _wait_websocket(request, login_id): @routes.get('/signup') +@web_security_headers async def signup(request) -> NoReturn: next_page = request.query.get('next', deploy_config.external_url('auth', '/user')) validate_next_page_url(next_page) @@ -314,6 +326,7 @@ async def signup(request) -> NoReturn: @routes.get('/login') +@web_security_headers async def login(request) -> NoReturn: next_page = request.query.get('next', deploy_config.external_url('auth', '/user')) validate_next_page_url(next_page) @@ -330,6 +343,7 @@ async def login(request) -> NoReturn: @routes.get('/oauth2callback') +@web_security_headers async def callback(request) -> web.Response: session = await aiohttp_session.get_session(request) if 'flow' not in session: @@ -406,6 +420,7 @@ async def callback(request) -> web.Response: @routes.post('/api/v1alpha/users/{user}/create') +@api_security_headers @auth.authenticated_developers_only() async def create_user(request: web.Request, _) -> web.Response: db = request.app[AppKeys.DB] @@ -438,6 +453,7 @@ async def create_user(request: web.Request, _) -> web.Response: @routes.get('/user') +@web_security_headers @auth.authenticated_users_only() async def user_page(request: web.Request, userdata: UserData) -> web.Response: return await render_template('auth', request, userdata, 'user.html', {'cloud': CLOUD}) @@ -453,6 +469,7 @@ async def create_copy_paste_token(db, session_id, max_age_secs=300): @routes.post('/copy-paste-token') +@web_security_headers @auth.authenticated_users_only() async def get_copy_paste_token(request: web.Request, userdata: UserData) -> web.Response: session = await aiohttp_session.get_session(request) @@ -464,6 +481,7 @@ async def get_copy_paste_token(request: web.Request, userdata: UserData) -> web. @routes.post('/api/v1alpha/copy-paste-token') +@api_security_headers @auth.authenticated_users_only() async def get_copy_paste_token_api(request: web.Request, _) -> web.Response: session_id = await get_session_id(request) @@ -473,6 +491,7 @@ async def get_copy_paste_token_api(request: web.Request, _) -> web.Response: @routes.post('/logout') +@web_security_headers @auth.maybe_authenticated_user async def logout(request: web.Request, userdata: Optional[UserData]) -> NoReturn: if not userdata: @@ -489,6 +508,7 @@ async def logout(request: web.Request, userdata: Optional[UserData]) -> NoReturn @routes.get('/api/v1alpha/login') +@api_security_headers async def rest_login(request: web.Request) -> web.Response: callback_port = request.query['callback_port'] callback_uri = f'http://127.0.0.1:{callback_port}/oauth2callback' @@ -504,12 +524,14 @@ async def rest_login(request: web.Request) -> web.Response: @routes.get('/api/v1alpha/oauth2-client') +@api_security_headers async def hailctl_oauth_client(request): # pylint: disable=unused-argument idp = IdentityProvider.GOOGLE if CLOUD == 'gcp' else IdentityProvider.MICROSOFT return json_response({'idp': idp.value, 'oauth2_client': request.app[AppKeys.HAILCTL_CLIENT_CONFIG]}) @routes.get('/roles') +@web_security_headers @auth.authenticated_developers_only() async def get_roles(request: web.Request, userdata: UserData) -> web.Response: db = request.app[AppKeys.DB] @@ -519,6 +541,7 @@ async def get_roles(request: web.Request, userdata: UserData) -> web.Response: @routes.post('/roles') +@web_security_headers @auth.authenticated_developers_only() async def post_create_role(request: web.Request, _) -> NoReturn: session = await aiohttp_session.get_session(request) @@ -540,6 +563,7 @@ async def post_create_role(request: web.Request, _) -> NoReturn: @routes.get('/users') +@web_security_headers @auth.authenticated_developers_only() async def get_users(request: web.Request, userdata: UserData) -> web.Response: db = request.app[AppKeys.DB] @@ -549,6 +573,7 @@ async def get_users(request: web.Request, userdata: UserData) -> web.Response: @routes.post('/users') +@web_security_headers @auth.authenticated_developers_only() async def post_create_user(request: web.Request, _) -> NoReturn: session = await aiohttp_session.get_session(request) @@ -574,6 +599,7 @@ async def post_create_user(request: web.Request, _) -> NoReturn: @routes.get('/api/v1alpha/users') +@api_security_headers @auth.authenticated_users_only() async def rest_get_users(request: web.Request, userdata: UserData) -> web.Response: if userdata['is_developer'] != 1 and userdata['username'] != 'ci': @@ -589,6 +615,7 @@ async def rest_get_users(request: web.Request, userdata: UserData) -> web.Respon @routes.get('/api/v1alpha/users/{user}') +@api_security_headers @auth.authenticated_developers_only() async def rest_get_user(request: web.Request, _) -> web.Response: db = request.app[AppKeys.DB] @@ -628,6 +655,7 @@ async def _delete_user(db: Database, username: str, id: Optional[str]): @routes.post('/users/delete') +@web_security_headers @auth.authenticated_developers_only() async def delete_user(request: web.Request, _) -> NoReturn: session = await aiohttp_session.get_session(request) @@ -646,6 +674,7 @@ async def delete_user(request: web.Request, _) -> NoReturn: @routes.delete('/api/v1alpha/users/{user}') +@api_security_headers @auth.authenticated_developers_only() async def rest_delete_user(request: web.Request, _) -> web.Response: db = request.app[AppKeys.DB] @@ -660,6 +689,7 @@ async def rest_delete_user(request: web.Request, _) -> web.Response: @routes.get('/api/v1alpha/oauth2callback') +@api_security_headers async def rest_callback(request): flow_json = request.query.get('flow') if flow_json is None: @@ -698,6 +728,7 @@ async def rest_callback(request): @routes.post('/api/v1alpha/copy-paste-login') +@api_security_headers async def rest_copy_paste_login(request): copy_paste_token = request.query['copy_paste_token'] db = request.app[AppKeys.DB] @@ -724,6 +755,7 @@ async def maybe_pop_token(tx): @routes.post('/api/v1alpha/logout') +@api_security_headers @auth.authenticated_users_only() async def rest_logout(request: web.Request, _) -> web.Response: session_id = await get_session_id(request) @@ -800,12 +832,14 @@ async def get_userinfo_from_hail_session_id(request: web.Request, session_id: st @routes.get('/api/v1alpha/userinfo') +@api_security_headers @auth.authenticated_users_only() async def userinfo(_, userdata: UserData) -> web.Response: return json_response(userdata) @routes.route('*', '/api/v1alpha/verify_dev_credentials', name='verify_dev') +@api_security_headers @auth.authenticated_users_only() async def verify_dev_credentials(_, userdata: UserData) -> web.Response: if userdata['is_developer'] != 1: @@ -814,6 +848,7 @@ async def verify_dev_credentials(_, userdata: UserData) -> web.Response: @routes.route('*', '/api/v1alpha/verify_dev_or_sa_credentials', name='verify_dev_or_sa') +@api_security_headers @auth.authenticated_users_only() async def verify_dev_or_sa_credentials(_, userdata: UserData) -> web.Response: if userdata['is_developer'] != 1 and userdata['is_service_account'] != 1: diff --git a/batch/batch/front_end/front_end.py b/batch/batch/front_end/front_end.py index ead984f3f82..1d0d89f63df 100644 --- a/batch/batch/front_end/front_end.py +++ b/batch/batch/front_end/front_end.py @@ -70,7 +70,14 @@ time_msecs, time_msecs_str, ) -from web_common import render_template, set_message, setup_aiohttp_jinja2, setup_common_static_routes +from web_common import ( + api_security_headers, + render_template, + set_message, + setup_aiohttp_jinja2, + setup_common_static_routes, + web_security_headers, +) from ..batch import batch_record_to_dict, cancel_job_group_in_db, job_group_record_to_dict, job_record_to_dict from ..batch_configuration import BATCH_STORAGE_URI, CLOUD, DEFAULT_NAMESPACE, SCOPE @@ -233,22 +240,28 @@ async def wrapped(request, *args, **kwargs): @routes.get('/healthcheck') +@api_security_headers async def get_healthcheck(_) -> web.Response: - return web.Response() + r = web.Response() + r.headers['Strict-Transport-Security'] = 'max-age=63072000; includeSubDomains;' + return r @routes.get('/api/v1alpha/version') +@api_security_headers async def rest_get_version(_) -> web.Response: return web.Response(text=version()) @routes.get('/api/v1alpha/cloud') +@api_security_headers async def rest_cloud(_) -> web.Response: return web.Response(text=CLOUD) @routes.get('/api/v1alpha/supported_regions') @auth.authenticated_users_only() +@api_security_headers async def rest_get_supported_regions(request: web.Request, _) -> web.Response: return json_response(list(request.app['regions'].keys())) @@ -349,6 +362,7 @@ async def _get_job_group_jobs( @routes.get('/api/v1alpha/batches/{batch_id}/jobs') @billing_project_users_only() @add_metadata_to_request +@api_security_headers async def get_batch_jobs_v1(request: web.Request, _, batch_id: int) -> web.Response: return await _api_get_job_group_jobs(request, batch_id, ROOT_JOB_GROUP_ID, 1) @@ -356,6 +370,7 @@ async def get_batch_jobs_v1(request: web.Request, _, batch_id: int) -> web.Respo @routes.get('/api/v2alpha/batches/{batch_id}/jobs') @billing_project_users_only() @add_metadata_to_request +@api_security_headers async def get_batch_jobs_v2(request: web.Request, _, batch_id: int) -> web.Response: return await _api_get_job_group_jobs(request, batch_id, ROOT_JOB_GROUP_ID, 2) @@ -363,6 +378,7 @@ async def get_batch_jobs_v2(request: web.Request, _, batch_id: int) -> web.Respo @routes.get('/api/v1alpha/batches/{batch_id}/job-groups/{job_group_id}/jobs') @billing_project_users_only() @add_metadata_to_request +@api_security_headers async def get_job_group_jobs_v1(request: web.Request, _, batch_id: int) -> web.Response: job_group_id = int(request.match_info['job_group_id']) return await _api_get_job_group_jobs(request, batch_id, job_group_id, 1) @@ -371,6 +387,7 @@ async def get_job_group_jobs_v1(request: web.Request, _, batch_id: int) -> web.R @routes.get('/api/v2alpha/batches/{batch_id}/job-groups/{job_group_id}/jobs') @billing_project_users_only() @add_metadata_to_request +@api_security_headers async def get_job_group_jobs_v2(request: web.Request, _, batch_id: int) -> web.Response: job_group_id = int(request.match_info['job_group_id']) return await _api_get_job_group_jobs(request, batch_id, job_group_id, 2) @@ -667,6 +684,7 @@ async def _get_full_job_status(app, record): @routes.get('/api/v1alpha/batches/{batch_id}/jobs/{job_id}/log') @billing_project_users_only() @add_metadata_to_request +@api_security_headers @deprecated async def get_job_log(request: web.Request, _, batch_id: int) -> web.Response: job_id = int(request.match_info['job_id']) @@ -697,6 +715,7 @@ async def get_job_container_log(request, batch_id): @routes.get('/api/v1alpha/batches/{batch_id}/jobs/{job_id}/log/{container}') @billing_project_users_only() @add_metadata_to_request +@api_security_headers async def rest_get_job_container_log(request, _, batch_id) -> web.Response: return await get_job_container_log(request, batch_id) @@ -723,6 +742,7 @@ async def _query_batches(request, user: str, q: str, version: int, last_batch_id @routes.get('/api/v1alpha/batches') @auth.authenticated_users_only() @add_metadata_to_request +@api_security_headers async def get_batches_v1(request, userdata): # pylint: disable=unused-argument user = userdata['username'] q = request.query.get('q', f'user:{user}') @@ -739,6 +759,7 @@ async def get_batches_v1(request, userdata): # pylint: disable=unused-argument @routes.get('/api/v2alpha/batches') @auth.authenticated_users_only() @add_metadata_to_request +@api_security_headers async def get_batches_v2(request, userdata): # pylint: disable=unused-argument user = userdata['username'] q = request.query.get('q', f'user = {user}') @@ -801,6 +822,7 @@ async def _api_get_job_groups_v1(request: web.Request, batch_id: int, job_group_ @routes.get('/api/v1alpha/batches/{batch_id}/job-groups') @billing_project_users_only() @add_metadata_to_request +@api_security_headers async def get_root_job_groups_v1(request: web.Request, _, batch_id: int): # pylint: disable=unused-argument return await _api_get_job_groups_v1(request, batch_id, ROOT_JOB_GROUP_ID) @@ -808,6 +830,7 @@ async def get_root_job_groups_v1(request: web.Request, _, batch_id: int): # pyl @routes.get('/api/v1alpha/batches/{batch_id}/job-groups/{job_group_id}/job-groups') @billing_project_users_only() @add_metadata_to_request +@api_security_headers async def get_job_groups_v1(request: web.Request, _, batch_id: int): # pylint: disable=unused-argument job_group_id = int(request.match_info['job_group_id']) return await _api_get_job_groups_v1(request, batch_id, job_group_id) @@ -816,6 +839,7 @@ async def get_job_groups_v1(request: web.Request, _, batch_id: int): # pylint: @routes.post('/api/v1alpha/batches/{batch_id}/updates/{update_id}/job-groups/create') @auth.authenticated_users_only() @add_metadata_to_request +@api_security_headers async def create_job_groups(request: web.Request, userdata: UserData) -> web.Response: app = request.app db: Database = app['db'] @@ -853,6 +877,7 @@ def check_service_account_permissions(user, sa): @auth.authenticated_users_only() @add_metadata_to_request @deprecated # Use create_jobs_for_update instead +@api_security_headers async def create_jobs(request: web.Request, userdata: UserData) -> web.Response: app = request.app batch_id = int(request.match_info['batch_id']) @@ -868,6 +893,7 @@ async def create_jobs(request: web.Request, userdata: UserData) -> web.Response: @routes.post('/api/v1alpha/batches/{batch_id}/updates/{update_id}/jobs/create') @auth.authenticated_users_only() @add_metadata_to_request +@api_security_headers async def create_jobs_for_update(request: web.Request, userdata: UserData) -> web.Response: app = request.app @@ -1549,6 +1575,7 @@ async def write_and_insert(tx): @routes.post('/api/v1alpha/batches/create-fast') @auth.authenticated_users_only() @add_metadata_to_request +@api_security_headers async def create_batch_fast(request, userdata): app = request.app db: Database = app['db'] @@ -1603,6 +1630,7 @@ async def create_batch_fast(request, userdata): @routes.post('/api/v1alpha/batches/create') @auth.authenticated_users_only() @add_metadata_to_request +@api_security_headers async def create_batch(request, userdata): app = request.app db: Database = app['db'] @@ -1744,6 +1772,7 @@ async def insert(tx): @routes.post('/api/v1alpha/batches/{batch_id}/update-fast') @auth.authenticated_users_only() @add_metadata_to_request +@api_security_headers async def update_batch_fast(request, userdata): app = request.app db: Database = app['db'] @@ -1804,6 +1833,7 @@ async def update_batch_fast(request, userdata): @routes.post('/api/v1alpha/batches/{batch_id}/updates/create') @auth.authenticated_users_only() @add_metadata_to_request +@api_security_headers async def create_update(request, userdata): app = request.app db: Database = app['db'] @@ -2043,6 +2073,7 @@ async def _delete_batch(app, batch_id): @routes.get('/api/v1alpha/batches/{batch_id}') @billing_project_users_only() @add_metadata_to_request +@api_security_headers async def get_batch(request: web.Request, _, batch_id: int) -> web.Response: return json_response(await _get_batch(request.app, batch_id)) @@ -2050,6 +2081,7 @@ async def get_batch(request: web.Request, _, batch_id: int) -> web.Response: @routes.patch('/api/v1alpha/batches/{batch_id}/cancel') @billing_project_users_only() @add_metadata_to_request +@api_security_headers async def cancel_batch(request: web.Request, _, batch_id: int) -> web.Response: await _handle_api_error(_cancel_job_group, request.app, batch_id, ROOT_JOB_GROUP_ID) return web.Response() @@ -2058,6 +2090,7 @@ async def cancel_batch(request: web.Request, _, batch_id: int) -> web.Response: @routes.get('/api/v1alpha/batches/{batch_id}/job-groups/{job_group_id}') @billing_project_users_only() @add_metadata_to_request +@api_security_headers async def get_job_group(request: web.Request, _, batch_id: int) -> web.Response: job_group_id = int(request.match_info['job_group_id']) return json_response(await _get_job_group(request.app, batch_id, job_group_id)) @@ -2066,6 +2099,7 @@ async def get_job_group(request: web.Request, _, batch_id: int) -> web.Response: @routes.patch('/api/v1alpha/batches/{batch_id}/job-groups/{job_group_id}/cancel') @billing_project_users_only() @add_metadata_to_request +@api_security_headers async def cancel_job_group(request: web.Request, _, batch_id: int) -> web.Response: job_group_id = int(request.match_info['job_group_id']) await _handle_api_error(_cancel_job_group, request.app, batch_id, job_group_id) @@ -2076,6 +2110,7 @@ async def cancel_job_group(request: web.Request, _, batch_id: int) -> web.Respon @auth.authenticated_users_only() @add_metadata_to_request @deprecated +@api_security_headers async def close_batch(request, userdata): batch_id = int(request.match_info['batch_id']) user = userdata['username'] @@ -2120,6 +2155,7 @@ async def close_batch(request, userdata): @routes.patch('/api/v1alpha/batches/{batch_id}/updates/{update_id}/commit') @auth.authenticated_users_only() @add_metadata_to_request +@api_security_headers async def commit_update(request: web.Request, userdata): app = request.app db: Database = app['db'] @@ -2179,6 +2215,7 @@ async def _commit_update(app: web.Application, batch_id: int, update_id: int, us @routes.delete('/api/v1alpha/batches/{batch_id}') @billing_project_users_only() @add_metadata_to_request +@api_security_headers async def delete_batch(request: web.Request, _, batch_id: int) -> web.Response: await _delete_batch(request.app, batch_id) return web.Response() @@ -2187,6 +2224,7 @@ async def delete_batch(request: web.Request, _, batch_id: int) -> web.Response: @routes.get('/batches/{batch_id}') @billing_project_users_only() @catch_ui_error_in_dev +@web_security_headers async def ui_batch(request, userdata, batch_id): app = request.app batch = await _get_batch(app, batch_id) @@ -2232,6 +2270,7 @@ async def ui_batch(request, userdata, batch_id): @routes.post('/batches/{batch_id}/cancel') @billing_project_users_only(redirect=False) @catch_ui_error_in_dev +@web_security_headers async def ui_cancel_batch(request: web.Request, _, batch_id: int) -> NoReturn: post = await request.post() q = post.get('q') @@ -2250,6 +2289,7 @@ async def ui_cancel_batch(request: web.Request, _, batch_id: int) -> NoReturn: @routes.post('/batches/{batch_id}/delete') @billing_project_users_only(redirect=False) @catch_ui_error_in_dev +@web_security_headers async def ui_delete_batch(request: web.Request, _, batch_id: int) -> NoReturn: post = await request.post() q = post.get('q') @@ -2266,6 +2306,7 @@ async def ui_delete_batch(request: web.Request, _, batch_id: int) -> NoReturn: @routes.get('/batches', name='batches') @auth.authenticated_users_only() @catch_ui_error_in_dev +@web_security_headers async def ui_batches(request: web.Request, userdata: UserData) -> web.Response: session = await aiohttp_session.get_session(request) user = userdata['username'] @@ -2394,6 +2435,7 @@ async def _get_attempts(app, batch_id, job_id): @routes.get('/api/v1alpha/batches/{batch_id}/jobs/{job_id}/attempts') @billing_project_users_only() +@api_security_headers async def get_attempts(request: web.Request, _, batch_id: int) -> web.Response: job_id = int(request.match_info['job_id']) attempts = await _get_attempts(request.app, batch_id, job_id) @@ -2403,6 +2445,7 @@ async def get_attempts(request: web.Request, _, batch_id: int) -> web.Response: @routes.get('/api/v1alpha/batches/{batch_id}/jobs/{job_id}') @billing_project_users_only() @add_metadata_to_request +@api_security_headers async def get_job(request: web.Request, _, batch_id: int) -> web.Response: job_id = int(request.match_info['job_id']) status = await _get_job(request.app, batch_id, job_id) @@ -2411,6 +2454,7 @@ async def get_job(request: web.Request, _, batch_id: int) -> web.Response: @routes.get('/api/v1alpha/batches/{batch_id}/jobs/{job_id}/resource_usage') @billing_project_users_only() +@api_security_headers async def get_job_resource_usage(request: web.Request, _, batch_id: int) -> web.Response: """ Get the resource_usage data for a job. The data is returned as a JSON object @@ -2650,6 +2694,7 @@ def add_trace(time, measurement, row, col, container_name, show_legend): @routes.get('/batches/{batch_id}/jobs/{job_id}/jvm_profile') @billing_project_users_only() @catch_ui_error_in_dev +@web_security_headers async def ui_get_jvm_profile(request: web.Request, _, batch_id: int) -> web.Response: app = request.app job_id = int(request.match_info['job_id']) @@ -2662,6 +2707,7 @@ async def ui_get_jvm_profile(request: web.Request, _, batch_id: int) -> web.Resp @routes.get('/batches/{batch_id}/jobs/{job_id}') @billing_project_users_only() @catch_ui_error_in_dev +@web_security_headers async def ui_get_job(request, userdata, batch_id): app = request.app job_id = int(request.match_info['job_id']) @@ -2783,6 +2829,7 @@ async def ui_get_job(request, userdata, batch_id): @routes.get('/batches/{batch_id}/jobs/{job_id}/log/{container}') @billing_project_users_only() @catch_ui_error_in_dev +@web_security_headers async def ui_get_job_log(request: web.Request, _, batch_id: int) -> web.StreamResponse: return await get_job_container_log(request, batch_id) @@ -2790,6 +2837,7 @@ async def ui_get_job_log(request: web.Request, _, batch_id: int) -> web.StreamRe @routes.get('/billing_limits') @auth.authenticated_users_only() @catch_ui_error_in_dev +@web_security_headers async def ui_get_billing_limits(request, userdata): app = request.app db: Database = app['db'] @@ -2858,6 +2906,7 @@ async def insert(tx): @routes.post('/api/v1alpha/billing_limits/{billing_project}/edit') @authenticated_developers_or_auth_only +@api_security_headers async def post_edit_billing_limits(request: web.Request) -> web.Response: db: Database = request.app['db'] billing_project = request.match_info['billing_project'] @@ -2870,6 +2919,7 @@ async def post_edit_billing_limits(request: web.Request) -> web.Response: @routes.post('/billing_limits/{billing_project}/edit') @auth.authenticated_developers_only(redirect=False) @catch_ui_error_in_dev +@web_security_headers async def post_edit_billing_limits_ui(request: web.Request, _) -> NoReturn: db: Database = request.app['db'] billing_project = request.match_info['billing_project'] @@ -2956,6 +3006,7 @@ async def parse_error(msg: str) -> Tuple[list, str, None]: @routes.get('/billing') @auth.authenticated_users_only() @catch_ui_error_in_dev +@web_security_headers async def ui_get_billing(request, userdata): is_developer = userdata['is_developer'] == 1 user = userdata['username'] if not is_developer else None @@ -3002,6 +3053,7 @@ async def ui_get_billing(request, userdata): @routes.get('/billing_projects') @auth.authenticated_developers_only() +@web_security_headers @catch_ui_error_in_dev async def ui_get_billing_projects(request, userdata): db: Database = request.app['db'] @@ -3015,6 +3067,7 @@ async def ui_get_billing_projects(request, userdata): @routes.get('/api/v1alpha/billing_projects') @auth.authenticated_users_only() +@api_security_headers async def get_billing_projects(request, userdata): db: Database = request.app['db'] @@ -3029,6 +3082,7 @@ async def get_billing_projects(request, userdata): @routes.get('/api/v1alpha/billing_projects/{billing_project}') @auth.authenticated_users_only() +@api_security_headers async def get_billing_project(request, userdata): db: Database = request.app['db'] billing_project = request.match_info['billing_project'] @@ -3095,6 +3149,7 @@ async def delete(tx): @routes.post('/billing_projects/{billing_project}/users/{user}/remove') @auth.authenticated_developers_only(redirect=False) @catch_ui_error_in_dev +@web_security_headers async def post_billing_projects_remove_user(request: web.Request, _) -> NoReturn: db: Database = request.app['db'] billing_project = request.match_info['billing_project'] @@ -3110,6 +3165,7 @@ async def post_billing_projects_remove_user(request: web.Request, _) -> NoReturn @routes.post('/api/v1alpha/billing_projects/{billing_project}/users/{user}/remove') @authenticated_developers_or_auth_only +@api_security_headers async def api_get_billing_projects_remove_user(request: web.Request) -> web.Response: db: Database = request.app['db'] billing_project = request.match_info['billing_project'] @@ -3175,6 +3231,7 @@ async def insert(tx): @routes.post('/billing_projects/{billing_project}/users/add') @auth.authenticated_developers_only(redirect=False) @catch_ui_error_in_dev +@web_security_headers async def post_billing_projects_add_user(request: web.Request, _) -> NoReturn: db: Database = request.app['db'] post = await request.post() @@ -3192,6 +3249,7 @@ async def post_billing_projects_add_user(request: web.Request, _) -> NoReturn: @routes.post('/api/v1alpha/billing_projects/{billing_project}/users/{user}/add') @authenticated_developers_or_auth_only +@api_security_headers async def api_billing_projects_add_user(request: web.Request) -> web.Response: db: Database = request.app['db'] user = request.match_info['user'] @@ -3232,6 +3290,7 @@ async def insert(tx): @routes.post('/billing_projects/create') @auth.authenticated_developers_only(redirect=False) @catch_ui_error_in_dev +@web_security_headers async def post_create_billing_projects(request: web.Request, _) -> NoReturn: db: Database = request.app['db'] post = await request.post() @@ -3247,6 +3306,7 @@ async def post_create_billing_projects(request: web.Request, _) -> NoReturn: @routes.post('/api/v1alpha/billing_projects/{billing_project}/create') @authenticated_developers_or_auth_only +@api_security_headers async def api_get_create_billing_projects(request: web.Request) -> web.Response: db: Database = request.app['db'] billing_project = request.match_info['billing_project'] @@ -3292,6 +3352,7 @@ async def close_project(tx): @routes.post('/billing_projects/{billing_project}/close') @auth.authenticated_developers_only(redirect=False) @catch_ui_error_in_dev +@web_security_headers async def post_close_billing_projects(request: web.Request, _) -> NoReturn: db: Database = request.app['db'] billing_project = request.match_info['billing_project'] @@ -3306,6 +3367,7 @@ async def post_close_billing_projects(request: web.Request, _) -> NoReturn: @routes.post('/api/v1alpha/billing_projects/{billing_project}/close') @authenticated_developers_or_auth_only +@api_security_headers async def api_close_billing_projects(request: web.Request) -> web.Response: db: Database = request.app['db'] billing_project = request.match_info['billing_project'] @@ -3336,6 +3398,7 @@ async def open_project(tx): @routes.post('/billing_projects/{billing_project}/reopen') @auth.authenticated_developers_only(redirect=False) @catch_ui_error_in_dev +@web_security_headers async def post_reopen_billing_projects(request: web.Request, _) -> NoReturn: db: Database = request.app['db'] billing_project = request.match_info['billing_project'] @@ -3350,6 +3413,7 @@ async def post_reopen_billing_projects(request: web.Request, _) -> NoReturn: @routes.post('/api/v1alpha/billing_projects/{billing_project}/reopen') @authenticated_developers_or_auth_only +@api_security_headers async def api_reopen_billing_projects(request: web.Request) -> web.Response: db: Database = request.app['db'] billing_project = request.match_info['billing_project'] @@ -3380,6 +3444,7 @@ async def delete_project(tx): @routes.post('/api/v1alpha/billing_projects/{billing_project}/delete') @authenticated_developers_or_auth_only +@api_security_headers async def api_delete_billing_projects(request: web.Request) -> web.Response: db: Database = request.app['db'] billing_project = request.match_info['billing_project'] @@ -3408,6 +3473,7 @@ async def _refresh(app): @routes.get('/') @auth.authenticated_users_only() @catch_ui_error_in_dev +@web_security_headers async def index(request: web.Request, _) -> NoReturn: location = request.app.router['batches'].url_for() raise web.HTTPFound(location=location) diff --git a/ci/ci/ci.py b/ci/ci/ci.py index bcc9c205581..216ac39ae08 100644 --- a/ci/ci/ci.py +++ b/ci/ci/ci.py @@ -37,7 +37,14 @@ from hailtop.config import get_deploy_config from hailtop.hail_logging import AccessLogger from hailtop.utils import collect_aiter, humanize_timedelta_msecs, periodically_call, retry_transient_errors -from web_common import render_template, set_message, setup_aiohttp_jinja2, setup_common_static_routes +from web_common import ( + api_security_headers, + render_template, + set_message, + setup_aiohttp_jinja2, + setup_common_static_routes, + web_security_headers, +) from .constants import AUTHORIZED_USERS, TEAMS from .environment import CLOUD, DEFAULT_NAMESPACE, DOMAIN, STORAGE_URI @@ -131,6 +138,7 @@ async def watched_branch_config(app: web.Application, wb: WatchedBranch, index: @routes.get('') @routes.get('/') +@web_security_headers @auth.authenticated_developers_only() async def index(request: web.Request, userdata: UserData) -> web.Response: wb_configs = [await watched_branch_config(request.app, wb, i) for i, wb in enumerate(watched_branches)] @@ -166,6 +174,7 @@ def filter_jobs(jobs): @routes.get('/watched_branches/{watched_branch_index}/pr/{pr_number}') +@web_security_headers @auth.authenticated_developers_only() async def get_pr(request: web.Request, userdata: UserData) -> web.Response: wb, pr = wb_and_pr_from_request(request) @@ -244,6 +253,7 @@ async def retry_pr(wb: WatchedBranch, pr: PR, request: web.Request): @routes.post('/watched_branches/{watched_branch_index}/pr/{pr_number}/retry') +@web_security_headers @auth.authenticated_developers_only(redirect=False) async def post_retry_pr(request: web.Request, _) -> NoReturn: wb, pr = wb_and_pr_from_request(request) @@ -253,6 +263,7 @@ async def post_retry_pr(request: web.Request, _) -> NoReturn: @routes.get('/batches') +@web_security_headers @auth.authenticated_developers_only() async def get_batches(request: web.Request, userdata: UserData): batch_client = request.app[AppKeys.BATCH_CLIENT] @@ -263,6 +274,7 @@ async def get_batches(request: web.Request, userdata: UserData): @routes.get('/batches/{batch_id}') +@web_security_headers @auth.authenticated_developers_only() async def get_batch(request: web.Request, userdata: UserData): batch_id = int(request.match_info['batch_id']) @@ -313,6 +325,7 @@ def pr_requires_action(gh_username: str, pr_config: PRConfig) -> bool: @routes.get('/me') +@web_security_headers @auth.authenticated_developers_only() async def get_user(request: web.Request, userdata: UserData) -> web.Response: for authorized_user in AUTHORIZED_USERS: @@ -346,6 +359,7 @@ async def get_user(request: web.Request, userdata: UserData) -> web.Response: @routes.post('/authorize_source_sha') +@web_security_headers @auth.authenticated_developers_only(redirect=False) async def post_authorized_source_sha(request: web.Request, _) -> NoReturn: app = request.app @@ -360,6 +374,7 @@ async def post_authorized_source_sha(request: web.Request, _) -> NoReturn: @routes.get('/healthcheck') +@web_security_headers async def healthcheck(_) -> web.Response: return web.Response(status=200) @@ -414,6 +429,7 @@ async def github_callback_handler(request: web.Request): @routes.post('/github_callback') +@api_security_headers async def github_callback(request: web.Request): await asyncio.shield(github_callback_handler(request)) return web.Response(status=200) @@ -453,6 +469,7 @@ async def batch_callback_handler(request: web.Request): @routes.get('/api/v1alpha/deploy_status') +@api_security_headers @auth.authenticated_developers_only() async def deploy_status(request: web.Request, _) -> web.Response: batch_client = request.app[AppKeys.BATCH_CLIENT] @@ -488,6 +505,7 @@ async def fetch_job_and_log(j): @routes.post('/api/v1alpha/update') +@api_security_headers @auth.authenticated_developers_only() async def post_update(request: web.Request, _) -> web.Response: log.info('developer triggered update') @@ -505,6 +523,7 @@ async def update_all(): @routes.post('/api/v1alpha/dev_deploy_branch') +@api_security_headers @auth.authenticated_developers_only() async def dev_deploy_branch(request: web.Request, userdata: UserData) -> web.Response: app = request.app @@ -557,12 +576,14 @@ async def dev_deploy_branch(request: web.Request, userdata: UserData) -> web.Res @routes.post('/api/v1alpha/batch_callback') +@api_security_headers async def batch_callback(request: web.Request): await asyncio.shield(batch_callback_handler(request)) return web.Response(status=200) @routes.post('/freeze_merge_deploy') +@web_security_headers @auth.authenticated_developers_only() async def freeze_deploys(request: web.Request, _) -> NoReturn: app = request.app @@ -585,6 +606,7 @@ async def freeze_deploys(request: web.Request, _) -> NoReturn: @routes.post('/unfreeze_merge_deploy') +@web_security_headers @auth.authenticated_developers_only() async def unfreeze_deploys(request: web.Request, _) -> NoReturn: app = request.app @@ -607,6 +629,7 @@ async def unfreeze_deploys(request: web.Request, _) -> NoReturn: @routes.get('/namespaces') +@web_security_headers @auth.authenticated_developers_only() async def get_active_namespaces(request: web.Request, userdata: UserData) -> web.Response: db = request.app[AppKeys.DB] @@ -628,6 +651,7 @@ async def get_active_namespaces(request: web.Request, userdata: UserData) -> web @routes.post('/namespaces/{namespace}/services/add') +@web_security_headers @auth.authenticated_developers_only() async def add_namespaced_service(request: web.Request, _) -> NoReturn: db = request.app[AppKeys.DB] @@ -656,6 +680,7 @@ async def add_namespaced_service(request: web.Request, _) -> NoReturn: @routes.post('/namespaces/{namespace}/services/{service}/edit') +@web_security_headers @auth.authenticated_developers_only() async def update_namespaced_service(request: web.Request, _) -> NoReturn: db = request.app[AppKeys.DB] @@ -675,6 +700,7 @@ async def update_namespaced_service(request: web.Request, _) -> NoReturn: @routes.post('/namespaces/add') +@web_security_headers @auth.authenticated_developers_only() async def add_namespace(request: web.Request, _) -> NoReturn: db = request.app[AppKeys.DB] @@ -699,6 +725,7 @@ async def add_namespace(request: web.Request, _) -> NoReturn: @routes.get('/envoy-config/{proxy}') +@web_security_headers @auth.authenticated_developers_only() async def get_envoy_configs(request: web.Request, _) -> web.Response: proxy = request.match_info['proxy'] diff --git a/web_common/web_common/__init__.py b/web_common/web_common/__init__.py index 2154381e51d..e5d3ef3cb5a 100644 --- a/web_common/web_common/__init__.py +++ b/web_common/web_common/__init__.py @@ -1,10 +1,13 @@ from .web_common import ( + api_security_headers, base_context, render_template, sass_compile, set_message, setup_aiohttp_jinja2, setup_common_static_routes, + web_security_headers, + web_security_headers_unsafe_eval, ) __all__ = [ @@ -14,4 +17,7 @@ 'set_message', 'base_context', 'render_template', + 'api_security_headers', + 'web_security_headers', + 'web_security_headers_unsafe_eval', ] diff --git a/web_common/web_common/web_common.py b/web_common/web_common/web_common.py index aa585418302..7799d8eaa08 100644 --- a/web_common/web_common/web_common.py +++ b/web_common/web_common/web_common.py @@ -1,5 +1,6 @@ import importlib import os +from functools import wraps from typing import Any, Dict, Optional import aiohttp_jinja2 @@ -103,3 +104,39 @@ async def render_template( response = aiohttp_jinja2.render_template(file, request, context) response.set_cookie('_csrf', csrf_token, secure=True, httponly=True, samesite='strict') return response + + +def api_security_headers(fun): + @wraps(fun) + async def wrapped(request, *args, **kwargs): + response = await fun(request, *args, **kwargs) + response.headers['Strict-Transport-Security'] = 'max-age=63072000; includeSubDomains;' + return response + + return wrapped + + +def web_security_headers(fun): + @wraps(fun) + async def wrapped(request, *args, **kwargs): + response = await fun(request, *args, **kwargs) + response.headers['Strict-Transport-Security'] = 'max-age=63072000; includeSubDomains;' + response.headers['Content-Security-Policy'] = ( + 'default-src \'self\' fonts.googleapis.com fonts.gstatic.com; script-src cdn.jsdelivr.net; frame-ancestors \'self\';' + ) + return response + + return wrapped + + +def web_security_headers_unsafe_eval(fun): + @wraps(fun) + async def wrapped(request, *args, **kwargs): + response = await fun(request, *args, **kwargs) + response.headers['Strict-Transport-Security'] = 'max-age=63072000; includeSubDomains;' + response.headers['Content-Security-Policy'] = ( + 'default-src \'self\' fonts.googleapis.com fonts.gstatic.com; script-src \'unsafe-eval\' cdn.jsdelivr.net; frame-ancestors \'self\';' + ) + return response + + return wrapped