Commit 8c08f957 authored by Joshua Tauberer's avatar Joshua Tauberer

bidirectional alias controls: a new permitted_senders column in the aliases...

bidirectional alias controls: a new permitted_senders column in the aliases table allows setting who can send as an address independently of where the address forwards to

But the default permitted senders are the same as the addresses the alias forwards to.

Merge branch 'dhpiggott-bidirectional-alias-controls'
parents c23a34d4 5924d0fe
...@@ -45,7 +45,7 @@ def authorized_personnel_only(viewfunc): ...@@ -45,7 +45,7 @@ def authorized_personnel_only(viewfunc):
# Authorized to access an API view? # Authorized to access an API view?
if "admin" in privs: if "admin" in privs:
# Call view func. # Call view func.
return viewfunc(*args, **kwargs) return viewfunc(*args, **kwargs)
elif not error: elif not error:
error = "You are not an administrator." error = "You are not an administrator."
...@@ -185,14 +185,15 @@ def mail_aliases(): ...@@ -185,14 +185,15 @@ def mail_aliases():
if request.args.get("format", "") == "json": if request.args.get("format", "") == "json":
return json_response(get_mail_aliases_ex(env)) return json_response(get_mail_aliases_ex(env))
else: else:
return "".join(x+"\t"+y+"\n" for x, y in get_mail_aliases(env)) return "".join(address+"\t"+receivers+"\t"+(senders or "")+"\n" for address, receivers, senders in get_mail_aliases(env))
@app.route('/mail/aliases/add', methods=['POST']) @app.route('/mail/aliases/add', methods=['POST'])
@authorized_personnel_only @authorized_personnel_only
def mail_aliases_add(): def mail_aliases_add():
return add_mail_alias( return add_mail_alias(
request.form.get('source', ''), request.form.get('address', ''),
request.form.get('destination', ''), request.form.get('forwards_to', ''),
request.form.get('permitted_senders', ''),
env, env,
update_if_exists=(request.form.get('update_if_exists', '') == '1') update_if_exists=(request.form.get('update_if_exists', '') == '1')
) )
...@@ -200,7 +201,7 @@ def mail_aliases_add(): ...@@ -200,7 +201,7 @@ def mail_aliases_add():
@app.route('/mail/aliases/remove', methods=['POST']) @app.route('/mail/aliases/remove', methods=['POST'])
@authorized_personnel_only @authorized_personnel_only
def mail_aliases_remove(): def mail_aliases_remove():
return remove_mail_alias(request.form.get('source', ''), env) return remove_mail_alias(request.form.get('address', ''), env)
@app.route('/mail/domains') @app.route('/mail/domains')
@authorized_personnel_only @authorized_personnel_only
...@@ -289,7 +290,7 @@ def dns_set_record(qname, rtype="A"): ...@@ -289,7 +290,7 @@ def dns_set_record(qname, rtype="A"):
# make this action set (replace all records for this # make this action set (replace all records for this
# qname-rtype pair) rather than add (add a new record). # qname-rtype pair) rather than add (add a new record).
action = "set" action = "set"
elif request.method == "DELETE": elif request.method == "DELETE":
if value == '': if value == '':
# Delete all records for this qname-type pair. # Delete all records for this qname-type pair.
......
This diff is collapsed.
...@@ -33,7 +33,7 @@ def run_checks(rounded_values, env, output, pool): ...@@ -33,7 +33,7 @@ def run_checks(rounded_values, env, output, pool):
# (ignore errors; if bind9/rndc isn't running we'd already report # (ignore errors; if bind9/rndc isn't running we'd already report
# that in run_services checks.) # that in run_services checks.)
shell('check_call', ["/usr/sbin/rndc", "flush"], trap=True) shell('check_call', ["/usr/sbin/rndc", "flush"], trap=True)
run_system_checks(rounded_values, env, output) run_system_checks(rounded_values, env, output)
# perform other checks asynchronously # perform other checks asynchronously
...@@ -264,10 +264,10 @@ def run_domain_checks_on_domain(domain, rounded_time, env, dns_domains, dns_zone ...@@ -264,10 +264,10 @@ def run_domain_checks_on_domain(domain, rounded_time, env, dns_domains, dns_zone
if domain == env["PRIMARY_HOSTNAME"]: if domain == env["PRIMARY_HOSTNAME"]:
check_primary_hostname_dns(domain, env, output, dns_domains, dns_zonefiles) check_primary_hostname_dns(domain, env, output, dns_domains, dns_zonefiles)
if domain in dns_domains: if domain in dns_domains:
check_dns_zone(domain, env, output, dns_zonefiles) check_dns_zone(domain, env, output, dns_zonefiles)
if domain in mail_domains: if domain in mail_domains:
check_mail_domain(domain, env, output) check_mail_domain(domain, env, output)
...@@ -351,11 +351,14 @@ def check_primary_hostname_dns(domain, env, output, dns_domains, dns_zonefiles): ...@@ -351,11 +351,14 @@ def check_primary_hostname_dns(domain, env, output, dns_domains, dns_zonefiles):
check_alias_exists("Hostmaster contact address", "hostmaster@" + domain, env, output) check_alias_exists("Hostmaster contact address", "hostmaster@" + domain, env, output)
def check_alias_exists(alias_name, alias, env, output): def check_alias_exists(alias_name, alias, env, output):
mail_alises = dict(get_mail_aliases(env)) mail_aliases = dict([(address, receivers) for address, receivers, *_ in get_mail_aliases(env)])
if alias in mail_alises: if alias in mail_aliases:
output.print_ok("%s exists as a mail alias. [%s ↦ %s]" % (alias_name, alias, mail_alises[alias])) if mail_aliases[alias]:
output.print_ok("%s exists as a mail alias. [%s ↦ %s]" % (alias_name, alias, mail_aliases[alias]))
else:
output.print_error("""You must set the destination of the mail alias for %s to direct email to you or another administrator.""" % alias)
else: else:
output.print_error("""You must add a mail alias for %s and direct email to you or another administrator.""" % alias) output.print_error("""You must add a mail alias for %s which directs email to you or another administrator.""" % alias)
def check_dns_zone(domain, env, output, dns_zonefiles): def check_dns_zone(domain, env, output, dns_zonefiles):
# If a DS record is set at the registrar, check DNSSEC first because it will affect the NS query. # If a DS record is set at the registrar, check DNSSEC first because it will affect the NS query.
...@@ -492,7 +495,7 @@ def check_mail_domain(domain, env, output): ...@@ -492,7 +495,7 @@ def check_mail_domain(domain, env, output):
# Check that the postmaster@ email address exists. Not required if the domain has a # Check that the postmaster@ email address exists. Not required if the domain has a
# catch-all address or domain alias. # catch-all address or domain alias.
if "@" + domain not in dict(get_mail_aliases(env)): if "@" + domain not in [address for address, *_ in get_mail_aliases(env)]:
check_alias_exists("Postmaster contact address", "postmaster@" + domain, env, output) check_alias_exists("Postmaster contact address", "postmaster@" + domain, env, output)
# Stop if the domain is listed in the Spamhaus Domain Block List. # Stop if the domain is listed in the Spamhaus Domain Block List.
...@@ -644,7 +647,7 @@ def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring ...@@ -644,7 +647,7 @@ def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring
return "*." + idna.encode(dns_name[2:]).decode('ascii') return "*." + idna.encode(dns_name[2:]).decode('ascii')
else: else:
return idna.encode(dns_name).decode('ascii') return idna.encode(dns_name).decode('ascii')
try: try:
sans = cert.extensions.get_extension_for_oid(OID_SUBJECT_ALTERNATIVE_NAME).value.get_values_for_type(DNSName) sans = cert.extensions.get_extension_for_oid(OID_SUBJECT_ALTERNATIVE_NAME).value.get_values_for_type(DNSName)
for san in sans: for san in sans:
...@@ -884,7 +887,7 @@ def run_and_output_changes(env, pool, send_via_email): ...@@ -884,7 +887,7 @@ def run_and_output_changes(env, pool, send_via_email):
if category not in cur_status: if category not in cur_status:
out.add_heading(category) out.add_heading(category)
out.print_warning("This section was removed.") out.print_warning("This section was removed.")
if send_via_email: if send_via_email:
# If there were changes, send off an email. # If there were changes, send off an email.
buf = out.buf.getvalue() buf = out.buf.getvalue()
...@@ -896,7 +899,7 @@ def run_and_output_changes(env, pool, send_via_email): ...@@ -896,7 +899,7 @@ def run_and_output_changes(env, pool, send_via_email):
msg['To'] = "administrator@%s" % env['PRIMARY_HOSTNAME'] msg['To'] = "administrator@%s" % env['PRIMARY_HOSTNAME']
msg['Subject'] = "[%s] Status Checks Change Notice" % env['PRIMARY_HOSTNAME'] msg['Subject'] = "[%s] Status Checks Change Notice" % env['PRIMARY_HOSTNAME']
msg.set_payload(buf, "UTF-8") msg.set_payload(buf, "UTF-8")
# send to administrator@ # send to administrator@
import smtplib import smtplib
mailserver = smtplib.SMTP('localhost', 25) mailserver = smtplib.SMTP('localhost', 25)
...@@ -906,7 +909,7 @@ def run_and_output_changes(env, pool, send_via_email): ...@@ -906,7 +909,7 @@ def run_and_output_changes(env, pool, send_via_email):
"administrator@%s" % env['PRIMARY_HOSTNAME'], # RCPT TO "administrator@%s" % env['PRIMARY_HOSTNAME'], # RCPT TO
msg.as_string()) msg.as_string())
mailserver.quit() mailserver.quit()
# Store the current status checks output for next time. # Store the current status checks output for next time.
os.makedirs(os.path.dirname(cache_fn), exist_ok=True) os.makedirs(os.path.dirname(cache_fn), exist_ok=True)
with open(cache_fn, "w") as f: with open(cache_fn, "w") as f:
......
This diff is collapsed.
...@@ -5,7 +5,7 @@ ...@@ -5,7 +5,7 @@
# #
# This script configures user authentication for Dovecot # This script configures user authentication for Dovecot
# and Postfix (which relies on Dovecot) and destination # and Postfix (which relies on Dovecot) and destination
# validation by quering an Sqlite3 database of mail users. # validation by quering an Sqlite3 database of mail users.
source setup/functions.sh # load our functions source setup/functions.sh # load our functions
source /etc/mailinabox.conf # load global vars source /etc/mailinabox.conf # load global vars
...@@ -21,7 +21,7 @@ db_path=$STORAGE_ROOT/mail/users.sqlite ...@@ -21,7 +21,7 @@ db_path=$STORAGE_ROOT/mail/users.sqlite
if [ ! -f $db_path ]; then if [ ! -f $db_path ]; then
echo Creating new user database: $db_path; echo Creating new user database: $db_path;
echo "CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, password TEXT NOT NULL, extra, privileges TEXT NOT NULL DEFAULT '');" | sqlite3 $db_path; echo "CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, password TEXT NOT NULL, extra, privileges TEXT NOT NULL DEFAULT '');" | sqlite3 $db_path;
echo "CREATE TABLE aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL UNIQUE, destination TEXT NOT NULL);" | sqlite3 $db_path; echo "CREATE TABLE aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL UNIQUE, destination TEXT NOT NULL, permitted_senders TEXT);" | sqlite3 $db_path;
fi fi
# ### User Authentication # ### User Authentication
...@@ -71,18 +71,23 @@ tools/editconf.py /etc/postfix/main.cf \ ...@@ -71,18 +71,23 @@ tools/editconf.py /etc/postfix/main.cf \
# ### Sender Validation # ### Sender Validation
# Use a Sqlite3 database to set login maps. This is used with # We use Postfix's reject_authenticated_sender_login_mismatch filter to
# reject_authenticated_sender_login_mismatch to see if user is # prevent intra-domain spoofing by logged in but untrusted users in outbound
# allowed to send mail using FROM field specified in the request. # email. In all outbound mail (the sender has authenticated), the MAIL FROM
# address (aka envelope or return path address) must be "owned" by the user
# who authenticated. An SQL query will find who are the owners of any given
# address.
tools/editconf.py /etc/postfix/main.cf \ tools/editconf.py /etc/postfix/main.cf \
smtpd_sender_login_maps=sqlite:/etc/postfix/sender-login-maps.cf smtpd_sender_login_maps=sqlite:/etc/postfix/sender-login-maps.cf
# SQL statement to set login map which includes the case when user is # Postfix will query the exact address first, where the priority will be alias
# sending email using a valid alias. # records first, then user records. If there are no matches for the exact
# This is the same as virtual-alias-maps.cf, See below # address, then Postfix will query just the domain part, which we call
# catch-alls and domain aliases. A NULL permitted_senders column means to
# take the value from the destination column.
cat > /etc/postfix/sender-login-maps.cf << EOF; cat > /etc/postfix/sender-login-maps.cf << EOF;
dbpath=$db_path dbpath=$db_path
query = SELECT destination from (SELECT destination, 0 as priority FROM aliases WHERE source='%s' UNION SELECT email as destination, 1 as priority FROM users WHERE email='%s') ORDER BY priority LIMIT 1; query = SELECT permitted_senders FROM (SELECT permitted_senders, 0 AS priority FROM aliases WHERE source='%s' AND permitted_senders IS NOT NULL UNION SELECT destination AS permitted_senders, 1 AS priority FROM aliases WHERE source='%s' AND permitted_senders IS NULL UNION SELECT email as permitted_senders, 2 AS priority FROM users WHERE email='%s') ORDER BY priority LIMIT 1;
EOF EOF
# ### Destination Validation # ### Destination Validation
...@@ -95,13 +100,13 @@ tools/editconf.py /etc/postfix/main.cf \ ...@@ -95,13 +100,13 @@ tools/editconf.py /etc/postfix/main.cf \
virtual_alias_maps=sqlite:/etc/postfix/virtual-alias-maps.cf \ virtual_alias_maps=sqlite:/etc/postfix/virtual-alias-maps.cf \
local_recipient_maps=\$virtual_mailbox_maps local_recipient_maps=\$virtual_mailbox_maps
# SQL statement to check if we handle mail for a domain, either for users or aliases. # SQL statement to check if we handle incoming mail for a domain, either for users or aliases.
cat > /etc/postfix/virtual-mailbox-domains.cf << EOF; cat > /etc/postfix/virtual-mailbox-domains.cf << EOF;
dbpath=$db_path dbpath=$db_path
query = SELECT 1 FROM users WHERE email LIKE '%%@%s' UNION SELECT 1 FROM aliases WHERE source LIKE '%%@%s' query = SELECT 1 FROM users WHERE email LIKE '%%@%s' UNION SELECT 1 FROM aliases WHERE source LIKE '%%@%s'
EOF EOF
# SQL statement to check if we handle mail for a user. # SQL statement to check if we handle incoming mail for a user.
cat > /etc/postfix/virtual-mailbox-maps.cf << EOF; cat > /etc/postfix/virtual-mailbox-maps.cf << EOF;
dbpath=$db_path dbpath=$db_path
query = SELECT 1 FROM users WHERE email='%s' query = SELECT 1 FROM users WHERE email='%s'
...@@ -127,9 +132,13 @@ EOF ...@@ -127,9 +132,13 @@ EOF
# might be returned by the UNION, so the whole query is wrapped in # might be returned by the UNION, so the whole query is wrapped in
# another select that prioritizes the alias definition to preserve # another select that prioritizes the alias definition to preserve
# postfix's preference for aliases for whole email addresses. # postfix's preference for aliases for whole email addresses.
#
# Since we might have alias records with an empty destination because
# it might have just permitted_senders, skip any records with an
# empty destination here so that other lower priority rules might match.
cat > /etc/postfix/virtual-alias-maps.cf << EOF; cat > /etc/postfix/virtual-alias-maps.cf << EOF;
dbpath=$db_path dbpath=$db_path
query = SELECT destination from (SELECT destination, 0 as priority FROM aliases WHERE source='%s' UNION SELECT email as destination, 1 as priority FROM users WHERE email='%s') ORDER BY priority LIMIT 1; query = SELECT destination from (SELECT destination, 0 as priority FROM aliases WHERE source='%s' AND destination<>'' UNION SELECT email as destination, 1 as priority FROM users WHERE email='%s') ORDER BY priority LIMIT 1;
EOF EOF
# Restart Services # Restart Services
......
...@@ -101,6 +101,16 @@ def migration_8(env): ...@@ -101,6 +101,16 @@ def migration_8(env):
# a new key, which will be 2048 bits. # a new key, which will be 2048 bits.
os.unlink(os.path.join(env['STORAGE_ROOT'], 'mail/dkim/mail.private')) os.unlink(os.path.join(env['STORAGE_ROOT'], 'mail/dkim/mail.private'))
def migration_9(env):
# Add a column to the aliases table to store permitted_senders,
# which is a list of user account email addresses that are
# permitted to send mail using this alias instead of their own
# address. This was motivated by the addition of #427 ("Reject
# outgoing mail if FROM does not match Login") - which introduced
# the notion of outbound permitted-senders.
db = os.path.join(env["STORAGE_ROOT"], 'mail/users.sqlite')
shell("check_call", ["sqlite3", db, "ALTER TABLE aliases ADD permitted_senders TEXT"])
def get_current_migration(): def get_current_migration():
ver = 0 ver = 0
while True: while True:
......
...@@ -120,10 +120,10 @@ elif sys.argv[1] == "alias" and len(sys.argv) == 2: ...@@ -120,10 +120,10 @@ elif sys.argv[1] == "alias" and len(sys.argv) == 2:
print(mgmt("/mail/aliases")) print(mgmt("/mail/aliases"))
elif sys.argv[1] == "alias" and sys.argv[2] == "add" and len(sys.argv) == 5: elif sys.argv[1] == "alias" and sys.argv[2] == "add" and len(sys.argv) == 5:
print(mgmt("/mail/aliases/add", { "source": sys.argv[3], "destination": sys.argv[4] })) print(mgmt("/mail/aliases/add", { "address": sys.argv[3], "forwards_to": sys.argv[4] }))
elif sys.argv[1] == "alias" and sys.argv[2] == "remove" and len(sys.argv) == 4: elif sys.argv[1] == "alias" and sys.argv[2] == "remove" and len(sys.argv) == 4:
print(mgmt("/mail/aliases/remove", { "source": sys.argv[3] })) print(mgmt("/mail/aliases/remove", { "address": sys.argv[3] }))
else: else:
print("Invalid command-line arguments.") print("Invalid command-line arguments.")
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment