Merge branch 'fix/spam-guard'

This commit is contained in:
Martin Zimmermann 2013-11-13 21:30:23 +01:00
commit 0be3c69e1d
10 changed files with 213 additions and 99 deletions

View File

@ -186,14 +186,25 @@ for IPv4, ``/48`` for IPv6).
[guard] [guard]
enabled = true enabled = true
ratelimit = 2 ratelimit = 2
direct-reply = 3
reply-to-self = false
enabled enabled
enable guard, recommended in production. Not useful for debugging enable guard, recommended in production. Not useful for debugging
purposes. purposes.
ratelimit: N ratelimit
limit to N new comments per minute. limit to N new comments per minute.
direct-reply
how many comments directly to the thread (prevent a simple
`while true; do curl ...; done`.
reply-to-self
allow commenters to reply to their own comments when they could still edit
the comment. After the editing timeframe is gone, commenters can reply to
their own comments anyways.
Appendum Appendum
--------- ---------

View File

@ -119,8 +119,9 @@ class Config:
"to = ", "from = ", "to = ", "from = ",
"[guard]", "[guard]",
"enabled = true", "enabled = true",
"ratelimit = 2" "ratelimit = 2",
"" "direct-reply = 3",
"reply-to-self = false"
] ]
@classmethod @classmethod

View File

@ -5,11 +5,9 @@ import logging
logger = logging.getLogger("isso") logger = logging.getLogger("isso")
class IssoDBException(Exception):
pass
from isso.db.comments import Comments from isso.db.comments import Comments
from isso.db.threads import Threads from isso.db.threads import Threads
from isso.db.spam import Guard
class SQLite3: class SQLite3:
@ -34,6 +32,7 @@ class SQLite3:
self.threads = Threads(self) self.threads = Threads(self)
self.comments = Comments(self) self.comments = Comments(self)
self.guard = Guard(self)
self.execute([ self.execute([
'CREATE TRIGGER IF NOT EXISTS remove_stale_threads', 'CREATE TRIGGER IF NOT EXISTS remove_stale_threads',

View File

@ -33,7 +33,6 @@ class Comments:
' text VARCHAR, author VARCHAR, email VARCHAR, website VARCHAR,', ' text VARCHAR, author VARCHAR, email VARCHAR, website VARCHAR,',
' likes INTEGER DEFAULT 0, dislikes INTEGER DEFAULT 0, voters BLOB NOT NULL);']) ' likes INTEGER DEFAULT 0, dislikes INTEGER DEFAULT 0, voters BLOB NOT NULL);'])
@spam.check
def add(self, uri, c): def add(self, uri, c):
""" """
Add a new comment to the database and return public fields as dict. Add a new comment to the database and return public fields as dict.

View File

@ -1,47 +1,68 @@
# -*- encoding: utf-8 -*- # -*- encoding: utf-8 -*-
import time import time
import functools
from isso.db import IssoDBException
class TooManyComments(IssoDBException): class Guard:
pass
def __init__(self, db):
def check(func): self.db = db
self.conf = db.conf.section("guard")
self.max_age = db.conf.getint("general", "max-age")
def dec(self, uri, c): def validate(self, uri, comment):
if not self.db.conf.getboolean("guard", "enabled"): if not self.conf.getboolean("enabled"):
return func(self, uri, c) return True, ""
# block more than two comments per minute for func in (self._limit, self._spam):
valid, reason = func(uri, comment)
if not valid:
return False, reason
return True, ""
@classmethod
def ids(cls, rv):
return [str(col[0]) for col in rv]
def _limit(self, uri, comment):
# block more than :param:`ratelimit` comments per minute
rv = self.db.execute([ rv = self.db.execute([
'SELECT id FROM comments WHERE remote_addr = ? AND created > ?;' 'SELECT id FROM comments WHERE remote_addr = ? AND ? - created < 60;'
], (c["remote_addr"], time.time() + 60)).fetchall() ], (comment["remote_addr"], time.time())).fetchall()
if len(rv) >= self.db.conf.getint("guard", "ratelimit"): if len(rv) >= self.conf.getint("ratelimit"):
raise TooManyComments return False, "{0}: ratelimit exceeded ({1})".format(
comment["remote_addr"], ', '.join(Guard.ids(rv)))
# block more than three comments as direct response to the post # block more than three comments as direct response to the post
rv = self.db.execute([ if comment["parent"] is None:
'SELECT id FROM comments WHERE remote_addr = ? AND parent IS NULL;'
], (c["remote_addr"], )).fetchall()
if len(rv) >= 3:
raise TooManyComments
# block reply to own comment if the cookie is still available (max age)
if "parent" in c:
rv = self.db.execute([ rv = self.db.execute([
'SELECT id FROM comments WHERE remote_addr = ? AND id = ? AND ? - created < ?;' 'SELECT id FROM comments WHERE',
], (c["remote_addr"], c["parent"], time.time(), ' tid = (SELECT id FROM threads WHERE uri = ?)',
self.db.conf.getint("general", "max-age"))).fetchall() 'AND remote_addr = ?',
'AND parent IS NULL;'
], (uri, comment["remote_addr"])).fetchall()
if len(rv) >= self.conf.getint("direct-reply"):
return False, "%i direct responses to %s" % (len(rv), uri)
elif self.conf.getboolean("reply-to-self") == False:
rv = self.db.execute([
'SELECT id FROM comments WHERE'
' remote_addr = ?',
'AND id = ?',
'AND ? - created < ?'
], (comment["remote_addr"], comment["parent"],
time.time(), self.max_age)).fetchall()
if len(rv) > 0: if len(rv) > 0:
raise TooManyComments return False, "edit time frame is still open"
return func(self, uri, c) return True, ""
return dec def _spam(self, uri, comment):
return True, ""

