Skip to content

Commit fd076cc

Browse files
Merge pull request #1 from patchstack/feature/drupal-extension
Feature: Added Drupal extension.
2 parents 5a649ab + 6061e3b commit fd076cc

3 files changed

Lines changed: 298 additions & 3 deletions

File tree

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
<?php
2+
3+
namespace Patchstack\Extensions\Drupal;
4+
5+
use Patchstack\Extensions\ExtensionInterface;
6+
7+
/**
8+
* Drupal-specific implementation of the Patchstack firewall extension.
9+
*
10+
* This extension provides Drupal integration for the Patchstack firewall engine.
11+
* It handles logging, IP detection, and block page rendering within Drupal's context.
12+
*/
13+
class Extension implements ExtensionInterface {
14+
15+
/**
16+
* Log the request to the patchstack_logs table.
17+
*
18+
* @param int $ruleId
19+
* The firewall rule ID that matched.
20+
* @param string $bodyData
21+
* The request body data.
22+
* @param string $blockType
23+
* The type of block performed (BLOCK, LOG, or REDIRECT).
24+
*/
25+
public function logRequest($ruleId, $bodyData, $blockType): void {
26+
try {
27+
$request = \Drupal::request();
28+
29+
// Get POST data.
30+
$postData = $request->request->all();
31+
if (empty($postData)) {
32+
$rawContent = $request->getContent();
33+
$postData = !empty($rawContent) ? $rawContent : NULL;
34+
}
35+
else {
36+
$postData = json_encode($postData);
37+
}
38+
39+
// Insert into the logs table.
40+
$connection = \Drupal::database();
41+
$connection->insert('patchstack_logs')
42+
->fields([
43+
'ip' => $this->getIpAddress(),
44+
'request_uri' => $request->getRequestUri() ?? '',
45+
'user_agent' => $request->headers->get('User-Agent') ?? '',
46+
'method' => $request->getMethod() ?? '',
47+
'fid' => (int) $ruleId,
48+
'post_data' => $postData,
49+
'block_type' => $blockType,
50+
'log_date' => \Drupal::time()->getRequestTime(),
51+
])
52+
->execute();
53+
}
54+
catch (\Throwable $e) {
55+
// Silently fail to avoid breaking the site.
56+
\Drupal::logger('patchstack')->error('Failed to log blocked request: @message', [
57+
'@message' => $e->getMessage(),
58+
]);
59+
}
60+
}
61+
62+
/**
63+
* Determine if the current visitor can bypass the firewall.
64+
*
65+
* @param bool $isMuCall
66+
* Whether this is an early call before user session is available.
67+
*
68+
* @return bool
69+
* TRUE if the visitor can bypass, FALSE otherwise.
70+
*/
71+
public function canBypass($isMuCall): bool {
72+
// If called too early, we can't check user permissions.
73+
if ($isMuCall) {
74+
return FALSE;
75+
}
76+
77+
try {
78+
$currentUser = \Drupal::currentUser();
79+
80+
// Allow administrators to bypass.
81+
if ($currentUser->hasPermission('administer site configuration')) {
82+
return TRUE;
83+
}
84+
}
85+
catch (\Throwable $e) {
86+
// If we can't determine user, don't bypass.
87+
}
88+
89+
return FALSE;
90+
}
91+
92+
/**
93+
* Determine if the visitor is blocked due to too many blocked requests.
94+
*
95+
* @param int $minutes
96+
* The time window in minutes.
97+
* @param int $blockTime
98+
* Additional block time in minutes.
99+
* @param int $attempts
100+
* Maximum allowed attempts before blocking.
101+
*
102+
* @return bool
103+
* TRUE if the visitor should be blocked, FALSE otherwise.
104+
*/
105+
public function isBlocked($minutes, $blockTime, $attempts): bool {
106+
// Currently disabled - return false to not auto-block based on attempts.
107+
// This can be enabled later with proper configuration.
108+
if (empty($minutes) || empty($blockTime) || empty($attempts)) {
109+
return FALSE;
110+
}
111+
112+
try {
113+
// Calculate the time window.
114+
$time = ($minutes + $blockTime) * 60;
115+
$cutoffTime = \Drupal::time()->getRequestTime() - $time;
116+
117+
// Count blocked requests from this IP in the time window.
118+
$connection = \Drupal::database();
119+
$count = $connection->select('patchstack_logs', 'pl')
120+
->condition('block_type', 'BLOCK')
121+
->condition('ip', $this->getIpAddress())
122+
->condition('log_date', $cutoffTime, '>=')
123+
->countQuery()
124+
->execute()
125+
->fetchField();
126+
127+
return (int) $count >= $attempts;
128+
}
129+
catch (\Throwable $e) {
130+
return FALSE;
131+
}
132+
}
133+
134+
/**
135+
* Force exit the page when a request has been blocked.
136+
*
137+
* @param int $ruleId
138+
* The firewall rule ID that matched.
139+
*/
140+
public function forceExit($ruleId): void {
141+
// Set cache control headers.
142+
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
143+
header('Cache-Control: post-check=0, pre-check=0', FALSE);
144+
header('Pragma: no-cache');
145+
http_response_code(403);
146+
147+
// Include the blocked page template.
148+
$blockedPage = __DIR__ . '/Views/Blocked.php';
149+
if (file_exists($blockedPage)) {
150+
include $blockedPage;
151+
}
152+
else {
153+
// Fallback simple message.
154+
echo '<!DOCTYPE html><html><head><title>Access Denied</title></head>';
155+
echo '<body><h1>Access Denied</h1>';
156+
echo '<p>This request has been blocked by the firewall. Error code: ' . (int) $ruleId . '</p>';
157+
echo '</body></html>';
158+
}
159+
160+
exit;
161+
}
162+
163+
/**
164+
* Get the IP address of the request.
165+
*
166+
* @return string
167+
* The client IP address.
168+
*/
169+
public function getIpAddress(): string {
170+
try {
171+
return \Drupal::request()->getClientIp() ?? '0.0.0.0';
172+
}
173+
catch (\Throwable $e) {
174+
// Fallback if Drupal request isn't available.
175+
return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
176+
}
177+
}
178+
179+
/**
180+
* Get the hostname of the environment.
181+
*
182+
* @return string
183+
* The HTTP host.
184+
*/
185+
public function getHostName(): string {
186+
try {
187+
return \Drupal::request()->getHost();
188+
}
189+
catch (\Throwable $e) {
190+
return $_SERVER['HTTP_HOST'] ?? '';
191+
}
192+
}
193+
194+
/**
195+
* Determine if the request should bypass the firewall based on whitelist.
196+
*
197+
* @param array $whitelistRules
198+
* The whitelist rules.
199+
* @param array $request
200+
* The request data.
201+
*
202+
* @return bool
203+
* TRUE if the request is whitelisted, FALSE otherwise.
204+
*/
205+
public function isWhitelisted($whitelistRules, $request): bool {
206+
// Whitelist processing is handled by the firewall engine itself.
207+
// This method can be used for additional Drupal-specific whitelisting.
208+
return FALSE;
209+
}
210+
211+
/**
212+
* Determine if the current request is a file upload request.
213+
*
214+
* @return bool
215+
* TRUE if files are being uploaded, FALSE otherwise.
216+
*/
217+
public function isFileUploadRequest(): bool {
218+
try {
219+
$files = \Drupal::request()->files->all();
220+
return !empty($files);
221+
}
222+
catch (\Throwable $e) {
223+
return isset($_FILES) && count($_FILES) > 0;
224+
}
225+
}
226+
227+
}

0 commit comments

Comments
 (0)