diff --git a/app/SECURITY.md b/app/SECURITY.md index 9c4234f..1c91486 100644 --- a/app/SECURITY.md +++ b/app/SECURITY.md @@ -7,8 +7,4 @@ If you want be up to date on security patches, make sure your SimpleLogin image ## Reporting a Vulnerability -If you've found a security vulnerability, you can disclose it responsibly by sending a summary to security@simplelogin.io. -We will review the potential threat and fix it as fast as we can. - -We are incredibly thankful for people who disclose vulnerabilities, unfortunately we do not have a bounty program in place yet. - +If you want to report a vulnerability, please take a look at our bug bounty program at https://proton.me/security/bug-bounty. diff --git a/app/app/auth/views/login.py b/app/app/auth/views/login.py index 261c1a9..ffa09a6 100644 --- a/app/app/auth/views/login.py +++ b/app/app/auth/views/login.py @@ -10,6 +10,7 @@ from app.events.auth_event import LoginEvent from app.extensions import limiter from app.log import LOG from app.models import User +from app.pw_models import PasswordOracle from app.utils import sanitize_email, sanitize_next_url, canonicalize_email @@ -43,6 +44,13 @@ def login(): user = User.get_by(email=email) or User.get_by(email=canonical_email) if not user or not user.check_password(form.password.data): + if not user: + # Do the hash to avoid timing attacks nevertheless + dummy_pw = PasswordOracle() + dummy_pw.password = ( + "$2b$12$ZWqpL73h4rGNfLkJohAFAu0isqSw/bX9p/tzpbWRz/To5FAftaW8u" + ) + dummy_pw.check_password(form.password.data) # Trigger rate limiter g.deduct_limit = True form.password.data = None diff --git a/app/app/contact_utils.py b/app/app/contact_utils.py index e2bb62e..d7fc89e 100644 --- a/app/app/contact_utils.py +++ b/app/app/contact_utils.py @@ -91,6 +91,7 @@ def create_contact( alias_id = alias.id try: flags = Contact.FLAG_PARTNER_CREATED if from_partner else 0 + is_invalid_email = email == "" contact = Contact.create( user_id=alias.user_id, alias_id=alias.id, @@ -100,9 +101,10 @@ def create_contact( mail_from=mail_from, automatic_created=automatic_created, flags=flags, - invalid_email=email == "", + invalid_email=is_invalid_email, commit=True, ) + contact_id = contact.id if automatic_created: trail = ". Automatically created" else: @@ -110,11 +112,11 @@ def create_contact( emit_alias_audit_log( alias=alias, action=AliasAuditLogAction.CreateContact, - message=f"Created contact {contact.id} ({contact.email}){trail}", + message=f"Created contact {contact_id} ({email}){trail}", commit=True, ) LOG.d( - f"Created contact {contact} for alias {alias} with email {email} invalid_email={contact.invalid_email}" + f"Created contact {contact} for alias {alias} with email {email} invalid_email={is_invalid_email}" ) return ContactCreateResult(contact, created=True, error=None) except IntegrityError: diff --git a/app/app/dashboard/views/account_setting.py b/app/app/dashboard/views/account_setting.py index 28c0241..d1fb4cf 100644 --- a/app/app/dashboard/views/account_setting.py +++ b/app/app/dashboard/views/account_setting.py @@ -1,3 +1,5 @@ +import secrets + import arrow from flask import ( render_template, @@ -163,7 +165,7 @@ def send_reset_password_email(user): """ # the activation code is valid for 1h reset_password_code = ResetPasswordCode.create( - user_id=user.id, code=random_string(60) + user_id=user.id, code=secrets.token_urlsafe(32) ) Session.commit() diff --git a/app/app/dashboard/views/lifetime_licence.py b/app/app/dashboard/views/lifetime_licence.py index 2fc4c56..d853d18 100644 --- a/app/app/dashboard/views/lifetime_licence.py +++ b/app/app/dashboard/views/lifetime_licence.py @@ -1,3 +1,4 @@ +import arrow from flask import render_template, flash, redirect, url_for from flask_login import login_required, current_user from flask_wtf import FlaskForm @@ -7,6 +8,8 @@ from app.config import ADMIN_EMAIL from app.dashboard.base import dashboard_bp from app.db import Session from app.email_utils import send_email +from app.events.event_dispatcher import EventDispatcher +from app.events.generated.event_pb2 import UserPlanChanged, EventContent from app.models import LifetimeCoupon @@ -40,6 +43,14 @@ def lifetime_licence(): current_user.lifetime_coupon_id = coupon.id if coupon.paid: current_user.paid_lifetime = True + EventDispatcher.send_event( + user=current_user, + content=EventContent( + user_plan_change=UserPlanChanged( + plan_end_time=arrow.get("2100-01-01").timestamp + ) + ), + ) Session.commit() # notify admin diff --git a/app/app/mailbox_utils.py b/app/app/mailbox_utils.py index bc7f86b..4b77746 100644 --- a/app/app/mailbox_utils.py +++ b/app/app/mailbox_utils.py @@ -1,6 +1,5 @@ import dataclasses import secrets -import random from enum import Enum from typing import Optional import arrow @@ -233,7 +232,7 @@ def generate_activation_code( if config.MAILBOX_VERIFICATION_OVERRIDE_CODE: code = config.MAILBOX_VERIFICATION_OVERRIDE_CODE else: - code = "{:06d}".format(random.randint(1, 999999)) + code = "{:06d}".format(secrets.randbelow(1000000))[:6] else: code = secrets.token_urlsafe(16) return MailboxActivation.create( diff --git a/app/app/utils.py b/app/app/utils.py index 21d96bf..c2a8b79 100644 --- a/app/app/utils.py +++ b/app/app/utils.py @@ -1,4 +1,3 @@ -import random import re import secrets import string @@ -32,8 +31,9 @@ def random_words(words: int = 2, numbers: int = 0): fields = [secrets.choice(_words) for i in range(words)] if numbers > 0: - digits = "".join([str(random.randint(0, 9)) for i in range(numbers)]) - return "_".join(fields) + digits + digits = [n for n in range(10)] + suffix = "".join([str(secrets.choice(digits)) for i in range(numbers)]) + return "_".join(fields) + suffix else: return "_".join(fields) diff --git a/app/cron.py b/app/cron.py index 1d65adb..af86bb6 100644 --- a/app/cron.py +++ b/app/cron.py @@ -286,8 +286,16 @@ def notify_manual_sub_end(): def poll_apple_subscription(): """Poll Apple API to update AppleSubscription""" - # todo: only near the end of the subscription - for apple_sub in AppleSubscription.all(): + for apple_sub in ( + AppleSubscription.filter( + AppleSubscription.expires_date < arrow.now().shift(days=15) + ) + .enable_eagerloads(False) + .yield_per(100) + ): + if not apple_sub.is_valid(): + # Subscription is not valid anymore and hasn't been renewed + continue if not apple_sub.product_id: LOG.d("Ignore %s", apple_sub) continue @@ -900,6 +908,24 @@ def check_mailbox_valid_pgp_keys(): def check_custom_domain(): + # Delete custom domains that haven't been verified in a month + for custom_domain in ( + CustomDomain.filter( + CustomDomain.verified == False, # noqa: E712 + CustomDomain.created_at < arrow.now().shift(months=-1), + ) + .enable_eagerloads(False) + .yield_per(100) + ): + alias_count = Alias.filter(Alias.custom_domain_id == custom_domain.id).count() + if alias_count > 0: + LOG.warn( + f"Custom Domain {custom_domain} has {alias_count} aliases. Won't delete" + ) + else: + LOG.i(f"Deleting unverified old custom domain {custom_domain}") + CustomDomain.delete(custom_domain.id) + LOG.d("Check verified domain for DNS issues") for custom_domain in CustomDomain.filter_by(verified=True): # type: CustomDomain @@ -971,7 +997,7 @@ def delete_expired_tokens(): LOG.d("Delete api to cookie tokens older than %s, nb row %s", max_time, nb_row) -async def _hibp_check(api_key, queue): +async def _hibp_check(api_key: str, queue: asyncio.Queue): """ Uses a single API key to check the queue as fast as possible. @@ -990,11 +1016,16 @@ async def _hibp_check(api_key, queue): if not alias: continue user = alias.user - if user.disabled or not user.is_paid(): + if user.disabled or not user.is_premium(): # Mark it as hibp done to skip it as if it had been checked alias.hibp_last_check = arrow.utcnow() Session.commit() continue + if alias.flags & Alias.FLAG_PARTNER_CREATED > 0: + # Mark as hibp done + alias.hibp_last_check = arrow.utcnow() + Session.commit() + continue LOG.d("Checking HIBP for %s", alias) diff --git a/app/crontab.yml b/app/crontab.yml index 7812df8..1198d54 100644 --- a/app/crontab.yml +++ b/app/crontab.yml @@ -16,13 +16,25 @@ jobs: shell: /bin/bash schedule: "15 2 * * *" captureStderr: true + onFailure: + retry: + maximumRetries: 10 + initialDelay: 1 + maximumDelay: 30 + backoffMultiplier: 2 - name: SimpleLogin HIBP check command: python /code/cron.py -j check_hibp shell: /bin/bash - schedule: "15 3 * * *" + schedule: "16 */4 * * *" captureStderr: true concurrencyPolicy: Forbid + onFailure: + retry: + maximumRetries: 10 + initialDelay: 1 + maximumDelay: 30 + backoffMultiplier: 2 - name: SimpleLogin Notify HIBP breaches command: python /code/cron.py -j notify_hibp @@ -31,6 +43,7 @@ jobs: captureStderr: true concurrencyPolicy: Forbid + - name: SimpleLogin Delete Logs command: python /code/cron.py -j delete_logs shell: /bin/bash diff --git a/app/email_handler.py b/app/email_handler.py index 38fa8ab..59f203b 100644 --- a/app/email_handler.py +++ b/app/email_handler.py @@ -177,7 +177,9 @@ from init_app import load_pgp_public_keys from server import create_light_app -def get_or_create_contact(from_header: str, mail_from: str, alias: Alias) -> Contact: +def get_or_create_contact( + from_header: str, mail_from: str, alias: Alias +) -> Optional[Contact]: """ contact_from_header is the RFC 2047 format FROM header """ @@ -208,6 +210,8 @@ def get_or_create_contact(from_header: str, mail_from: str, alias: Alias) -> Con automatic_created=True, from_partner=False, ) + if contact_result.error: + LOG.w(f"Error creating contact: {contact_result.error.value}") return contact_result.contact @@ -558,7 +562,7 @@ def handle_forward(envelope, msg: Message, rcpt_to: str) -> List[Tuple[bool, str if not user.is_active(): LOG.w(f"User {user} has been soft deleted") - return False, status.E502 + return [(False, status.E502)] if not user.can_send_or_receive(): LOG.i(f"User {user} cannot receive emails") @@ -579,6 +583,8 @@ def handle_forward(envelope, msg: Message, rcpt_to: str) -> List[Tuple[bool, str from_header = get_header_unicode(msg[headers.FROM]) LOG.d("Create or get contact for from_header:%s", from_header) contact = get_or_create_contact(from_header, envelope.mail_from, alias) + if not contact: + return [(False, status.E504)] alias = ( contact.alias ) # In case the Session was closed in the get_or_create we re-fetch the alias diff --git a/app/oneshot/send_lifetime_user_events.py b/app/oneshot/send_lifetime_user_events.py new file mode 100644 index 0000000..a280f32 --- /dev/null +++ b/app/oneshot/send_lifetime_user_events.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +import argparse +import time + +import arrow +from sqlalchemy import func + +from app.events.event_dispatcher import EventDispatcher +from app.events.generated.event_pb2 import UserPlanChanged, EventContent +from app.models import PartnerUser +from app.db import Session + +parser = argparse.ArgumentParser( + prog="Backfill alias", description="Send lifetime users to proton" +) +parser.add_argument( + "-s", "--start_pu_id", default=0, type=int, help="Initial partner_user_id" +) +parser.add_argument( + "-e", "--end_pu_id", default=0, type=int, help="Last partner_user_id" +) + +args = parser.parse_args() +pu_id_start = args.start_pu_id +max_pu_id = args.end_pu_id +if max_pu_id == 0: + max_pu_id = Session.query(func.max(PartnerUser.id)).scalar() + +print(f"Checking partner user {pu_id_start} to {max_pu_id}") +step = 100 +done = 0 +start_time = time.time() +with_lifetime = 0 +for batch_start in range(pu_id_start, max_pu_id, step): + partner_users = ( + Session.query(PartnerUser).filter( + PartnerUser.id >= batch_start, PartnerUser.id < batch_start + step + ) + ).all() + for partner_user in partner_users: + done += 1 + if not partner_user.user.lifetime: + continue + with_lifetime += 1 + event = UserPlanChanged(plan_end_time=arrow.get("2100-01-01").timestamp) + EventDispatcher.send_event( + partner_user.user, EventContent(user_plan_change=event) + ) + Session.flush() + Session.commit() + elapsed = time.time() - start_time + last_batch_id = batch_start + step + time_per_alias = elapsed / (last_batch_id) + remaining = max_pu_id - last_batch_id + time_remaining = remaining / time_per_alias + hours_remaining = time_remaining / 60.0 + print( + f"\PartnerUser {batch_start}/{max_pu_id} {done} {hours_remaining:.2f} mins remaining" + ) +print(f"With SL lifetime {with_lifetime}") diff --git a/app/oneshot/send_plan_change_events.py b/app/oneshot/send_plan_change_events.py index 4fcaad2..2afae40 100644 --- a/app/oneshot/send_plan_change_events.py +++ b/app/oneshot/send_plan_change_events.py @@ -2,6 +2,7 @@ import argparse import time +import arrow from sqlalchemy import func from app.events.event_dispatcher import EventDispatcher @@ -30,6 +31,7 @@ step = 100 updated = 0 start_time = time.time() with_premium = 0 +with_lifetime = 0 for batch_start in range(pu_id_start, max_pu_id, step): partner_users = ( Session.query(PartnerUser).filter( @@ -41,7 +43,10 @@ for batch_start in range(pu_id_start, max_pu_id, step): include_partner_subscription=False ) end_timestamp = None - if subscription_end: + if partner_user.user.lifetime: + with_lifetime += 1 + end_timestamp = arrow.get("2100-01-01").timestamp + elif subscription_end: with_premium += 1 end_timestamp = subscription_end.timestamp event = UserPlanChanged(plan_end_time=end_timestamp) @@ -60,4 +65,4 @@ for batch_start in range(pu_id_start, max_pu_id, step): print( f"\PartnerUser {batch_start}/{max_pu_id} {updated} {hours_remaining:.2f} mins remaining" ) -print(f"With SL premium {with_premium}") +print(f"With SL premium {with_premium} lifetime {with_lifetime}") diff --git a/app/templates/admin/email_search.html b/app/templates/admin/email_search.html index e42d9e3..caef2c3 100644 --- a/app/templates/admin/email_search.html +++ b/app/templates/admin/email_search.html @@ -11,6 +11,7 @@