Skip to content

Commit a38e5d5

Browse files
CounterPillowArylide
authored andcommitted
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
1 parent f04e0fd commit a38e5d5

File tree

7 files changed

+237
-13
lines changed

7 files changed

+237
-13
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""add rangebans
2+
3+
Revision ID: f69d7fec88d6
4+
Revises: 6cc823948c5a
5+
Create Date: 2018-06-01 14:01:49.596007
6+
7+
"""
8+
from alembic import op
9+
import sqlalchemy as sa
10+
11+
12+
# revision identifiers, used by Alembic.
13+
revision = 'f69d7fec88d6'
14+
down_revision = '6cc823948c5a'
15+
branch_labels = None
16+
depends_on = None
17+
18+
19+
def upgrade():
20+
# ### commands auto generated by Alembic - please adjust! ###
21+
op.create_table('rangebans',
22+
sa.Column('id', sa.Integer(), nullable=False),
23+
sa.Column('cidr_string', sa.String(length=18), nullable=False),
24+
sa.Column('masked_cidr', sa.BigInteger(), nullable=False),
25+
sa.Column('mask', sa.BigInteger(), nullable=False),
26+
sa.Column('enabled', sa.Boolean(), nullable=False),
27+
sa.Column('temp', sa.DateTime(), nullable=True),
28+
sa.PrimaryKeyConstraint('id')
29+
)
30+
op.create_index(op.f('ix_rangebans_mask'), 'rangebans', ['mask'], unique=False)
31+
op.create_index(op.f('ix_rangebans_masked_cidr'), 'rangebans', ['masked_cidr'], unique=False)
32+
# ### end Alembic commands ###
33+
34+
35+
def downgrade():
36+
# ### commands auto generated by Alembic - please adjust! ###
37+
op.drop_index(op.f('ix_rangebans_masked_cidr'), table_name='rangebans')
38+
op.drop_index(op.f('ix_rangebans_mask'), table_name='rangebans')
39+
op.drop_table('rangebans')
40+
# ### end Alembic commands ###

nyaa/backend.py

+6
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,12 @@ def handle_torrent_upload(upload_form, uploading_user=None, fromAPI=False):
158158
upload_form.ratelimit.errors = ["You've gone over the upload ratelimit."]
159159
raise TorrentExtraValidationException()
160160

161+
if not uploading_user:
162+
if models.RangeBan.is_rangebanned(ip_address(flask.request.remote_addr).packed):
163+
upload_form.rangebanned.errors = ["Your IP is banned from "
164+
"uploading anonymously."]
165+
raise TorrentExtraValidationException()
166+
161167
# Delete existing torrent which is marked as deleted
162168
if torrent_data.db_id is not None:
163169
old_torrent = models.Torrent.by_id(torrent_data.db_id)

nyaa/forms.py

+1
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,7 @@ def validate_category(form, field):
349349
])
350350

351351
ratelimit = HiddenField()
352+
rangebanned = HiddenField()
352353

353354
def validate_torrent_file(form, field):
354355
# Decode and ensure data is bencoded data

nyaa/models.py

+38
Original file line numberDiff line numberDiff line change
@@ -775,6 +775,44 @@ def __init__(self, info_hash, method):
775775
self.method = method
776776

777777

778+
class RangeBan(db.Model):
779+
__tablename__ = 'rangebans'
780+
781+
id = db.Column(db.Integer, primary_key=True)
782+
_cidr_string = db.Column('cidr_string', db.String(length=18), nullable=False)
783+
masked_cidr = db.Column(db.BigInteger, nullable=False,
784+
index=True)
785+
mask = db.Column(db.BigInteger, nullable=False, index=True)
786+
enabled = db.Column(db.Boolean, nullable=False, default=True)
787+
# If this rangeban may be automatically cleared once it becomes
788+
# out of date, set this column to the creation time of the ban.
789+
# None (or NULL in the db) is understood as the ban being permanent.
790+
temp = db.Column(db.DateTime(timezone=False), nullable=True, default=None)
791+
792+
@property
793+
def cidr_string(self):
794+
return self._cidr_string
795+
796+
@cidr_string.setter
797+
def cidr_string(self, s):
798+
subnet, masked_bits = s.split('/')
799+
subnet_b = ip_address(subnet).packed
800+
self.mask = (1 << 32) - (1 << (32 - int(masked_bits)))
801+
self.masked_cidr = int.from_bytes(subnet_b, 'big') & self.mask
802+
self._cidr_string = s
803+
804+
@classmethod
805+
def is_rangebanned(cls, ip):
806+
if len(ip) > 4:
807+
raise NotImplementedError("IPv6 is unsupported.")
808+
elif len(ip) < 4:
809+
raise ValueError("Not an IP address.")
810+
ip_int = int.from_bytes(ip, 'big')
811+
q = cls.query.filter(cls.mask.op('&')(ip_int) == cls.masked_cidr,
812+
cls.enabled)
813+
return q.count() > 0
814+
815+
778816
# Actually declare our site-specific classes
779817

780818
# Torrent

nyaa/templates/upload.html

+12
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,18 @@ <h1>Upload Torrent</h1>
3737
</div>
3838
{% endif %}
3939

40+
{% if upload_form.rangebanned.errors %}
41+
<div class="row">
42+
<div class="col-md-12">
43+
<div class="alert alert-danger" role="alert">
44+
{% for error in upload_form.rangebanned.errors %}
45+
<p>{{ error }}</p>
46+
{% endfor %}
47+
</div>
48+
</div>
49+
</div>
50+
{% endif %}
51+
4052
<div class="row">
4153
<div class="col-md-12">
4254
{{ render_upload(upload_form.torrent_file, accept=".torrent") }}

nyaa/views/account.py

+21-13
Original file line numberDiff line numberDiff line change
@@ -89,19 +89,27 @@ def register():
8989
user.last_login_ip = ip_address(flask.request.remote_addr).packed
9090
db.session.add(user)
9191
db.session.commit()
92-
93-
if app.config['USE_EMAIL_VERIFICATION']: # force verification, enable email
94-
send_verification_email(user)
95-
return flask.render_template('waiting.html')
96-
else: # disable verification, set user as active and auto log in
97-
user.status = models.UserStatusType.ACTIVE
98-
db.session.add(user)
99-
db.session.commit()
100-
flask.g.user = user
101-
flask.session['user_id'] = user.id
102-
flask.session.permanent = True
103-
flask.session.modified = True
104-
return flask.redirect(redirect_url())
92+
if models.RangeBan.is_rangebanned(user.last_login_ip):
93+
flask.flash(flask.Markup('Your IP is blocked from creating new accounts. '
94+
'Please <a href="{}">ask a moderator</a> to manually '
95+
'activate your account <a href="{}">\'{}\'</a>.'
96+
.format(flask.url_for('site.help') + '#irchelp',
97+
flask.url_for('users.view_user',
98+
user_name=user.username),
99+
user.username)), 'warning')
100+
else:
101+
if app.config['USE_EMAIL_VERIFICATION']: # force verification, enable email
102+
send_verification_email(user)
103+
return flask.render_template('waiting.html')
104+
else: # disable verification, set user as active and auto log in
105+
user.status = models.UserStatusType.ACTIVE
106+
db.session.add(user)
107+
db.session.commit()
108+
flask.g.user = user
109+
flask.session['user_id'] = user.id
110+
flask.session.permanent = True
111+
flask.session.modified = True
112+
return flask.redirect(redirect_url())
105113

106114
return flask.render_template('register.html', form=form)
107115

rangeban.py

+119
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
#!/usr/bin/env python3
2+
3+
from datetime import datetime
4+
from ipaddress import ip_address
5+
import sys
6+
7+
import click
8+
9+
from nyaa import create_app, models
10+
from nyaa.extensions import db
11+
12+
13+
def is_cidr_valid(c):
14+
'''Checks whether a CIDR range string is valid.'''
15+
try:
16+
subnet, mask = c.split('/')
17+
except ValueError:
18+
return False
19+
if int(mask) < 1 or int(mask) > 32:
20+
return False
21+
try:
22+
ip = ip_address(subnet)
23+
except ValueError:
24+
return False
25+
return True
26+
27+
28+
def check_str(b):
29+
'''Returns a checkmark or cross depending on the condition.'''
30+
return '\u2713' if b else '\u2717'
31+
32+
33+
@click.group()
34+
def rangeban():
35+
global app
36+
app = create_app('config')
37+
38+
39+
@rangeban.command()
40+
@click.option('--temp/--no-temp', help='Mark this entry as one that may be '
41+
'cleaned out occasionally.', default=False)
42+
@click.argument('cidrrange')
43+
def ban(temp, cidrrange):
44+
if not is_cidr_valid(cidrrange):
45+
click.secho('{} is not of the format xxx.xxx.xxx.xxx/xx.'
46+
.format(cidrrange), err=True, fg='red')
47+
sys.exit(1)
48+
with app.app_context():
49+
ban = models.RangeBan(cidr_string=cidrrange, temp=datetime.utcnow() if temp else None)
50+
db.session.add(ban)
51+
db.session.commit()
52+
click.echo('Added {} for {}.'.format('temp ban' if temp else 'ban',
53+
cidrrange))
54+
55+
56+
@rangeban.command()
57+
@click.argument('cidrrange')
58+
def unban(cidrrange):
59+
if not is_cidr_valid(cidrrange):
60+
click.secho('{} is not of the format xxx.xxx.xxx.xxx/xx.'
61+
.format(cidrrange), err=True, fg='red')
62+
sys.exit(1)
63+
with app.app_context():
64+
# Dunno why this wants _cidr_string and not cidr_string, probably
65+
# due to this all being a janky piece of shit.
66+
bans = models.RangeBan.query.filter(
67+
models.RangeBan._cidr_string == cidrrange).all()
68+
if len(bans) == 0:
69+
click.echo('Ban not found.')
70+
for b in bans:
71+
click.echo('Unbanned {}'.format(b.cidr_string))
72+
db.session.delete(b)
73+
db.session.commit()
74+
75+
76+
@rangeban.command()
77+
def list():
78+
with app.app_context():
79+
bans = models.RangeBan.query.all()
80+
if len(bans) == 0:
81+
click.echo('No bans.')
82+
else:
83+
click.secho('ID CIDR Range Enabled Temp', bold=True)
84+
for b in bans:
85+
click.echo('{0: <6} {1: <18} {2: <7} {3: <4}'
86+
.format(b.id, b.cidr_string,
87+
check_str(b.enabled),
88+
check_str(b.temp is not None)))
89+
90+
@rangeban.command()
91+
@click.argument('banid', type=int)
92+
@click.argument('status')
93+
def enabled(banid, status):
94+
yeses = ['true', '1', 'yes', '\u2713']
95+
noses = ['false', '0', 'no', '\u2717']
96+
if status.lower() in yeses:
97+
set_to = True
98+
elif status.lower() in noses:
99+
set_to = False
100+
else:
101+
click.secho('Please choose one of {} or {}.'
102+
.format(yeses, noses), err=True, fg='red')
103+
sys.exit(1)
104+
with app.app_context():
105+
ban = models.RangeBan.query.get(banid)
106+
if not ban:
107+
click.secho('No ban with id {} found.'
108+
.format(banid), err=True, fg='red')
109+
sys.exit(1)
110+
ban.enabled = set_to
111+
db.session.add(ban)
112+
db.session.commit()
113+
click.echo('{} ban {} on {}.'.format('Enabled' if set_to else 'Disabled',
114+
banid, ban._cidr_string))
115+
116+
117+
118+
if __name__ == '__main__':
119+
rangeban()

0 commit comments

Comments
 (0)