From 9f2062a9006095ae330fac15e1c0c553e9398be0 Mon Sep 17 00:00:00 2001 From: Martin Zimmermann Date: Wed, 13 Nov 2013 16:10:17 +0100 Subject: [PATCH] fix #35 Also add an option `direct-reply` to control the number of comments on a thread without referencing a child (to avoid a simple while loop that `curl -XPOST ...` the url). Defaults to 3, that means a /24 (or /48 for IPv6) address can only post 3 direct responses on a thread at all. --- docs/CONFIGURATION.rst | 7 ++++- isso/core.py | 4 +-- isso/db/__init__.py | 5 ++-- isso/db/comments.py | 1 - isso/db/spam.py | 66 +++++++++++++++++++++++------------------- isso/views/comments.py | 9 ++++-- 6 files changed, 53 insertions(+), 39 deletions(-) diff --git a/docs/CONFIGURATION.rst b/docs/CONFIGURATION.rst index dfe518d..a169a3e 100644 --- a/docs/CONFIGURATION.rst +++ b/docs/CONFIGURATION.rst @@ -186,14 +186,19 @@ for IPv4, ``/48`` for IPv6). [guard] enabled = true ratelimit = 2 + direct-reply = 3 enabled enable guard, recommended in production. Not useful for debugging purposes. -ratelimit: N +ratelimit limit to N new comments per minute. +direct-reply + how many comments directly to the thread (prevent a simple + `while true; do curl ...; done`. + Appendum --------- diff --git a/isso/core.py b/isso/core.py index efa59d8..d7ec371 100644 --- a/isso/core.py +++ b/isso/core.py @@ -119,8 +119,8 @@ class Config: "to = ", "from = ", "[guard]", "enabled = true", - "ratelimit = 2" - "" + "ratelimit = 2", + "direct-reply = 3" ] @classmethod diff --git a/isso/db/__init__.py b/isso/db/__init__.py index 56e8f4f..7b2e25b 100644 --- a/isso/db/__init__.py +++ b/isso/db/__init__.py @@ -2,11 +2,9 @@ import sqlite3 -class IssoDBException(Exception): - pass - from isso.db.comments import Comments from isso.db.threads import Threads +from isso.db.spam import Guard class SQLite3: @@ -19,6 +17,7 @@ class SQLite3: self.threads = Threads(self) self.comments = Comments(self) + self.guard = Guard(self) self.execute([ 'CREATE TRIGGER IF NOT EXISTS remove_stale_threads', diff --git a/isso/db/comments.py b/isso/db/comments.py index 2a549ba..3bf37c1 100644 --- a/isso/db/comments.py +++ b/isso/db/comments.py @@ -33,7 +33,6 @@ class Comments: ' text VARCHAR, author VARCHAR, email VARCHAR, website VARCHAR,', ' likes INTEGER DEFAULT 0, dislikes INTEGER DEFAULT 0, voters BLOB NOT NULL);']) - @spam.check def add(self, uri, c): """ Add a new comment to the database and return public fields as dict. diff --git a/isso/db/spam.py b/isso/db/spam.py index 1b840f2..bed2014 100644 --- a/isso/db/spam.py +++ b/isso/db/spam.py @@ -1,47 +1,55 @@ # -*- encoding: utf-8 -*- import time - -from isso.db import IssoDBException +import functools -class TooManyComments(IssoDBException): - pass +class Guard: + def __init__(self, db): -def check(func): + self.db = db + self.conf = db.conf.section("guard") - def dec(self, uri, c): + def validate(self, uri, comment): - if not self.db.conf.getboolean("guard", "enabled"): - return func(self, uri, c) + if not self.conf.getboolean("enabled"): + 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([ - 'SELECT id FROM comments WHERE remote_addr = ? AND created > ?;' - ], (c["remote_addr"], time.time() + 60)).fetchall() + 'SELECT id FROM comments WHERE remote_addr = ? AND ? - created < 60;' + ], (comment["remote_addr"], time.time())).fetchall() - if len(rv) >= self.db.conf.getint("guard", "ratelimit"): - raise TooManyComments + if len(rv) >= self.conf.getint("ratelimit"): + 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 - rv = self.db.execute([ - '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: + if comment["parent"] is None: rv = self.db.execute([ - 'SELECT id FROM comments WHERE remote_addr = ? AND id = ? AND ? - created < ?;' - ], (c["remote_addr"], c["parent"], time.time(), - self.db.conf.getint("general", "max-age"))).fetchall() + 'SELECT id FROM comments WHERE', + ' tid = (SELECT id FROM threads WHERE uri = ?)', + 'AND remote_addr = ?', + 'AND parent IS NULL;' + ], (uri, comment["remote_addr"])).fetchall() - if len(rv) > 0: - raise TooManyComments + if len(rv) >= self.conf.getint("direct-reply"): + return False, "%i direct responses to %s" % (len(rv), uri) - return func(self, uri, c) + return True, "" - return dec + def _spam(self, uri, comment): + return True, "" diff --git a/isso/views/comments.py b/isso/views/comments.py index 4af9c8e..fbe29c0 100644 --- a/isso/views/comments.py +++ b/isso/views/comments.py @@ -15,7 +15,7 @@ from werkzeug.exceptions import BadRequest, Forbidden, NotFound 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.crypto import pbkdf2 from isso.views import requires @@ -61,6 +61,7 @@ class API(object): self.conf = isso.conf.section("general") self.moderated = isso.conf.getboolean("moderation", "enabled") + self.guard = isso.db.guard self.threads = isso.db.threads self.comments = isso.db.comments @@ -127,8 +128,10 @@ class API(object): # notify extensions that the new comment is about to save self.signal("comments.new:before-save", thread, data) - if data is None: - raise Forbidden + valid, reason = self.guard.validate(uri, data) + if not valid: + self.signal("comments.new:guard", reason) + raise Forbidden(reason) with self.isso.lock: rv = self.comments.add(uri, data)