Browse Source

Better and faster basic flood prevention, while merging it into $config['filters'].

pull/40/head
Michael Foster 11 years ago
parent
commit
f309e4037c
  1. 177
      inc/config.php
  2. 109
      inc/filters.php
  3. 58
      inc/functions.php
  4. 17
      install.php
  5. 21
      install.sql
  6. 23
      post.php

177
inc/config.php

@ -168,13 +168,6 @@
* ====================
*/
// Minimum time between between each post by the same IP address.
$config['flood_time'] = 10;
// Minimum time between between each post with the exact same content AND same IP address.
$config['flood_time_ip'] = 120;
// Same as above but by a different IP address. (Same content, not necessarily same IP address.)
$config['flood_time_same'] = 30;
/*
* To further prevent spam and abuse, you can use DNS blacklists (DNSBL). A DNSBL is a list of IP
* addresses published through the Internet Domain Name Service (DNS) either as a zone file that can be
@ -283,6 +276,118 @@
$config['recaptcha_public'] = '6LcXTcUSAAAAAKBxyFWIt2SO8jwx4W7wcSMRoN3f';
$config['recaptcha_private'] = '6LcXTcUSAAAAAOGVbVdhmEM1_SyRF4xTKe8jbzf_';
/*
* Custom filters detect certain posts and reject/ban accordingly. They are made up of a condition and an
* action (for when ALL conditions are met). As every single post has to be put through each filter,
* having hundreds probably isn't ideal as it could slow things down.
*
* By default, the custom filters array is populated with basic flood prevention conditions. This
* includes forcing users to wait at least 5 seconds between posts. To disable (or amend) these flood
* prevention settings, you will need to empty the $config['filters'] array first. You can do so by
* adding "$config['filters'] = array();" to inc/instance-config.php. Basic flood prevention used to be
* controlled solely by config variables such as $config['flood_time'] and $config['flood_time_ip'], and
* it still is, as long as you leave the relevant $config['filters'] intact. These old config variables
* still exist for backwards-compatability and general convenience.
*
* Read more: http://tinyboard.org/docs/index.php?p=Config/Filters
*/
// Minimum time between between each post by the same IP address.
$config['flood_time'] = 10;
// Minimum time between between each post with the exact same content AND same IP address.
$config['flood_time_ip'] = 120;
// Same as above but by a different IP address. (Same content, not necessarily same IP address.)
$config['flood_time_same'] = 30;
// Minimum time between posts by the same IP address (all boards).
$config['filters'][] = array(
'condition' => array(
'flood-match' => array('ip'), // Only match IP address
'flood-time' => &$config['flood_time'] // 10 seconds minimum
),
'action' => 'reject',
'message' => &$config['error']['flood']
);
// Minimum time between posts by the same IP address with the same text.
$config['filters'][] = array(
'condition' => array(
'flood-match' => array('ip', 'body'), // Match IP address and post body
'flood-time' => &$config['flood_time_ip'] // 2 minutes minimum
),
'action' => 'reject',
'message' => &$config['error']['flood']
);
// Minimum time between posts with the same text. (Same content, but not always the same IP address.)
$config['filters'][] = array(
'condition' => array(
'flood-match' => array('body'), // Match IP address and post body
'flood-time' => &$config['flood_time_same'] // 30 seconds minimum
),
'action' => 'reject',
'message' => &$config['error']['flood']
);
// Example: Minimum time between posts with the same file hash.
// $config['filters'][] = array(
// 'condition' => array(
// 'flood-match' => array('file'), // Match file hash
// 'flood-time' => 60 * 2 // 2 minutes minimum
// ),
// 'action' => 'reject',
// 'message' => &$config['error']['flood']
// );
// An example of blocking an imaginary known spammer, who keeps posting a reply with the name "surgeon",
// ending his posts with "regards, the surgeon" or similar.
// $config['filters'][] = array(
// 'condition' => array(
// 'name' => '/^surgeon$/',
// 'body' => '/regards,\s+(the )?surgeon$/i',
// 'OP' => false
// ),
// 'action' => 'reject',
// 'message' => 'Go away, spammer.'
// );
// Same as above, but issuing a 3-hour ban instead of just reject the post.
// $config['filters'][] = array(
// 'condition' => array(
// 'name' => '/^surgeon$/',
// 'body' => '/regards,\s+(the )?surgeon$/i',
// 'OP' => false
// ),
// 'action' => 'ban',
// 'expires' => 60 * 60 * 3, // 3 hours
// 'reason' => 'Go away, spammer.'
// );
// PHP 5.3+ (anonymous functions)
// There is also a "custom" condition, making the possibilities of this feature pretty much endless.
// This is a bad example, because there is already a "name" condition built-in.
// $config['filters'][] = array(
// 'condition' => array(
// 'body' => '/h$/i',
// 'OP' => false,
// 'custom' => function($post) {
// if($post['name'] == 'Anonymous')
// return true;
// else
// return false;
// }
// ),
// 'action' => 'reject'
// );
// Filter flood prevention conditions ("flood-match") depend on a table which contains a cache of recent
// posts across all boards. This table is automatically purged of older posts, determining the maximum
// "age" by looking at each filter. However, when determining the maximum age, Tinyboard does not look
// outside the current board. This means that if you have a special flood condition for a specific board
// (contained in a board configuration file) which has a flood-time greater than any of those in the
// global configuration, you need to set the following variable to the maximum flood-time condition value.
// $config['flood_cache'] = 60 * 60 * 24; // 24 hours
/*
* ====================
* Post settings
@ -401,57 +506,6 @@
// Require users to see the ban page at least once for a ban even if it has since expired.
$config['require_ban_view'] = true;
/*
* Custom filters detect certain posts and reject/ban accordingly. They are made up of a
* condition and an action (for when ALL conditions are met). As every single post has to
* be put through each filter, having hundreds probably isn’t ideal as it could slow things down.
*
* Read more: http://tinyboard.org/docs/index.php?p=Config/Filters
*
* This used to be named $config['flood_filters'] (still exists as an alias).
*/
// An example of blocking an imaginary known spammer, who keeps posting a reply with the name "surgeon",
// ending his posts with "regards, the surgeon" or similar.
// $config['filters'][] = array(
// 'condition' => array(
// 'name' => '/^surgeon$/',
// 'body' => '/regards,\s+(the )?surgeon$/i',
// 'OP' => false
// ),
// 'action' => 'reject',
// 'message' => 'Go away, spammer.'
// );
// Same as above, but issuing a 3-hour ban instead of just reject the post.
// $config['filters'][] = array(
// 'condition' => array(
// 'name' => '/^surgeon$/',
// 'body' => '/regards,\s+(the )?surgeon$/i',
// 'OP' => false
// ),
// 'action' => 'ban',
// 'expires' => 60 * 60 * 3, // 3 hours
// 'reason' => 'Go away, spammer.'
// );
// PHP 5.3+ (anonymous functions)
// There is also a "custom" condition, making the possibilities of this feature pretty much endless.
// This is a bad example, because there is already a "name" condition built-in.
// $config['filters'][] = array(
// 'condition' => array(
// 'body' => '/h$/i',
// 'OP' => false,
// 'custom' => function($post) {
// if($post['name'] == 'Anonymous')
// return true;
// else
// return false;
// }
// ),
// 'action' => 'reject'
// );
/*
* ====================
* Markup settings
@ -593,10 +647,6 @@
// that as a thumbnail instead of resizing/redrawing.
$config['minimum_copy_resize'] = false;
// Image hashing function. There's really no reason to change this.
// sha1_file, md5_file, etc. You can also define your own similar function.
$config['file_hash'] = 'sha1_file';
// Maximum image upload size in bytes.
$config['max_filesize'] = 10 * 1024 * 1024; // 10MB
// Maximum image dimensions.
@ -1156,7 +1206,8 @@
// Post bypass unoriginal content check on robot-enabled boards
$config['mod']['postunoriginal'] = ADMIN;
// Bypass flood check
$config['mod']['flood'] = ADMIN;
$config['mod']['bypass_filters'] = ADMIN;
$config['mod']['flood'] = &$config['mod']['bypass_filters'];
// Raw HTML posting
$config['mod']['rawhtml'] = ADMIN;

109
inc/filters.php

@ -7,6 +7,7 @@
defined('TINYBOARD') or exit;
class Filter {
public $flood_check;
private $condition;
public function __construct(array $arr) {
@ -22,6 +23,56 @@ class Filter {
if (!is_callable($match))
error('Custom condition for filter is not callable!');
return $match($post);
case 'flood-match':
if (!is_array($match))
error('Filter condition "flood-match" must be an array.');
// Filter out "flood" table entries which do not match this filter.
$flood_check_matched = array();
foreach ($this->flood_check as $flood_post) {
foreach ($match as $flood_match_arg) {
switch ($flood_match_arg) {
case 'ip':
if ($flood_post['ip'] != $_SERVER['REMOTE_ADDR'])
continue 3;
break;
case 'body':
if ($flood_post['posthash'] != md5($post['body_nomarkup']))
continue 3;
break;
case 'file':
if (!isset($post['filehash']))
return false;
if ($flood_post['filehash'] != $post['filehash'])
continue 3;
break;
case 'board':
if ($flood_post['board'] != $post['board'])
continue 3;
break;
case 'isreply':
if ($flood_post['isreply'] == $post['op'])
continue 3;
break;
default:
error('Invalid filter flood condition: ' . $flood_match_arg);
}
}
$flood_check_matched[] = $flood_post;
}
$this->flood_check = $flood_check_matched;
return !empty($this->flood_check);
case 'flood-time':
foreach ($this->flood_check as $flood_post) {
if (time() - $flood_post['time'] <= $match) {
return true;
}
}
return false;
case 'name':
return preg_match($match, $post['name']);
case 'trip':
@ -31,7 +82,7 @@ class Filter {
case 'subject':
return preg_match($match, $post['subject']);
case 'body':
return preg_match($match, $post['body']);
return preg_match($match, $post['body_nomarkup']);
case 'filename':
if (!$post['has_file'])
return false;
@ -126,14 +177,64 @@ class Filter {
}
}
function purge_flood_table() {
global $config;
// Determine how long we need to keep a cache of posts for flood prevention. Unfortunately, it is not
// aware of flood filters in other board configurations. You can solve this problem by settings the
// config variable $config['flood_cache'] (seconds).
if (isset($config['flood_cache'])) {
$max_time = &$config['flood_cache'];
} else {
$max_time = 0;
foreach ($config['filters'] as $filter) {
if (isset($filter['condition']['flood-time']))
$max_time = max($max_time, $filter['condition']['flood-time']);
}
}
$time = time() - $max_time;
query("DELETE FROM ``flood`` WHERE ``time`` < $time") or error(db_error());
}
function do_filters(array $post) {
global $config;
if (!isset($config['filters']))
if (!isset($config['filters']) || empty($config['filters']))
return;
foreach ($config['filters'] as $arr) {
$filter = new Filter($arr);
foreach ($config['filters'] as $filter) {
if (isset($filter['condition']['flood-match'])) {
$has_flood = true;
break;
}
}
purge_flood_table();
if (isset($has_flood)) {
if ($post['has_file']) {
$query = prepare("SELECT * FROM ``flood`` WHERE `ip` = :ip OR `posthash` = :posthash OR `filehash` = :filehash");
$query->bindValue(':ip', $_SERVER['REMOTE_ADDR']);
$query->bindValue(':posthash', md5($post['body_nomarkup']));
$query->bindValue(':filehash', $post['filehash']);
} else {
$query = prepare("SELECT * FROM ``flood`` WHERE `ip` = :ip OR `posthash` = :posthash");
$query->bindValue(':ip', $_SERVER['REMOTE_ADDR']);
$query->bindValue(':posthash', md5($post['body_nomarkup']));
}
$query->execute() or error(db_error($query));
$flood_check = $query->fetchAll(PDO::FETCH_ASSOC);
} else {
$flood_check = false;
}
foreach ($config['filters'] as $filter_array) {
$filter = new Filter($filter_array);
$filter->flood_check = $flood_check;
if ($filter->check($post))
$filter->action();
}

58
inc/functions.php

@ -572,30 +572,6 @@ function listBoards() {
return $boards;
}
function checkFlood($post) {
global $board, $config;
$query = prepare(sprintf("SELECT COUNT(*) FROM ``posts_%s`` WHERE
(`ip` = :ip AND `time` >= :floodtime)
OR
(`ip` = :ip AND :body != '' AND `body_nomarkup` = :body AND `time` >= :floodsameiptime)
OR
(:body != '' AND `body_nomarkup` = :body AND `time` >= :floodsametime) LIMIT 1", $board['uri']));
$query->bindValue(':ip', $_SERVER['REMOTE_ADDR']);
$query->bindValue(':body', $post['body']);
$query->bindValue(':floodtime', time()-$config['flood_time'], PDO::PARAM_INT);
$query->bindValue(':floodsameiptime', time()-$config['flood_time_ip'], PDO::PARAM_INT);
$query->bindValue(':floodsametime', time()-$config['flood_time_same'], PDO::PARAM_INT);
$query->execute() or error(db_error($query));
$flood = (bool) $query->fetchColumn();
if (event('check-flood', $post))
return true;
return $flood;
}
function until($timestamp) {
$difference = $timestamp - time();
if ($difference < 60) {
@ -780,6 +756,22 @@ function threadExists($id) {
return false;
}
function insertFloodPost(array $post) {
global $board;
$query = prepare("INSERT INTO ``flood`` VALUES (NULL, :ip, :board, :time, :posthash, :filehash, :isreply)");
$query->bindValue(':ip', $_SERVER['REMOTE_ADDR']);
$query->bindValue(':board', $board['uri']);
$query->bindValue(':time', time());
$query->bindValue(':posthash', md5($post['body_nomarkup']));
if ($post['has_file'])
$query->bindValue(':filehash', $post['filehash']);
else
$query->bindValue(':filehash', null, PDO::PARAM_NULL);
$query->bindValue(':isreply', !$post['op']);
$query->execute() or error(db_error($query));
}
function post(array $post) {
global $pdo, $board;
$query = prepare(sprintf("INSERT INTO ``posts_%s`` VALUES ( NULL, :thread, :subject, :email, :name, :trip, :capcode, :body, :body_nomarkup, :time, :time, :thumb, :thumbwidth, :thumbheight, :file, :width, :height, :filesize, :filename, :filehash, :password, :ip, :sticky, :locked, 0, :embed)", $board['uri']));
@ -788,19 +780,19 @@ function post(array $post) {
if (!empty($post['subject'])) {
$query->bindValue(':subject', $post['subject']);
} else {
$query->bindValue(':subject', NULL, PDO::PARAM_NULL);
$query->bindValue(':subject', null, PDO::PARAM_NULL);
}
if (!empty($post['email'])) {
$query->bindValue(':email', $post['email']);
} else {
$query->bindValue(':email', NULL, PDO::PARAM_NULL);
$query->bindValue(':email', null, PDO::PARAM_NULL);
}
if (!empty($post['trip'])) {
$query->bindValue(':trip', $post['trip']);
} else {
$query->bindValue(':trip', NULL, PDO::PARAM_NULL);
$query->bindValue(':trip', null, PDO::PARAM_NULL);
}
$query->bindValue(':name', $post['name']);
@ -811,27 +803,27 @@ function post(array $post) {
$query->bindValue(':ip', isset($post['ip']) ? $post['ip'] : $_SERVER['REMOTE_ADDR']);
if ($post['op'] && $post['mod'] && isset($post['sticky']) && $post['sticky']) {
$query->bindValue(':sticky', 1, PDO::PARAM_INT);
$query->bindValue(':sticky', true, PDO::PARAM_INT);
} else {
$query->bindValue(':sticky', 0, PDO::PARAM_INT);
$query->bindValue(':sticky', false, PDO::PARAM_INT);
}
if ($post['op'] && $post['mod'] && isset($post['locked']) && $post['locked']) {
$query->bindValue(':locked', 1, PDO::PARAM_INT);
$query->bindValue(':locked', true, PDO::PARAM_INT);
} else {
$query->bindValue(':locked', 0, PDO::PARAM_INT);
$query->bindValue(':locked', false, PDO::PARAM_INT);
}
if ($post['mod'] && isset($post['capcode']) && $post['capcode']) {
$query->bindValue(':capcode', $post['capcode'], PDO::PARAM_INT);
} else {
$query->bindValue(':capcode', NULL, PDO::PARAM_NULL);
$query->bindValue(':capcode', null, PDO::PARAM_NULL);
}
if (!empty($post['embed'])) {
$query->bindValue(':embed', $post['embed']);
} else {
$query->bindValue(':embed', NULL, PDO::PARAM_NULL);
$query->bindValue(':embed', null, PDO::PARAM_NULL);
}
if ($post['op']) {

17
install.php

@ -1,7 +1,7 @@
<?php
// Installation/upgrade file
define('VERSION', 'v0.9.6-dev-18');
define('VERSION', 'v0.9.6-dev-19');
require 'inc/functions.php';
@ -392,6 +392,21 @@ if (file_exists($config['has_installed'])) {
query("ALTER TABLE ``ip_notes``
DROP INDEX `ip`,
ADD INDEX `ip_lookup` (`ip`, `time`)") or error(db_error());
case 'v0.9.6-dev-18':
query("CREATE TABLE IF NOT EXISTS ``flood`` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`ip` varchar(39) CHARACTER SET ascii NOT NULL,
`board` varchar(58) CHARACTER SET utf8 NOT NULL,
`time` int(11) NOT NULL,
`posthash` char(32) NOT NULL,
`filehash` char(32) DEFAULT NULL,
`isreply` tinyint(1) NOT NULL,
PRIMARY KEY (`id`),
KEY `ip` (`ip`),
KEY `posthash` (`posthash`),
KEY `filehash` (`filehash`),
KEY `time` (`time`)
) ENGINE=MyISAM DEFAULT CHARSET=ascii COLLATE=ascii_bin AUTO_INCREMENT=1 ;") or error(db_error());
case false:
// Update version number
file_write($config['has_installed'], VERSION);

21
install.sql

@ -245,6 +245,27 @@ CREATE TABLE IF NOT EXISTS `theme_settings` (
KEY `theme` (`theme`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8mb4;
-- --------------------------------------------------------
--
-- Table structure for table `flood`
--
CREATE TABLE IF NOT EXISTS `flood` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`ip` varchar(39) CHARACTER SET ascii NOT NULL,
`board` varchar(58) CHARACTER SET utf8 NOT NULL,
`time` int(11) NOT NULL,
`posthash` char(32) NOT NULL,
`filehash` char(32) DEFAULT NULL,
`isreply` tinyint(1) NOT NULL,
PRIMARY KEY (`id`),
KEY `ip` (`ip`),
KEY `posthash` (`posthash`),
KEY `filehash` (`filehash`),
KEY `time` (`time`)
) ENGINE=MyISAM DEFAULT CHARSET=ascii COLLATE=ascii_bin AUTO_INCREMENT=1 ;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;

23
post.php

@ -198,7 +198,7 @@ if (isset($_POST['delete'])) {
}
if ($post['mod'] = isset($_POST['mod']) && $_POST['mod']) {
require 'inc/mod.php';
require 'inc/mod/auth.php';
if (!$mod) {
// Liar. You're not a mod.
error($config['error']['notamod']);
@ -428,11 +428,6 @@ if (isset($_POST['delete'])) {
wordfilters($post['body']);
// Check for a flood
if (!hasPermission($config['mod']['flood'], $board['uri']) && checkFlood($post)) {
error($config['error']['flood']);
}
$post['body'] = escape_markup_modifiers($post['body']);
if ($mod && isset($post['raw']) && $post['raw']) {
@ -468,10 +463,8 @@ if (isset($_POST['delete'])) {
}
$post['tracked_cites'] = markup($post['body'], true);
require_once 'inc/filters.php';
do_filters($post);
if ($post['has_file']) {
if (!in_array($post['extension'], $config['allowed_ext']) && !in_array($post['extension'], $config['allowed_ext_files']))
@ -487,9 +480,17 @@ if (isset($_POST['delete'])) {
if (!is_readable($upload))
error($config['error']['nomove']);
$post['filehash'] = $config['file_hash']($upload);
$post['filehash'] = md5_file($upload);
$post['filesize'] = filesize($upload);
}
if (!hasPermission($config['mod']['bypass_filters'], $board['uri'])) {
require_once 'inc/filters.php';
do_filters($post);
}
if ($post['has_file']) {
if ($is_an_image && $config['ie_mime_type_detection'] !== false) {
// Check IE MIME type detection XSS exploit
$buffer = file_get_contents($upload, null, null, null, 255);
@ -679,6 +680,8 @@ if (isset($_POST['delete'])) {
$post['id'] = $id = post($post);
insertFloodPost($post);
if (isset($post['antispam_hash'])) {
incrementSpamHash($post['antispam_hash']);
}

Loading…
Cancel
Save