From a38e5d5b53805ecb1d94853d849826f948f07aad Mon Sep 17 00:00:00 2001
From: Nicolas F <CounterPillow@users.noreply.github.com>
Date: Sat, 30 Jun 2018 05:15:04 +0200
Subject: [PATCH] Implement range bans (#478)

* Implement range bans

People connecting from banned IP ranges are unable to upload
torrents anonymously, and need to manually have their accounts
activated.

This adds a new table "rangebans", and a command line utility,
"rangeban.py", which can be used to add, list and remove rangebans
from the command line.

As an example:

./rangeban.py ban 192.168.0.0/24

This would rangeban anything in this /24.

The temporary_tor column allows automated scripts to clean out and
re-add ever-changing sets of ranges to be banned without affecting
the other ranges.

This has only been tested for IPv4.

* Revise Rangebans

Add an id column, and change "temporary_tor" to "temp". Also
index masked_cidr and mask.

* rangebans: fix enabled and the binary op

kill me

* Add enabling/disabling bans to rangeban.py

* rangebans: fail earlier on garbage arguments

* rangebans: fix linter errors

* rangeban.py: don't shadow builtin keyword 'id'

* rangebans: change temporary ban logic, column

The 'temp' column is now a nullable time column. If the field is
null, the ban is understood to be permanent. If there is a time
in there, it's understood to be the creation time of the ban.

This allows scripts to e.g. delete all temporary bans older than
a certain amount of time.

Also, rename the '_cidr_string' column to 'cidr_string', because
reasons.

* rangeban.py: use ip_address to parse CIDR subnet

* rangebans: fixes to the mask calculation and query

Both were not bugs per-se, but just technically not needed/correct.

* De-meme apparently
---
 .../versions/f69d7fec88d6_add_rangebans.py    |  40 ++++++
 nyaa/backend.py                               |   6 +
 nyaa/forms.py                                 |   1 +
 nyaa/models.py                                |  38 ++++++
 nyaa/templates/upload.html                    |  12 ++
 nyaa/views/account.py                         |  34 +++--
 rangeban.py                                   | 119 ++++++++++++++++++
 7 files changed, 237 insertions(+), 13 deletions(-)
 create mode 100644 migrations/versions/f69d7fec88d6_add_rangebans.py
 create mode 100755 rangeban.py

diff --git a/migrations/versions/f69d7fec88d6_add_rangebans.py b/migrations/versions/f69d7fec88d6_add_rangebans.py
new file mode 100644
index 0000000..9011744
--- /dev/null
+++ b/migrations/versions/f69d7fec88d6_add_rangebans.py
@@ -0,0 +1,40 @@
+"""add rangebans
+
+Revision ID: f69d7fec88d6
+Revises: 6cc823948c5a
+Create Date: 2018-06-01 14:01:49.596007
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'f69d7fec88d6'
+down_revision = '6cc823948c5a'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.create_table('rangebans',
+    sa.Column('id', sa.Integer(), nullable=False),
+    sa.Column('cidr_string', sa.String(length=18), nullable=False),
+    sa.Column('masked_cidr', sa.BigInteger(), nullable=False),
+    sa.Column('mask', sa.BigInteger(), nullable=False),
+    sa.Column('enabled', sa.Boolean(), nullable=False),
+    sa.Column('temp', sa.DateTime(), nullable=True),
+    sa.PrimaryKeyConstraint('id')
+    )
+    op.create_index(op.f('ix_rangebans_mask'), 'rangebans', ['mask'], unique=False)
+    op.create_index(op.f('ix_rangebans_masked_cidr'), 'rangebans', ['masked_cidr'], unique=False)
+    # ### end Alembic commands ###
+
+
+def downgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_index(op.f('ix_rangebans_masked_cidr'), table_name='rangebans')
+    op.drop_index(op.f('ix_rangebans_mask'), table_name='rangebans')
+    op.drop_table('rangebans')
+    # ### end Alembic commands ###
diff --git a/nyaa/backend.py b/nyaa/backend.py
index 8ed7cde..fc9b5a0 100644
--- a/nyaa/backend.py
+++ b/nyaa/backend.py
@@ -158,6 +158,12 @@ def handle_torrent_upload(upload_form, uploading_user=None, fromAPI=False):
             upload_form.ratelimit.errors = ["You've gone over the upload ratelimit."]
             raise TorrentExtraValidationException()
 
+    if not uploading_user:
+        if models.RangeBan.is_rangebanned(ip_address(flask.request.remote_addr).packed):
+            upload_form.rangebanned.errors = ["Your IP is banned from "
+                                              "uploading anonymously."]
+            raise TorrentExtraValidationException()
+
     # Delete existing torrent which is marked as deleted
     if torrent_data.db_id is not None:
         old_torrent = models.Torrent.by_id(torrent_data.db_id)
diff --git a/nyaa/forms.py b/nyaa/forms.py
index 99b3883..662794b 100644
--- a/nyaa/forms.py
+++ b/nyaa/forms.py
@@ -349,6 +349,7 @@ class UploadForm(FlaskForm):
     ])
 
     ratelimit = HiddenField()
+    rangebanned = HiddenField()
 
     def validate_torrent_file(form, field):
         # Decode and ensure data is bencoded data
diff --git a/nyaa/models.py b/nyaa/models.py
index 154e548..40515fd 100644
--- a/nyaa/models.py
+++ b/nyaa/models.py
@@ -775,6 +775,44 @@ class TrackerApiBase(DeclarativeHelperBase):
         self.method = method
 
 
+class RangeBan(db.Model):
+    __tablename__ = 'rangebans'
+
+    id = db.Column(db.Integer, primary_key=True)
+    _cidr_string = db.Column('cidr_string', db.String(length=18), nullable=False)
+    masked_cidr = db.Column(db.BigInteger, nullable=False,
+                            index=True)
+    mask = db.Column(db.BigInteger, nullable=False, index=True)
+    enabled = db.Column(db.Boolean, nullable=False, default=True)
+    # If this rangeban may be automatically cleared once it becomes
+    # out of date, set this column to the creation time of the ban.
+    # None (or NULL in the db) is understood as the ban being permanent.
+    temp = db.Column(db.DateTime(timezone=False), nullable=True, default=None)
+
+    @property
+    def cidr_string(self):
+        return self._cidr_string
+
+    @cidr_string.setter
+    def cidr_string(self, s):
+        subnet, masked_bits = s.split('/')
+        subnet_b = ip_address(subnet).packed
+        self.mask = (1 << 32) - (1 << (32 - int(masked_bits)))
+        self.masked_cidr = int.from_bytes(subnet_b, 'big') & self.mask
+        self._cidr_string = s
+
+    @classmethod
+    def is_rangebanned(cls, ip):
+        if len(ip) > 4:
+            raise NotImplementedError("IPv6 is unsupported.")
+        elif len(ip) < 4:
+            raise ValueError("Not an IP address.")
+        ip_int = int.from_bytes(ip, 'big')
+        q = cls.query.filter(cls.mask.op('&')(ip_int) == cls.masked_cidr,
+                             cls.enabled)
+        return q.count() > 0
+
+
 # Actually declare our site-specific classes
 
 # Torrent
diff --git a/nyaa/templates/upload.html b/nyaa/templates/upload.html
index 4a8595f..1ca14a7 100644
--- a/nyaa/templates/upload.html
+++ b/nyaa/templates/upload.html
@@ -37,6 +37,18 @@
 	</div>
 	{% endif %}
 
+	{% if upload_form.rangebanned.errors %}
+	<div class="row">
+		<div class="col-md-12">
+			<div class="alert alert-danger" role="alert">
+				{% for error in upload_form.rangebanned.errors %}
+				<p>{{ error }}</p>
+				{% endfor %}
+			</div>
+		</div>
+	</div>
+	{% endif %}
+
 	<div class="row">
 		<div class="col-md-12">
 		{{ render_upload(upload_form.torrent_file, accept=".torrent") }}
diff --git a/nyaa/views/account.py b/nyaa/views/account.py
index ca6b5a4..a530956 100644
--- a/nyaa/views/account.py
+++ b/nyaa/views/account.py
@@ -89,19 +89,27 @@ def register():
         user.last_login_ip = ip_address(flask.request.remote_addr).packed
         db.session.add(user)
         db.session.commit()
-
-        if app.config['USE_EMAIL_VERIFICATION']:  # force verification, enable email
-            send_verification_email(user)
-            return flask.render_template('waiting.html')
-        else:  # disable verification, set user as active and auto log in
-            user.status = models.UserStatusType.ACTIVE
-            db.session.add(user)
-            db.session.commit()
-            flask.g.user = user
-            flask.session['user_id'] = user.id
-            flask.session.permanent = True
-            flask.session.modified = True
-            return flask.redirect(redirect_url())
+        if models.RangeBan.is_rangebanned(user.last_login_ip):
+            flask.flash(flask.Markup('Your IP is blocked from creating new accounts. '
+                                     'Please <a href="{}">ask a moderator</a> to manually '
+                                     'activate your account <a href="{}">\'{}\'</a>.'
+                                     .format(flask.url_for('site.help') + '#irchelp',
+                                             flask.url_for('users.view_user',
+                                                           user_name=user.username),
+                                             user.username)), 'warning')
+        else:
+            if app.config['USE_EMAIL_VERIFICATION']:  # force verification, enable email
+                send_verification_email(user)
+                return flask.render_template('waiting.html')
+            else:  # disable verification, set user as active and auto log in
+                user.status = models.UserStatusType.ACTIVE
+                db.session.add(user)
+                db.session.commit()
+                flask.g.user = user
+                flask.session['user_id'] = user.id
+                flask.session.permanent = True
+                flask.session.modified = True
+                return flask.redirect(redirect_url())
 
     return flask.render_template('register.html', form=form)
 
diff --git a/rangeban.py b/rangeban.py
new file mode 100755
index 0000000..81b37be
--- /dev/null
+++ b/rangeban.py
@@ -0,0 +1,119 @@
+#!/usr/bin/env python3
+
+from datetime import datetime
+from ipaddress import ip_address
+import sys
+
+import click
+
+from nyaa import create_app, models
+from nyaa.extensions import db
+
+
+def is_cidr_valid(c):
+    '''Checks whether a CIDR range string is valid.'''
+    try:
+        subnet, mask = c.split('/')
+    except ValueError:
+        return False
+    if int(mask) < 1 or int(mask) > 32:
+        return False
+    try:
+        ip = ip_address(subnet)
+    except ValueError:
+        return False
+    return True
+
+
+def check_str(b):
+    '''Returns a checkmark or cross depending on the condition.'''
+    return '\u2713' if b else '\u2717'
+
+
+@click.group()
+def rangeban():
+    global app
+    app = create_app('config')
+
+
+@rangeban.command()
+@click.option('--temp/--no-temp', help='Mark this entry as one that may be '
+              'cleaned out occasionally.', default=False)
+@click.argument('cidrrange')
+def ban(temp, cidrrange):
+    if not is_cidr_valid(cidrrange):
+        click.secho('{} is not of the format xxx.xxx.xxx.xxx/xx.'
+                    .format(cidrrange), err=True, fg='red')
+        sys.exit(1)
+    with app.app_context():
+        ban = models.RangeBan(cidr_string=cidrrange, temp=datetime.utcnow() if temp else None)
+        db.session.add(ban)
+        db.session.commit()
+        click.echo('Added {} for {}.'.format('temp ban' if temp else 'ban',
+                                             cidrrange))
+
+
+@rangeban.command()
+@click.argument('cidrrange')
+def unban(cidrrange):
+    if not is_cidr_valid(cidrrange):
+        click.secho('{} is not of the format xxx.xxx.xxx.xxx/xx.'
+                    .format(cidrrange), err=True, fg='red')
+        sys.exit(1)
+    with app.app_context():
+        # Dunno why this wants _cidr_string and not cidr_string, probably
+        # due to this all being a janky piece of shit.
+        bans = models.RangeBan.query.filter(
+            models.RangeBan._cidr_string == cidrrange).all()
+        if len(bans) == 0:
+            click.echo('Ban not found.')
+        for b in bans:
+            click.echo('Unbanned {}'.format(b.cidr_string))
+            db.session.delete(b)
+        db.session.commit()
+
+
+@rangeban.command()
+def list():
+    with app.app_context():
+        bans = models.RangeBan.query.all()
+        if len(bans) == 0:
+            click.echo('No bans.')
+        else:
+            click.secho('ID     CIDR Range         Enabled Temp', bold=True)
+            for b in bans:
+                click.echo('{0: <6} {1: <18} {2: <7} {3: <4}'
+                           .format(b.id, b.cidr_string,
+                                   check_str(b.enabled),
+                                   check_str(b.temp is not None)))
+
+@rangeban.command()
+@click.argument('banid', type=int)
+@click.argument('status')
+def enabled(banid, status):
+    yeses = ['true', '1', 'yes', '\u2713']
+    noses = ['false', '0', 'no', '\u2717']
+    if status.lower() in yeses:
+        set_to = True
+    elif status.lower() in noses:
+        set_to = False
+    else:
+        click.secho('Please choose one of {} or {}.'
+                    .format(yeses, noses), err=True, fg='red')
+        sys.exit(1)
+    with app.app_context():
+        ban = models.RangeBan.query.get(banid)
+        if not ban:
+            click.secho('No ban with id {} found.'
+                        .format(banid), err=True, fg='red')
+            sys.exit(1)
+        ban.enabled = set_to
+        db.session.add(ban)
+        db.session.commit()
+        click.echo('{} ban {} on {}.'.format('Enabled' if set_to else 'Disabled',
+                                             banid, ban._cidr_string))
+
+
+
+if __name__ == '__main__':
+    rangeban()