-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathwebhook_helper.php
More file actions
192 lines (174 loc) · 5.97 KB
/
Copy pathwebhook_helper.php
File metadata and controls
192 lines (174 loc) · 5.97 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
<?php
/**
* CSWeb webhooks shared helper.
*
* Centralises:
* - JSON response shape: { success, data?, error?, meta? }
* - Bearer token authentication
* - Capped JSON body parsing
* - Method allowlist enforcement
* - Stable error codes (machine-readable)
*
* Used by:
* - breakout-webhook.php
* - breakout-status-webhook.php
* - dictionary-schema-webhook.php
* - log-reader-webhook.php
*/
const WEBHOOK_BODY_MAX_BYTES = 65_536;
const WEBHOOK_RATE_LIMIT_MAX = 60; // requests
const WEBHOOK_RATE_LIMIT_WINDOW = 60; // seconds
/**
* Send a JSON response with the unified shape and exit.
*
* @param array<string,mixed>|list<mixed>|null $data
* @param array{code:string,message:string}|null $error
* @param array<string,mixed>|null $meta
*/
function respondJson(int $httpCode, bool $success, array|null $data = null, ?array $error = null, ?array $meta = null): void {
http_response_code($httpCode);
header('Content-Type: application/json; charset=utf-8');
header('X-Robots-Tag: noindex, nofollow');
$payload = ['success' => $success];
$payload['data'] = $data;
$payload['error'] = $error;
if ($meta !== null) {
$payload['meta'] = $meta;
}
echo json_encode($payload, JSON_UNESCAPED_UNICODE);
exit;
}
/**
* @param array<string,mixed>|list<mixed>|null $data
* @param array<string,mixed>|null $meta
*/
function respondSuccess(array|null $data = null, ?array $meta = null, int $httpCode = 200): void {
respondJson($httpCode, true, $data, null, $meta);
}
function respondError(string $code, string $message, int $httpCode): void {
respondJson($httpCode, false, null, ['code' => $code, 'message' => $message]);
}
/**
* Enforce that the request method is one of $allowed (case-sensitive uppercase).
*
* @param list<string> $allowed
*/
function requireMethod(array $allowed): void {
$method = $_SERVER['REQUEST_METHOD'] ?? '';
if (!in_array($method, $allowed, true)) {
respondError(
'method_not_allowed',
'Method not allowed. Use: ' . implode(', ', $allowed) . '.',
405
);
}
}
/**
* Validate that the Authorization header carries the configured Bearer token.
* Aborts the request with 401 / 500 on failure.
*/
function requireBearerToken(): void {
$expected = getenv('BREAKOUT_WEBHOOK_TOKEN');
if (!$expected) {
respondError(
'server_misconfigured',
'Server misconfiguration: BREAKOUT_WEBHOOK_TOKEN environment variable is not set.',
500
);
}
$authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (!preg_match('/^Bearer\s+(.+)$/i', $authHeader, $matches)) {
respondError(
'missing_token',
'Missing or invalid Authorization header. Expected: Bearer <token>.',
401
);
}
if (!hash_equals($expected, $matches[1])) {
respondError('invalid_token', 'Invalid token.', 401);
}
// Sliding-window rate limit, keyed on (token-fingerprint, client IP).
// Fingerprint avoids exposing raw token in filenames.
$tokenFp = substr(hash('sha256', $matches[1]), 0, 16);
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
enforceRateLimit($tokenFp . '_' . preg_replace('/[^a-zA-Z0-9._-]/', '_', $ip));
}
/**
* Sliding-window rate limiter (max WEBHOOK_RATE_LIMIT_MAX requests per
* WEBHOOK_RATE_LIMIT_WINDOW seconds per key). Aborts with 429 on excess.
*/
function enforceRateLimit(string $key): void {
$cacheRoot = (getenv('CSWEB_ROOT') ?: '/var/www/html') . '/var/cache/ratelimit';
if (!is_dir($cacheRoot)) {
// Best-effort: if mkdir fails, do not lock the user out — log and bypass.
if (!@mkdir($cacheRoot, 0775, true) && !is_dir($cacheRoot)) {
error_log('[webhook] rate-limit cache dir unavailable, skipping limit: ' . $cacheRoot);
return;
}
}
$file = $cacheRoot . '/' . $key . '.json';
$now = time();
$cutoff = $now - WEBHOOK_RATE_LIMIT_WINDOW;
$fh = @fopen($file, 'c+');
if (!$fh) {
error_log('[webhook] rate-limit fopen failed: ' . $file);
return;
}
if (!flock($fh, LOCK_EX)) {
fclose($fh);
error_log('[webhook] rate-limit flock failed: ' . $file);
return;
}
rewind($fh);
$contents = stream_get_contents($fh);
$hits = json_decode($contents ?: '[]', true);
if (!is_array($hits)) {
$hits = [];
}
// Keep only timestamps within the window.
$hits = array_values(array_filter($hits, fn($t) => is_int($t) && $t >= $cutoff));
if (count($hits) >= WEBHOOK_RATE_LIMIT_MAX) {
// Compute Retry-After: seconds until the oldest hit exits the window.
$retryAfter = max(1, ($hits[0] + WEBHOOK_RATE_LIMIT_WINDOW) - $now);
flock($fh, LOCK_UN);
fclose($fh);
header('Retry-After: ' . $retryAfter);
respondError(
'rate_limited',
'Too many requests. Retry after ' . $retryAfter . ' seconds.',
429
);
}
$hits[] = $now;
ftruncate($fh, 0);
rewind($fh);
fwrite($fh, json_encode($hits));
fflush($fh);
flock($fh, LOCK_UN);
fclose($fh);
}
/**
* Read and decode the JSON request body, capped at WEBHOOK_BODY_MAX_BYTES.
* Returns null if the body is empty or invalid JSON; the caller decides how to react.
*
* @return array<string,mixed>|null
*/
function readJsonBody(): ?array {
$raw = file_get_contents('php://input', false, null, 0, WEBHOOK_BODY_MAX_BYTES);
if ($raw !== false && strlen($raw) >= WEBHOOK_BODY_MAX_BYTES) {
respondError(
'body_too_large',
'Request body too large (max ' . WEBHOOK_BODY_MAX_BYTES . ' bytes).',
413
);
}
if ($raw === false || $raw === '') {
return null;
}
try {
$decoded = json_decode($raw, true, 32, JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
respondError('invalid_body', 'Request body is not valid JSON.', 400);
}
return is_array($decoded) ? $decoded : null;
}