dev_endboard/opt/base.php

789 lines
25 KiB
PHP

<?php
/*
* This is the endboard software, version beta 0.80
* It is a textboard written for the use in the darknets.
*
* This file holds all the basic functions needed. It can be
* included without side effects.
*
* The writing of this code started some time ago with another software
* called smolBBS. Although there is almost no original code left now,
* I still regard endboard as a fork of smolBBS.
* The author of smolBBS has required that the following text be
* distributed with any redistribution, so here it goes.
* The license and other conditions apply to endboard as well.
*
* IRC: *dulm @ irc.rizon.net
*
* Copyright (C) 2020 sandlind
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are
* met:
*
* (1) Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
*
* (2) Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in
* the documentation and/or other materials provided with the
* distribution.
*
* (3)The name of the author may not be used to
* endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
* IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT,
* INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
* HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
* STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING
* IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
*/
// Check if the maximum requests as defined in the config file are
// exhausted or not
function check_max_requests($db, $settings, $ip)
{
$current = time();
if ( ($settings['max_requests_ip'] > 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