224 lines
7.6 KiB
PHP
224 lines
7.6 KiB
PHP
<?php
|
|
/**
|
|
* Lightweight form protection for public forms.
|
|
*
|
|
* Uses flat JSON files instead of third-party services or sessions:
|
|
* - one-time tokens with minimum and maximum age
|
|
* - hashed client rate limits
|
|
* - honeypot and conservative content heuristics
|
|
*/
|
|
|
|
require_once __DIR__ . '/json-store.php';
|
|
|
|
const RAIJU_FORM_TOKEN_FILE = __DIR__ . '/../content/form-tokens.json';
|
|
const RAIJU_FORM_RATE_FILE = __DIR__ . '/../content/form-rate-limit.json';
|
|
const RAIJU_FORM_TOKEN_MIN_AGE = 4;
|
|
const RAIJU_FORM_TOKEN_MAX_AGE = 3600;
|
|
const RAIJU_FORM_RATE_WINDOW = 600;
|
|
const RAIJU_FORM_RATE_MAX_SUBMISSIONS = 3;
|
|
const RAIJU_FORM_RATE_MAX_FAILURES = 8;
|
|
|
|
function raijuFormReadJsonFile(string $path): array
|
|
{
|
|
if (!file_exists($path)) {
|
|
return [];
|
|
}
|
|
|
|
$data = json_decode((string) file_get_contents($path), true);
|
|
return is_array($data) ? $data : [];
|
|
}
|
|
|
|
function raijuFormWriteJsonFile(string $path, array $data): bool
|
|
{
|
|
$dir = dirname($path);
|
|
if (!is_dir($dir) && !mkdir($dir, 0755, true)) {
|
|
return false;
|
|
}
|
|
|
|
return file_put_contents(
|
|
$path,
|
|
json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE),
|
|
LOCK_EX
|
|
) !== false;
|
|
}
|
|
|
|
function raijuFormTokenHash(string $token): string
|
|
{
|
|
return hash('sha256', $token);
|
|
}
|
|
|
|
function raijuCreateFormToken(string $formId = 'contact'): string
|
|
{
|
|
$token = bin2hex(random_bytes(32));
|
|
$now = time();
|
|
$saved = raijuJsonUpdate(RAIJU_FORM_TOKEN_FILE, function (array &$tokens) use ($token, $formId, $now): void {
|
|
$tokens = array_filter($tokens, static fn($entry) => is_array($entry)
|
|
&& (int)($entry['created'] ?? 0) > 0
|
|
&& $now - (int)$entry['created'] <= RAIJU_FORM_TOKEN_MAX_AGE);
|
|
$tokens[raijuFormTokenHash($token)] = ['form' => $formId, 'created' => $now];
|
|
});
|
|
if (!$saved) throw new RuntimeException('Could not create form token.');
|
|
return $token;
|
|
}
|
|
|
|
|
|
function raijuFormProtectionFields(string $formId = 'contact'): string
|
|
{
|
|
$token = raijuCreateFormToken($formId);
|
|
|
|
return sprintf(
|
|
'<input type="hidden" name="form_id" value="%s">' . "\n" .
|
|
' <input type="hidden" name="form_token" value="%s">' . "\n" .
|
|
' <div style="position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden" aria-hidden="true">' . "\n" .
|
|
' <label>Website <input type="text" name="website" tabindex="-1" autocomplete="off"></label>' . "\n" .
|
|
' </div>',
|
|
htmlspecialchars($formId, ENT_QUOTES, 'UTF-8'),
|
|
htmlspecialchars($token, ENT_QUOTES, 'UTF-8')
|
|
);
|
|
}
|
|
|
|
function raijuIsLazyFormRender(): bool
|
|
{
|
|
return defined('RAIJU_RENDER_LAZY_FORM') && RAIJU_RENDER_LAZY_FORM === true;
|
|
}
|
|
|
|
function raijuSafeFormBasePath(string $basePath): string
|
|
{
|
|
if (preg_match('/[\x00-\x1F\x7F<>"\'\\\\]/', $basePath)) {
|
|
return '';
|
|
}
|
|
|
|
if (preg_match('#^[a-z][a-z0-9+.-]*:#i', $basePath)) {
|
|
return '';
|
|
}
|
|
|
|
return $basePath;
|
|
}
|
|
|
|
function raijuLazyFormPlaceholder(string $formId, array $options = []): string
|
|
{
|
|
$basePath = raijuSafeFormBasePath((string) ($options['basePath'] ?? ''));
|
|
$endpoint = $options['endpoint'] ?? ($basePath . 'api/form.php');
|
|
$delay = (int) ($options['delay'] ?? 3500);
|
|
$attrs = [
|
|
'data-raiju-lazy-form' => $formId,
|
|
'data-endpoint' => $endpoint,
|
|
'data-delay' => (string) max(0, $delay),
|
|
];
|
|
|
|
foreach (($options['params'] ?? []) as $key => $value) {
|
|
if (!preg_match('/^[a-z0-9_-]+$/i', (string) $key)) {
|
|
continue;
|
|
}
|
|
$attrs['data-param-' . strtolower((string) $key)] = (string) $value;
|
|
}
|
|
|
|
$htmlAttrs = '';
|
|
foreach ($attrs as $key => $value) {
|
|
$htmlAttrs .= ' ' . $key . '="' . htmlspecialchars($value, ENT_QUOTES, 'UTF-8') . '"';
|
|
}
|
|
|
|
return '<div' . $htmlAttrs . '><noscript>This form requires JavaScript.</noscript></div>';
|
|
}
|
|
|
|
function raijuValidateFormToken(?string $token, string $formId = 'contact'): array
|
|
{
|
|
$token = trim((string)$token);
|
|
if ($token === '') return ['valid' => false, 'reason' => 'missing'];
|
|
$hash = raijuFormTokenHash($token);
|
|
$result = ['valid' => false, 'reason' => 'invalid'];
|
|
$saved = raijuJsonUpdate(RAIJU_FORM_TOKEN_FILE, function (array &$tokens) use ($hash, $formId, &$result): bool {
|
|
$entry = $tokens[$hash] ?? null;
|
|
if (!is_array($entry) || ($entry['form'] ?? '') !== $formId) return false;
|
|
unset($tokens[$hash]);
|
|
$age = time() - (int)($entry['created'] ?? 0);
|
|
$reason = $age < RAIJU_FORM_TOKEN_MIN_AGE ? 'too_fast' : ($age > RAIJU_FORM_TOKEN_MAX_AGE ? 'expired' : 'ok');
|
|
$result = ['valid' => $reason === 'ok', 'reason' => $reason];
|
|
return true;
|
|
});
|
|
return !$saved && $result['valid'] ? ['valid' => false, 'reason' => 'storage'] : $result;
|
|
}
|
|
|
|
|
|
function raijuFormClientKey(): string
|
|
{
|
|
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
|
|
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? '';
|
|
$salt = __DIR__;
|
|
|
|
return hash('sha256', $ip . '|' . $userAgent . '|' . $salt);
|
|
}
|
|
|
|
function raijuFormPruneRate(array &$rate, string $clientKey): void
|
|
{
|
|
$now = time();
|
|
foreach ($rate as $key => $entry) {
|
|
if (!is_array($entry)) { unset($rate[$key]); continue; }
|
|
foreach (['submissions', 'failures', 'pending'] as $kind) {
|
|
$rate[$key][$kind] = array_values(array_filter($entry[$kind] ?? [],
|
|
static fn($timestamp) => $now - (int)$timestamp <= RAIJU_FORM_RATE_WINDOW));
|
|
}
|
|
if (!$rate[$key]['submissions'] && !$rate[$key]['failures'] && !$rate[$key]['pending'] && $key !== $clientKey) unset($rate[$key]);
|
|
}
|
|
$rate[$clientKey] = $rate[$clientKey] ?? ['submissions' => [], 'failures' => [], 'pending' => []];
|
|
}
|
|
|
|
function raijuFormRateState(string $clientKey): array
|
|
{
|
|
$state = ['submissions' => [], 'failures' => []];
|
|
$saved = raijuJsonUpdate(RAIJU_FORM_RATE_FILE, function (array &$rate) use ($clientKey, &$state): void {
|
|
raijuFormPruneRate($rate, $clientKey);
|
|
$state = $rate[$clientKey];
|
|
});
|
|
if (!$saved) return ['submissions' => array_fill(0, RAIJU_FORM_RATE_MAX_SUBMISSIONS, time()), 'failures' => []];
|
|
return $state;
|
|
}
|
|
|
|
|
|
function raijuFormIsRateLimited(string $clientKey): bool
|
|
{
|
|
$state = raijuFormRateState($clientKey);
|
|
|
|
return count($state['submissions'] ?? []) + count($state['pending'] ?? []) >= RAIJU_FORM_RATE_MAX_SUBMISSIONS
|
|
|| count($state['failures'] ?? []) >= RAIJU_FORM_RATE_MAX_FAILURES;
|
|
}
|
|
|
|
/** Reserve a slot before processing so parallel submissions cannot evade limits. */
|
|
function raijuFormReserveRequest(string $clientKey): bool
|
|
{
|
|
return raijuJsonUpdate(RAIJU_FORM_RATE_FILE, function (array &$rate) use ($clientKey): bool {
|
|
raijuFormPruneRate($rate, $clientKey);
|
|
$state = &$rate[$clientKey];
|
|
if (count($state['submissions']) + count($state['pending']) >= RAIJU_FORM_RATE_MAX_SUBMISSIONS
|
|
|| count($state['failures']) >= RAIJU_FORM_RATE_MAX_FAILURES) return false;
|
|
$state['pending'][] = time();
|
|
return true;
|
|
});
|
|
}
|
|
|
|
function raijuFormRecordAttempt(string $clientKey, bool $success): void
|
|
{
|
|
raijuJsonUpdate(RAIJU_FORM_RATE_FILE, function (array &$rate) use ($clientKey, $success): void {
|
|
raijuFormPruneRate($rate, $clientKey);
|
|
array_shift($rate[$clientKey]['pending']);
|
|
$rate[$clientKey][$success ? 'submissions' : 'failures'][] = time();
|
|
});
|
|
}
|
|
|
|
|
|
function raijuFormLooksLikeSpam(array $fields): bool
|
|
{
|
|
$text = implode("\n", array_map('strval', $fields));
|
|
preg_match_all('#https?://|www\.#i', $text, $links);
|
|
|
|
if (count($links[0]) > 6) {
|
|
return true;
|
|
}
|
|
|
|
if (preg_match('/\[url=|<\/a>|<a\s/i', $text)) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|