Implement user-defined content white/blacklists

This commit is contained in:
Drew DeVault 2019-03-25 16:06:04 -04:00
parent ccbe58fdfb
commit e3757e09c0
8 changed files with 146 additions and 25 deletions

View File

@ -80,20 +80,6 @@ sock=/tmp/lists.sr.ht-lmtp.sock
# group.
sock-group=postfix
#
# Comma-delimited list of Content-Types to permit. Messages with Content-Types
# not included in this list are rejected. Multipart messages are always
# supported, and each part is checked against this list.
#
# Uses fnmatch for wildcard expansion.
permit-mimetypes=text/*,application/pgp-signature,application/pgp-keys
#
# Comma-delimited list of Content-Types to reject. Messages with Content-Types
# included in this list are rejected. Multipart messages are always supported,
# and each part is checked against this list.
#
# Uses fnmatch for wildcard expansion.
reject-mimetypes=text/html
#
# Link to include in the rejection message where senders can get help
# correcting their email.
reject-url=https://man.sr.ht/lists.sr.ht/how-to-send.md

View File

@ -21,10 +21,6 @@ class MailHandler:
self.pg = pg
async def initialize(self):
self.permit_mimetypes = cfg("lists.sr.ht::worker",
"permit-mimetypes").split(',')
self.reject_mimetypes = cfg("lists.sr.ht::worker",
"reject-mimetypes").split(',')
self.reject_url = cfg("lists.sr.ht::worker", "reject-url")
self.fetch_user = await self.pg.prepare(
'''SELECT "id" FROM "user"
@ -38,7 +34,9 @@ class MailHandler:
"owner_id",
"nonsubscriber_permissions",
"subscriber_permissions",
"account_permissions"
"account_permissions",
"permit_mimetypes",
"reject_mimetypes"
FROM "list"
WHERE "owner_id" = $1 AND "name" = $2''')
self.fetch_subscription = await self.pg.prepare(
@ -87,12 +85,14 @@ class MailHandler:
owner_id, list_name)
return result, command
def validate(self, mail):
def validate(self, mail, permit_mimetypes, reject_mimetypes):
required_headers = ["To", "From", "Subject", "Message-Id"]
for header in required_headers:
if not mail.get(header):
return "The {} header is required.".format(header)
found_textpart = False
permit_mimetypes = permit_mimetypes.split(",")
reject_mimetypes = reject_mimetypes.split(",") + ["text/html"]
for part in mail.walk():
content_type = part.get_content_type()
if content_type == "text/plain":
@ -100,14 +100,14 @@ class MailHandler:
if fnmatch(content_type, "multipart/*"):
continue
permit = False
for whitelist in self.permit_mimetypes:
for whitelist in permit_mimetypes:
if fnmatch(content_type, whitelist):
permit = True
break
if not permit:
return "Content-Type {} is not in the whitelist.".format(
content_type)
for blacklist in self.reject_mimetypes:
for blacklist in reject_mimetypes:
if fnmatch(content_type, blacklist):
return "Content-Type {} is blacklisted.".format(
content_type)
@ -127,7 +127,9 @@ class MailHandler:
if dest is None:
print("Rejected, mailing list not found")
return "550 The mailing list you requested does not exist."
dest_id, owner_id, nonsub_perms, sub_perms, external_perms = dest
(dest_id, owner_id,
nonsub_perms, sub_perms, external_perms,
permit_mimetypes, reject_mimetypes) = dest
nonsub_perms = ListAccess(nonsub_perms)
sub_perms = ListAccess(sub_perms)
external_perms = ListAccess(external_perms)
@ -152,7 +154,7 @@ class MailHandler:
dispatch_message.delay(address, dest_id, str(mail))
return "250 Message accepted for delivery"
err = self.validate(mail)
err = self.validate(mail, permit_mimetypes, reject_mimetypes)
if err is not None:
print("Rejected due to validation errors")
return "500 Rejected. {} See {} for help.".format(

View File

@ -3,4 +3,3 @@ import listssrht.alembic
import srht.alembic
from srht.database import alembic
alembic("lists.sr.ht", listssrht.alembic)
alembic("lists.sr.ht", srht.alembic)

View File

@ -0,0 +1,27 @@
"""Add permit/reject mimetypes
Revision ID: 056c313b267f
Revises: 86fbf902f15b
Create Date: 2019-03-25 15:55:06.110157
"""
# revision identifiers, used by Alembic.
revision = '056c313b267f'
down_revision = '86fbf902f15b'
from alembic import op
import sqlalchemy as sa
def upgrade():
op.add_column('list', sa.Column('permit_mimetypes',
sa.Unicode, nullable=False,
server_default="text/*,application/pgp-signature,application/pgp-keys"))
op.add_column('list', sa.Column('reject_mimetypes',
sa.Unicode, nullable=False, server_default=''))
def downgrade():
op.delete_column('list', 'permit_mimetypes')
op.delete_column('list', 'reject_mimetypes')

View File

@ -177,3 +177,31 @@ def acl_delete_POST(owner_name, list_name, acl_id):
db.session.commit()
return redirect(url_for("settings.access_GET",
owner_name=owner_name, list_name=list_name))
@settings.route("/<owner_name>/<list_name>/settings/content")
@loginrequired
def content_GET(owner_name, list_name):
owner, ml, access = get_list(owner_name, list_name)
if not ml:
abort(404)
if ml.owner_id != current_user.id:
abort(403)
return render_template("settings-content.html",
view="content", ml=ml, owner=owner)
@settings.route("/<owner_name>/<list_name>/settings/content", methods=["POST"])
@loginrequired
def content_POST(owner_name, list_name):
owner, ml, access = get_list(owner_name, list_name)
if not ml:
abort(404)
if ml.owner_id != current_user.id:
abort(403)
valid = Validation(request)
ml.permit_mimetypes = valid.optional("permit_mimetypes")
ml.reject_mimetypes = valid.optional("reject_mimetypes")
db.session.commit()
return redirect(url_for("settings.content_GET",
owner_name=owner_name, list_name=list_name))

View File

@ -0,0 +1,70 @@
{% extends "settings.html" %}
{% block title %}
<title>
{{ owner.canonical_name }}/{{ ml.name }} settings &mdash; {{ cfg("sr.ht", "site-name") }} lists
</title>
{% endblock %}
{% block content %}
<div class="container">
<div class="row">
<div class="col-md-8">
<form method="POST">
{{csrf_token()}}
<div class="form-group">
<label for="permit_mimetypes">
Permitted mimetypes
</label>
<input
type="text"
name="permit_mimetypes"
id="permit_mimetypes"
class="form-control"
value="{{ ml.permit_mimetypes }}"
aria-describedby="permit_mimetypes-help" />
<p
id="permit_mimetypes-help"
class="form-text text-muted"
>
Comma separated list of mimetypes, <a
href="https://pubs.opengroup.org/onlinepubs/009695399/utilities/xcu_chap02.html#tag_02_13_01"
rel="noopener"
target="_blank"
>fnmatch</a> for wildcards.
</p>
{{ valid.summary("permit_mimetypes") }}
</div>
<div class="form-group">
<label for="reject_mimetypes">
Rejected mimetypes
</label>
<input
type="text"
name="reject_mimetypes"
id="reject_mimetypes"
class="form-control"
value="{{ ml.reject_mimetypes }}"
aria-describedby="reject_mimetypes-help" />
<p
id="reject_mimetypes-help"
class="form-text text-muted"
>
Comma separated list of mimetypes, <a
href="https://pubs.opengroup.org/onlinepubs/009695399/utilities/xcu_chap02.html#tag_02_13_01"
rel="noopener"
target="_blank"
>fnmatch</a> for wildcards.
<code>text/html</code> is always rejected.
</p>
{{ valid.summary("reject_mimetypes") }}
</div>
{{ valid.summary() }}
<span class="pull-right">
<button type="submit" class="btn btn-primary">
Save {{icon("caret-right")}}
</button>
</span>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@ -31,5 +31,10 @@
list_name=ml.name), "access")}}
</li>
{% endif %}
<li class="nav-item">
{{link(url_for("settings.content_GET",
owner_name=owner.canonical_name,
list_name=ml.name), "content")}}
</li>
</ul>
{% endblock %}

View File

@ -30,6 +30,10 @@ class List(Base):
Permissions granted to holders of sr.ht accounts.
"""
permit_mimetypes = sa.Column(sa.Unicode, nullable=False,
server_default="text/*,application/pgp-signature,application/pgp-keys")
reject_mimetypes = sa.Column(sa.Unicode, nullable=False, server_default="")
owner_id = sa.Column(sa.Integer, sa.ForeignKey('user.id'), nullable=False)
owner = sa.orm.relationship('User', backref=sa.orm.backref('lists'))