0) && ($_SERVER['REMOTE_ADDR'] != '127.0.0.1') ) { $max_age = $current - ($settings['max_requests_timeframe'] * 60); // max age is in minutes, so times 60 to go to seconds $max_visits = $settings['max_requests_ip']; } elseif ( ($settings['max_requests_tor'] > 0) && ($_SERVER['REMOTE_ADDR'] == '127.0.0.1') ) { $max_age = $current - ($settings['max_requests_tor_timeframe'] * 60); // max age is in minutes, so times 60 to go to seconds $max_visits = $settings['max_requests_tor']; } else { return; } $statement = $db->prepare("SELECT unix_timestamp FROM logs WHERE type = 'portal' AND ip = '$ip' AND event in ('visit') AND unix_timestamp > '$max_age'"); $result = $statement->execute(); $visits = 0; while ($row = $result->fetchArray(SQLITE3_NUM)) { $visits++; } if ( ($visits > $max_visits) ) { $block_message = '429'; log_event($db, $settings, 'user', $block_message, $ip); header( 'HTTP/1.1 429 Too Many Requests' ); quit($db, '429'); } } // Check if a message exists already in the database when importing. function check_post_exists($db, $sub, $post_id) { $statement = $db->prepare("SELECT post_id FROM threads WHERE sub = '$sub' AND post_id = '$post_id'"); $result = $statement->execute(); $counter = 0; while ($row = $result->fetchArray(SQLITE3_NUM)) { $counter++; } if ( ($counter < 1) ) { // if the counter is smaller 1, there is no match return FALSE; } else { return TRUE; } } // Check if a sub exists before display // If the name does not exist, name* is searched function check_sub_exists($db, $sub) { if ( ($sub == 'overboard') || ($sub == 'main') ) { return $sub; } $statement = $db->prepare("SELECT sub FROM threads WHERE sub = '$sub' AND shadow = 'no'"); $result = $statement->execute(); $counter = 0; while ($row = $result->fetchArray(SQLITE3_NUM)) { $counter++; $sub = "{$row[0]}"; } if ( ($counter < 1) ) { // if the counter is smaller 1, there is no match $statement = $db->prepare("SELECT sub FROM threads WHERE sub GLOB '$sub*' AND shadow = 'no' ORDER BY sub ASC LIMIT 1"); $result = $statement->execute(); $counter_2 = 0; while ($row = $result->fetchArray(SQLITE3_NUM)) { $counter_2++; $sub = "{$row[0]}"; } if ( ($counter_2 < 1) ) { // if the counter is smaller 1, there is no match return FALSE; } else { return $sub; } } else { return $sub; } } // record $_SERVER info function debug_server($db, $settings) { $filename = $settings['work_dir'] . 'debug_server.txt'; $content = print_r($_SERVER, TRUE); file_put_contents($filename, $content); } // Dump the contents of a sub, a thread or the whole board to a json // file and send it to the browser. // If used on the overboard, it will dump everything, including the // messages from subs that are not actually displayed on the overboard. function dump($db, $sub, $org_id, $settings) { // rewrite to include ranges header( 'Content-Type: application/json' ); $json_dump = array(); if ( (!empty($org_id)) ) { $statement = $db->prepare("SELECT post_id, text FROM threads WHERE sub = '$sub' AND shadow = 'no' AND org_id = '$org_id'"); $result = $statement->execute(); $json_dump['sub'] = $sub; $json_dump['org_id'] = $org_id; while ($row = $result->fetchArray(SQLITE3_NUM)) { $post = array(); $post['post_id'] = "{$row[0]}"; $post['text'] = "{$row[1]}"; array_push($json_dump, $post); } } elseif ($sub == 'overboard') { $statement = $db->prepare("SELECT post_id, org_id, sub, text, timestamp FROM threads WHERE shadow = 'no'"); $result = $statement->execute(); while ($row = $result->fetchArray(SQLITE3_NUM)) { $post = array(); $post['post_id'] = "{$row[0]}"; $post['org_id'] = "{$row[1]}"; $post['sub'] = "{$row[2]}"; $post['text'] = "{$row[3]}"; $post['timestamp'] = "{$row[4]}"; array_push($json_dump, $post); } } else { $statement = $db->prepare("SELECT post_id, org_id, text FROM threads WHERE sub = '$sub' AND shadow = 'no'"); $result = $statement->execute(); $json_dump['sub'] = $sub; while ($row = $result->fetchArray(SQLITE3_NUM)) { $post = array(); $post['post_id'] = "{$row[0]}"; $post['org_id'] = "{$row[1]}"; $post['text'] = "{$row[2]}"; array_push($json_dump, $post); } } echo json_encode($json_dump, JSON_PRETTY_PRINT | JSON_NUMERIC_CHECK | JSON_UNESCAPED_UNICODE); } // Prevents additional tries to log in or to edit a post after too many // failed tries. To be configured in the config file. // Note: if enabled, this function can be used to dos the access to the // admin and mod panels by sending constant junk requests. // Still better than someone trying millions of user/password // combinations, right ? // If really paranoid, enable the admin panel only if you use it or // let it listen on another address altogether. // Read your logfiles to check if attacks are ongoing. function fail2ban($db, $settings) { if ($settings['enable_fail2ban'] != TRUE) { return; } $current = time(); $max_age = $current - ($settings['auth_time_frame'] * 60); // max age is in minutes, so times 60 to go to seconds if ($settings['superstrict'] == TRUE) { $statement = $db->prepare("SELECT unix_timestamp FROM logs WHERE type = 'auth' AND event in ( 'name#tripkey combination not valid', 'wrong combination user/password', 'invalid token used', 'wrong combination name/token', 'fail2ban triggered') AND unix_timestamp > '$max_age'"); } else { $statement = $db->prepare("SELECT unix_timestamp FROM logs WHERE type = 'auth' AND event in ( 'name#tripkey combination not valid', 'wrong combination user/password', 'invalid token used', 'wrong combination name/token') AND unix_timestamp > '$max_age'"); } $result = $statement->execute(); $counter = 0; while ($row = $result->fetchArray(SQLITE3_NUM)) { $counter++; } if ($counter > $settings['auth_max']) { $fail2ban_message = 'fail2ban triggered'; log_event($db, $settings, 'auth', $fail2ban_message, ''); header( 'HTTP/1.1 429 Too Many Requests' ); quit($db, '429'); } } // filter a variable according to different parameters and return the // result function filter($text, $type, $length) { if ( ( $type == 'alnum') ) { $filtered_text = substr( preg_replace("/[^0-9a-zA-Z]/", "", $text), 0, $length); } elseif ( ($type == 'num') ) { $filtered_text = substr( preg_replace("/[^0-9]/", "", $text), 0, $length); } elseif ( ($type == 'email') ) { $filtered_text = substr( preg_replace("/[^0-9a-zA-Z@._]/", "", $text), 0, $length); } return $filtered_text; } // Check how many pretty vars have been sent function get_pretty_vars_count() { $raw_vars = explode('/', $_SERVER['REQUEST_URI']); $count = count($raw_vars); return $count; } // Return number of posts in sub or overboard, with or without replies. function give_total_posts($db, $sub, $original_only, $settings) { if ( ($original_only) && ($sub != 'overboard') ) { $statement = $db->prepare("SELECT post_id FROM threads WHERE sub = '$sub' AND org_id = original AND shadow = 'no'"); $result = $statement->execute(); $counter = 0; while ($row = $result->fetchArray(SQLITE3_NUM)) { $counter++; } } elseif ( ($sub == 'overboard') ) { $no_overboard = ''; $last = array_pop($settings['no_overboard']); foreach($settings['no_overboard'] as $ex_sub) { $str = "'" . $ex_sub . "', "; $no_overboard .= $str; } $no_overboard .= "'" . $last . "'"; $statement = $db->prepare("SELECT post_id FROM threads WHERE org_id = original AND sub NOT IN ($no_overboard) AND shadow = 'no'"); $result = $statement->execute(); $counter = 0; while ($row = $result->fetchArray(SQLITE3_NUM)) { $counter++; } } else { $statement = $db->prepare("SELECT post_id FROM threads WHERE sub = '$sub' AND shadow = 'no'"); $result = $statement->execute(); $counter = 0; while ($row = $result->fetchArray(SQLITE3_NUM)) { $counter++; } } return $counter; } // Log an event to the db. Also, delete the overflow of logs as // defined in the config file. function log_event($db, $settings, $type, $text, $ip) { if ( ($settings['enable_logging'] != TRUE) ) { return; } $current = time(); $timestamp = date('Y-m-d H:i:s', $_SERVER['REQUEST_TIME']); $statement = $db->prepare("INSERT INTO logs(event, type, timestamp, unix_timestamp, ip) VALUES (?, '$type', '$timestamp', '$current', ?)"); $statement->bindParam(1, $text); $statement->bindParam(2, $ip); $statement->execute(); if ( ($settings['cap_logs'] > 0) ) { $statement = $db->prepare("DELETE FROM logs WHERE ROWID IN (SELECT ROWID FROM logs ORDER BY ROWID DESC LIMIT -1 OFFSET ?)"); // to prevent the db from bloating (and to prevent attacks), we // allow only so many lines of logs at any one time, and we check // this with each call. $statement->bindParam(1, $settings['cap_logs']); $result = $statement->execute(); } } // Make all tables that are needed (one each for posts, keys, hashes // (captchas), hashes (passwords) and logs). // Also, the hashes for the captchas are cropped to 20000. function make_tables($db) { // make basic tables: threads, captchas, logs, keys $db->exec('CREATE TABLE IF NOT EXISTS "captchas" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "hash" TEXT UNIQUE, "unix_timestamp" INTEGER )'); $db->exec('CREATE TABLE IF NOT EXISTS "logs" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "timestamp" TEXT, "unix_timestamp" INTEGER, "type" TEXT, "event" TEXT, "ip" TEXT )'); $db->exec('CREATE TABLE IF NOT EXISTS "keys" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "name" TEXT UNIQUE NOT NULL, "type" INTEGER NOT NULL, "email" TEXT, "key" TEXT, "subs" TEXT, "token" TEXT, "timestamp_token" INTEGER )'); $db->exec('CREATE TABLE IF NOT EXISTS "threads" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "post_id" INTEGER NOT NULL, "shadow" TEXT NOT NULL, "sub" TEXT NOT NULL, "global_id" TEXT NOT NULL UNIQUE, "text_id" TEXT NOT NULL, "text" TEXT NOT NULL, "org_id" INTEGER NOT NULL, "timestamp" TEXT, "name" TEXT, "tripcode" TEXT, "original" INTEGER NOT NULL, "move_message" TEXT, "edit_message" TEXT, UNIQUE(post_id, sub) ON CONFLICT IGNORE )'); $statement = $db->prepare("DELETE FROM captchas WHERE ROWID IN (SELECT ROWID FROM captchas ORDER BY ROWID DESC LIMIT -1 OFFSET 20000)"); // to prevent the db from bloating (and to prevent attacks), we // allow only 20000 captchas at any one time, and we check this // with each call. This should be enough if your site is getting // less or equal to 100.000 visitors a day. // Total combinations of captcha and token are ca. 2000 * 62^250. $result = $statement->execute(); } // Make a token to grant access to the admin panel or the mod panel for // a limited time. A way of having sessions without cookies. // Also used as a hidden field in the post form to prevent double // posting by sending the same input twice. function make_token($length, $mode) { if ( $mode == 'alnum' ) { $characters = '0123456789' . 'abcdefghijklmnopqrstuvwxyz' . 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; } elseif ( $mode == 'alpha' ) { $characters = 'abcdefghijklmnopqrstuvwxyz' . 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; } elseif ( $mode == 'num' ) { $characters = '0123456789'; } $counter = strlen($characters) - 1; $random_string = ''; for ($i = 0; $i < $length; $i++) { $index = random_int(0, $counter); $random_string .= $characters[$index]; } return $random_string; } // Close the database, so to not let this work for the garbage collector // and loose time. // Also display a final message (optionally), and exit. function quit($db, $text) { $db->close(); if ($text != '') { echo "$text"; } exit; } // Parse pretty vars at different positions and with different filters function read_pretty_vars($position, $filter, $length) { $var = ''; $raw_vars = explode('/', $_SERVER['REQUEST_URI']); $pretty_vars = array(); foreach($raw_vars as $raw_var) { if ($filter == 'number') { $pretty_var = substr(preg_replace("/[^0-9]/", "", $raw_var), 0, $length); } elseif ($filter == 'alnum') { $pretty_var = substr(preg_replace("/[^0-9a-zA-Z]/", "", $raw_var), 0, $length); } elseif ($filter == 'alnumplus') { $pretty_var = substr(preg_replace("/[^0-9a-zA-Z+-_]/", "", $raw_var), 0, $length); } array_push($pretty_vars, $pretty_var); } if ($position == 'last') { $var = array_pop($pretty_vars); } elseif ( (isset($pretty_vars[$position])) ) { $var = $pretty_vars[$position]; } return $var; } // Find the original post to a reply function reset_org_id($db, $sub, $post_id) { $statement = $db->prepare("SELECT org_id FROM threads WHERE sub = '$sub' AND shadow = 'no' AND post_id = '$post_id'"); $result = $statement->execute(); while ($row = $result->fetchArray(SQLITE3_NUM)) { $post_id = "{$row[0]}"; } return $post_id; } // Set the css that we use function set_css($mode, $settings) { $admin_css = array( 'auth_admin', 'admin', 'delete_admin', 'delete_logs', 'logs', 'password', 'setup', 'shadow', 'view_mods', 'change_mods', 'unshadow_post', 'unshadow_sub', 'import', 'dump_full' ); $mod_css = array( 'apply', 'auth_mod', 'mod', 'delete_mod' ); if (in_array($mode, $admin_css)) { return 'admin'; } elseif (in_array($mode, $mod_css)) { return 'mod'; } elseif ( (!empty($_POST['css'])) ) { $css = filter($_POST['css'], 'alnum', 20); // 20 chars should be enough to name a css } else { $raw_vars = explode('/', $_SERVER['REQUEST_URI']); foreach($raw_vars as $raw_var) { if ( (mb_strtolower(substr( $raw_var, 0, 4 )) === 'css=') ) { // css= is four letters $css_vars = explode('=', $raw_var); $css = filter($css_vars[1], 'alnum', 20); // 20 chars should be enough to name a css break; } } } if ( !empty($css) && ( file_exists($settings['server_dir'] . 'css/' . $css . '.css') ) ){ return "$css"; } else { return $settings['default_css']; } } // Check what is expected from the request, and set mode accordingly. // The variable "$short_mode" is read from the request. // The elements in the triggers array other than 'tr', capture requests // that are standard scans for vulns + target discovery. // Part of the bot trap, which can be disabled in the config file function set_mode($short_mode, $settings) { $triggers = array ( 'tr', 'login', 'wellknown', 'wplogin', 'wpjson', 'wp', 'products', 'wpusers', 'wpadmin', 'wpadminer', 'adminer', 'phpmyadmin', 'wpuploads', 'wpcontent', 'wpconfig', 'wpincludes', 'static', 'img', 'images', 'uploads', 'styles', 'style', 'serverinfo', 'privatekey', 'serverstatus' ); $short_modes = array ( 'cm', 'dim', 'pa', 'ush', 'dsh', 'usha', 'dsa', 'aa', 'am', 'dt', 'a', 'm', 'ap', 'sp', 'im', 'da', 'dm', 'dl', 'su', 'lo', 'sh', 'iv', 'p', 'r', 'v', 's', 'b', 'd', 'df', 'e', 'u', 'rss' ); $long_modes = array ( 'view_mods', 'change_mods', 'password', 'unshadow_post', 'delete_post', 'unshadow_sub', 'delete_sub', 'auth_admin', 'auth_mod', 'destroy_token', 'admin', 'mod', 'apply', 'setup', 'import', 'delete_admin', 'delete_mod', 'delete_logs', 'subs', 'logs', 'shadow', 'individual_view', 'post', 'reply', 'view', 'view', 'bot', 'dump', 'dump_full', 'edit', 'user', 'feed' ); if ( (in_array($short_mode, $triggers)) && ($settings['enable_bot_trap']) ) { $mode = 'trap'; } elseif ( (!in_array($short_mode, $short_modes)) ) { $mode = 'landing'; // if nothing fits, we display the landing page } else { for ($i = 0; $i < count($short_modes); $i++) { if ( ($short_modes[$i] == $short_mode) ) { $mode = $long_modes[$i]; break; } } } return $mode; } // In case of replies, check which post we are replying to // (based on the pretty vars in GET requests) function set_org_id() { $org_id = read_pretty_vars(3, 'number', 10); // we read from the third position, and a message does not need more // than 10 digits (=max 9 999 999 999 messages) return $org_id; } // Get the page if it is defined, otherwise set to 1 function set_page() { if (get_pretty_vars_count() == 5) { // the page is only defined if there are five vars $page = read_pretty_vars(3, 'number', 10); // read from third position, and a page does not need more // than 10 digits } else { $page = 0; // if there is no page given, we set it to zero } if ( ($page < 1) ) { // if the page is zero, it means we did not // get a number before $page = read_pretty_vars(3, 'alnum', 3); // read from the third position, and a page does not need more // than 10 digits, this time we allow letters, too. if ( ($page != 'all') ) { $page = 1; // if page is not "all", we start with the first } } return $page; } // Determine if there is an original post_id given in the poststream // (in case of replies) function set_post_org_id() { $org_id = ''; if ( (!empty($_POST['org_id'])) ) { $org_id = filter($_POST['org_id'], 'num', 10); // no post id needs more than 10 digits // (= 9 999 999 999 messages) } return $org_id; } // Determine which sub we use currently, first read from POST, // than from the pretty vars (GET) function set_sub($settings) { $sub = ''; if ( (!empty($_POST['sub'])) ) { $sub = filter($_POST['sub'], 'alnum', $settings['max_name_sub']); } elseif (get_pretty_vars_count() > 1) { $sub = read_pretty_vars(2, 'alnum', $settings['max_name_sub']); // read the sub from the second position } if ($sub == '') { $sub = 'main'; } return $sub; } // Get the quote, if there is one. function set_quote() { if (get_pretty_vars_count() == 6) { $quote = read_pretty_vars(4, 'alnum', 10); // read from fourth position, and a post id does not need more // than 10 digits } else { $quote = ''; } return $quote; } // EOF