View File

@ -15,7 +15,7 @@ from werkzeug.exceptions import BadRequest, Forbidden, NotFound
from isso.compat import text_type as str from isso.compat import text_type as str
from isso import utils, local from isso import utils, local, db
from isso.utils import http, parse, markdown from isso.utils import http, parse, markdown
from isso.utils.crypto import pbkdf2 from isso.utils.crypto import pbkdf2
from isso.views import requires from isso.views import requires
@ -61,6 +61,7 @@ class API(object):
self.conf = isso.conf.section("general") self.conf = isso.conf.section("general")
self.moderated = isso.conf.getboolean("moderation", "enabled") self.moderated = isso.conf.getboolean("moderation", "enabled")
self.guard = isso.db.guard
self.threads = isso.db.threads self.threads = isso.db.threads
self.comments = isso.db.comments self.comments = isso.db.comments
@ -127,8 +128,10 @@ class API(object):
# notify extensions that the new comment is about to save # notify extensions that the new comment is about to save
self.signal("comments.new:before-save", thread, data) self.signal("comments.new:before-save", thread, data)
if data is None: valid, reason = self.guard.validate(uri, data)
raise Forbidden if not valid:
self.signal("comments.new:guard", reason)
raise Forbidden(reason)
with self.isso.lock: with self.isso.lock:
rv = self.comments.add(uri, data) rv = self.comments.add(uri, data)

31
specs/fixtures.py Normal file
View File

@ -0,0 +1,31 @@
# -*- encoding: utf-8 -*-
import json
class FakeIP(object):
def __init__(self, app, ip):
self.app = app
self.ip = ip
def __call__(self, environ, start_response):
environ['REMOTE_ADDR'] = self.ip
return self.app(environ, start_response)
class Dummy:
status = 200
def __enter__(self):
return self
def read(self):
return ''
def __exit__(self, exc_type, exc_val, exc_tb):
pass
curl = lambda method, host, path: Dummy()
loads = lambda data: json.loads(data.decode('utf-8'))

View File

@ -1,6 +1,5 @@
# -*- encoding: utf-8 -*- # -*- encoding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
import os import os
@ -20,32 +19,8 @@ from isso import Isso, core
from isso.utils import http from isso.utils import http
from isso.views import comments from isso.views import comments
class Dummy: from fixtures import curl, loads, FakeIP
http.curl = curl
status = 200
def __enter__(self):
return self
def read(self):
return ''
def __exit__(self, exc_type, exc_val, exc_tb):
pass
http.curl = lambda method, host, path: Dummy()
loads = lambda data: json.loads(data.decode('utf-8'))
class FakeIP(object):
def __init__(self, app):
self.app = app
def __call__(self, environ, start_response):
environ['REMOTE_ADDR'] = '192.168.1.1'
return self.app(environ, start_response)
class TestComments(unittest.TestCase): class TestComments(unittest.TestCase):
@ -60,7 +35,7 @@ class TestComments(unittest.TestCase):
pass pass
self.app = App(conf) self.app = App(conf)
self.app.wsgi_app = FakeIP(self.app.wsgi_app) self.app.wsgi_app = FakeIP(self.app.wsgi_app, "192.168.1.1")
self.client = Client(self.app, Response) self.client = Client(self.app, Response)
self.get = self.client.get self.get = self.client.get
@ -307,7 +282,7 @@ class TestModeratedComments(unittest.TestCase):
pass pass
self.app = App(conf) self.app = App(conf)
self.app.wsgi_app = FakeIP(self.app.wsgi_app) self.app.wsgi_app = FakeIP(self.app.wsgi_app, "192.168.1.1")
self.client = Client(self.app, Response) self.client = Client(self.app, Response)
def tearDown(self): def tearDown(self):
@ -338,7 +313,7 @@ class TestPurgeComments(unittest.TestCase):
pass pass
self.app = App(conf) self.app = App(conf)
self.app.wsgi_app = FakeIP(self.app.wsgi_app) self.app.wsgi_app = FakeIP(self.app.wsgi_app, "192.168.1.1")
self.client = Client(self.app, Response) self.client = Client(self.app, Response)
def testPurgeDoesNoHarm(self): def testPurgeDoesNoHarm(self):

102
specs/test_guard.py Normal file
View File

@ -0,0 +1,102 @@
# -*- encoding: utf-8 -*-
import unittest
import os
import json
import tempfile
from werkzeug.test import Client
from werkzeug.wrappers import Response
from isso import Isso, core
from isso.utils import http
from fixtures import curl, FakeIP
http.curl = curl
class TestGuard(unittest.TestCase):
data = json.dumps({"text": "Lorem ipsum."})
def setUp(self):
self.path = tempfile.NamedTemporaryFile().name
def makeClient(self, ip, ratelimit=2, direct_reply=3, self_reply=False):
conf = core.Config.load(None)
conf.set("general", "dbpath", self.path)
conf.set("guard", "enabled", "true")
conf.set("guard", "ratelimit", str(ratelimit))
conf.set("guard", "direct-reply", str(direct_reply))
conf.set("guard", "reply-to-self", "1" if self_reply else "0")
class App(Isso, core.Mixin):
pass
app = App(conf)
app.wsgi_app = FakeIP(app.wsgi_app, ip)
return Client(app, Response)
def testRateLimit(self):
bob = self.makeClient("127.0.0.1", 2)
for i in range(2):
rv = bob.post('/new?uri=test', data=self.data)
assert rv.status_code == 201
rv = bob.post('/new?uri=test', data=self.data)
assert rv.status_code == 403
assert "ratelimit exceeded" in rv.get_data(as_text=True)
alice = self.makeClient("1.2.3.4", 2)
for i in range(2):
assert alice.post("/new?uri=test", data=self.data).status_code == 201
bob.application.db.execute([
"UPDATE comments SET",
" created = created - 60",
"WHERE remote_addr = '127.0.0.0'"
])
assert bob.post("/new?uri=test", data=self.data).status_code == 201
def testDirectReply(self):
client = self.makeClient("127.0.0.1", 15, 3)
for url in ("foo", "bar", "baz", "spam"):
for _ in range(3):
rv = client.post("/new?uri=%s" % url, data=self.data)
assert rv.status_code == 201
for url in ("foo", "bar", "baz", "spam"):
rv = client.post("/new?uri=%s" % url, data=self.data)
assert rv.status_code == 403
assert "direct responses to" in rv.get_data(as_text=True)
def testSelfReply(self):
payload = lambda id: json.dumps({"text": "...", "parent": id})
client = self.makeClient("127.0.0.1", self_reply=False)
assert client.post("/new?uri=test", data=self.data).status_code == 201
assert client.post("/new?uri=test", data=payload(1)).status_code == 403
client.application.db.execute([
"UPDATE comments SET",
" created = created - ?",
"WHERE id = 1"
], (client.application.conf.getint("general", "max-age"), ))
assert client.post("/new?uri=test", data=payload(1)).status_code == 201
client = self.makeClient("128.0.0.1", ratelimit=3, self_reply=False)
assert client.post("/new?uri=test", data=self.data).status_code == 201
assert client.post("/new?uri=test", data=payload(1)).status_code == 201
assert client.post("/new?uri=test", data=payload(2)).status_code == 201

View File

@ -12,42 +12,14 @@ from werkzeug.wrappers import Response
from isso import Isso, core from isso import Isso, core
from isso.utils import http from isso.utils import http
class Dummy: from fixtures import curl, loads, FakeIP
http.curl = curl
status = 200
def __enter__(self):
return self
def read(self):
return ''
def __exit__(self, exc_type, exc_val, exc_tb):
pass
http.curl = lambda method, host, path: Dummy()
loads = lambda data: json.loads(data.decode('utf-8'))
class FakeIP(object):
def __init__(self, app, ip):
self.app = app
self.ip = ip
def __call__(self, environ, start_response):
environ['REMOTE_ADDR'] = self.ip
return self.app(environ, start_response)
class TestVote(unittest.TestCase): class TestVote(unittest.TestCase):
def setUp(self): def setUp(self):
fd, self.path = tempfile.mkstemp() self.path = tempfile.NamedTemporaryFile().name
def tearDown(self):
os.unlink(self.path)
def makeClient(self, ip): def makeClient(self, ip):