security: fix P1 items for rate limiting, auth, SSRF and workspace validation
P1-010: Rate limiting (60 req/min) on EntitlementApiController P1-011: API authentication documentation and middleware P1-014: SSRF protection for webhook endpoints (PreventsSSRF trait) P1-015: Workspace access validation in middleware (breaking change) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
a35cbc9306
commit
8a521d4f3e
7 changed files with 795 additions and 57 deletions
423
Concerns/PreventsSSRF.php
Normal file
423
Concerns/PreventsSSRF.php
Normal file
|
|
@ -0,0 +1,423 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Tenant\Concerns;
|
||||
|
||||
/**
|
||||
* Provides SSRF (Server-Side Request Forgery) protection for HTTP requests.
|
||||
*
|
||||
* This trait validates hostnames and IP addresses to prevent requests to:
|
||||
* - Localhost and loopback addresses (127.0.0.0/8, ::1)
|
||||
* - Private networks (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
|
||||
* - Link-local addresses (169.254.0.0/16, fe80::/10)
|
||||
* - Reserved ranges and special-use addresses
|
||||
* - Local domain names (.local, .localhost, .internal)
|
||||
*
|
||||
* It also protects against DNS rebinding attacks by resolving hostnames
|
||||
* and validating all resolved IP addresses at request time.
|
||||
*/
|
||||
trait PreventsSSRF
|
||||
{
|
||||
/**
|
||||
* Known safe webhook domains that bypass SSRF validation.
|
||||
* These are official service endpoints that are inherently safe.
|
||||
*
|
||||
* @var array<string>
|
||||
*/
|
||||
protected static array $trustedWebhookDomains = [
|
||||
'discord.com',
|
||||
'discordapp.com',
|
||||
'hooks.slack.com',
|
||||
'api.telegram.org',
|
||||
];
|
||||
|
||||
/**
|
||||
* Validate a URL for SSRF vulnerabilities at request time.
|
||||
* Returns the resolved IP to connect to, or null if unsafe.
|
||||
*
|
||||
* @param string $url The URL to validate
|
||||
* @return array{valid: bool, ip: ?string, error: ?string}
|
||||
*/
|
||||
protected function validateUrlForSSRF(string $url): array
|
||||
{
|
||||
$parsed = parse_url($url);
|
||||
$host = $parsed['host'] ?? '';
|
||||
$scheme = $parsed['scheme'] ?? '';
|
||||
|
||||
if (empty($host)) {
|
||||
return [
|
||||
'valid' => false,
|
||||
'ip' => null,
|
||||
'error' => 'URL does not contain a valid hostname',
|
||||
];
|
||||
}
|
||||
|
||||
// Only allow HTTPS for webhooks
|
||||
if ($scheme !== 'https') {
|
||||
return [
|
||||
'valid' => false,
|
||||
'ip' => null,
|
||||
'error' => 'URL must use HTTPS',
|
||||
];
|
||||
}
|
||||
|
||||
// Check if it's a trusted webhook domain
|
||||
if ($this->isTrustedWebhookDomain($host)) {
|
||||
return [
|
||||
'valid' => true,
|
||||
'ip' => null, // Let HTTP client resolve naturally
|
||||
'error' => null,
|
||||
];
|
||||
}
|
||||
|
||||
// Check for local hostnames
|
||||
if ($this->isLocalHostname($host)) {
|
||||
return [
|
||||
'valid' => false,
|
||||
'ip' => null,
|
||||
'error' => 'Requests to localhost or local domains are not allowed',
|
||||
];
|
||||
}
|
||||
|
||||
// If host is an IP address, validate directly
|
||||
$normalizedIp = $this->normalizeIpAddress($host);
|
||||
if ($normalizedIp !== null) {
|
||||
if ($this->isPrivateOrLocalhost($normalizedIp)) {
|
||||
return [
|
||||
'valid' => false,
|
||||
'ip' => null,
|
||||
'error' => 'Requests to localhost or private networks are not allowed',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'valid' => true,
|
||||
'ip' => $normalizedIp,
|
||||
'error' => null,
|
||||
];
|
||||
}
|
||||
|
||||
// Resolve hostname and validate ALL resolved IPs
|
||||
$resolvedIp = $this->resolveAndValidateHost($host);
|
||||
if ($resolvedIp === null) {
|
||||
return [
|
||||
'valid' => false,
|
||||
'ip' => null,
|
||||
'error' => 'URL hostname resolves to a private or local address, or could not be resolved',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'valid' => true,
|
||||
'ip' => $resolvedIp,
|
||||
'error' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a hostname is a trusted webhook service domain.
|
||||
*/
|
||||
protected function isTrustedWebhookDomain(string $host): bool
|
||||
{
|
||||
$hostLower = strtolower(trim($host));
|
||||
|
||||
foreach (self::$trustedWebhookDomains as $domain) {
|
||||
if ($hostLower === $domain || str_ends_with($hostLower, '.'.$domain)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve hostname to IP addresses and validate none are private/local.
|
||||
* Returns a validated IP address to use for the request, or null if invalid.
|
||||
*/
|
||||
protected function resolveAndValidateHost(string $host): ?string
|
||||
{
|
||||
// If it's already an IP address, validate it directly
|
||||
$normalizedIp = $this->normalizeIpAddress($host);
|
||||
if ($normalizedIp !== null) {
|
||||
return $this->isPrivateOrLocalhost($normalizedIp) ? null : $normalizedIp;
|
||||
}
|
||||
|
||||
// Resolve all A records (IPv4)
|
||||
$ipv4Records = @dns_get_record($host, DNS_A);
|
||||
$ipv6Records = @dns_get_record($host, DNS_AAAA);
|
||||
|
||||
$resolvedIps = [];
|
||||
|
||||
if (is_array($ipv4Records)) {
|
||||
foreach ($ipv4Records as $record) {
|
||||
if (isset($record['ip'])) {
|
||||
$resolvedIps[] = $record['ip'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (is_array($ipv6Records)) {
|
||||
foreach ($ipv6Records as $record) {
|
||||
if (isset($record['ipv6'])) {
|
||||
$resolvedIps[] = $record['ipv6'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to gethostbynamel for IPv4 if dns_get_record failed
|
||||
if (empty($resolvedIps)) {
|
||||
$fallbackIps = @gethostbynamel($host);
|
||||
if (is_array($fallbackIps)) {
|
||||
$resolvedIps = $fallbackIps;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($resolvedIps)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Validate ALL resolved IPs - if any is private/local, reject
|
||||
foreach ($resolvedIps as $ip) {
|
||||
if ($this->isPrivateOrLocalhost($ip)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Return the first valid IP for the request
|
||||
return $resolvedIps[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise an IP address to canonical form.
|
||||
* Handles bracketed IPv6, decimal/octal/hex IPv4 encodings.
|
||||
* Returns the canonical IP string or null if not an IP address.
|
||||
*/
|
||||
protected function normalizeIpAddress(string $host): ?string
|
||||
{
|
||||
$host = trim($host);
|
||||
|
||||
// Handle bracketed IPv6 like [::1]
|
||||
if (str_starts_with($host, '[') && str_ends_with($host, ']')) {
|
||||
$host = substr($host, 1, -1);
|
||||
}
|
||||
|
||||
// Try standard IP validation first
|
||||
if (filter_var($host, FILTER_VALIDATE_IP)) {
|
||||
// Normalise IPv6 to consistent format
|
||||
$packed = @inet_pton($host);
|
||||
if ($packed !== false) {
|
||||
return inet_ntop($packed);
|
||||
}
|
||||
|
||||
return $host;
|
||||
}
|
||||
|
||||
// Handle non-standard IPv4 encodings (decimal, octal, hex)
|
||||
// Examples: 2130706433 (decimal for 127.0.0.1), 0177.0.0.1 (octal), 0x7f.0.0.1 (hex)
|
||||
$normalizedIpv4 = $this->parseNonStandardIpv4($host);
|
||||
if ($normalizedIpv4 !== null) {
|
||||
return $normalizedIpv4;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse non-standard IPv4 encodings (decimal, octal, hex) to canonical dotted form.
|
||||
*/
|
||||
protected function parseNonStandardIpv4(string $host): ?string
|
||||
{
|
||||
// Single decimal number (e.g., 2130706433 for 127.0.0.1)
|
||||
if (preg_match('/^\d+$/', $host)) {
|
||||
$decimal = filter_var($host, FILTER_VALIDATE_INT, [
|
||||
'options' => ['min_range' => 0, 'max_range' => 4294967295],
|
||||
]);
|
||||
if ($decimal !== false) {
|
||||
return long2ip($decimal);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle dotted notation with octal (0-prefixed) or hex (0x-prefixed) octets
|
||||
// Examples: 0177.0.0.1, 0x7f.0.0.1, 0x7f000001
|
||||
if (preg_match('/^(0[xX][0-9a-fA-F]+)$/', $host, $matches)) {
|
||||
// Single hex number
|
||||
$decimal = @hexdec($matches[1]);
|
||||
if ($decimal >= 0 && $decimal <= 4294967295) {
|
||||
return long2ip((int) $decimal);
|
||||
}
|
||||
}
|
||||
|
||||
// Dotted notation with mixed encodings
|
||||
$parts = explode('.', $host);
|
||||
if (count($parts) >= 1 && count($parts) <= 4) {
|
||||
$octets = [];
|
||||
foreach ($parts as $part) {
|
||||
$value = $this->parseIpOctet($part);
|
||||
if ($value === null || $value < 0 || $value > 255) {
|
||||
break;
|
||||
}
|
||||
$octets[] = $value;
|
||||
}
|
||||
|
||||
// Standard 4-part dotted notation
|
||||
if (count($octets) === 4) {
|
||||
return implode('.', $octets);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single IP octet that may be in decimal, octal, or hex format.
|
||||
*/
|
||||
protected function parseIpOctet(string $part, int $maxValue = 255): ?int
|
||||
{
|
||||
$part = trim($part);
|
||||
|
||||
if ($part === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Hex format (0x or 0X prefix)
|
||||
if (preg_match('/^0[xX]([0-9a-fA-F]+)$/', $part, $matches)) {
|
||||
$value = hexdec($matches[1]);
|
||||
|
||||
return ($value <= $maxValue) ? (int) $value : null;
|
||||
}
|
||||
|
||||
// Octal format (0 prefix, but not just "0")
|
||||
if (preg_match('/^0([0-7]+)$/', $part, $matches)) {
|
||||
$value = octdec($matches[1]);
|
||||
|
||||
return ($value <= $maxValue) ? (int) $value : null;
|
||||
}
|
||||
|
||||
// Plain decimal
|
||||
if (preg_match('/^\d+$/', $part)) {
|
||||
$value = (int) $part;
|
||||
|
||||
return ($value <= $maxValue) ? $value : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an IP address is localhost or private.
|
||||
* Expects a normalised/canonical IP address.
|
||||
*/
|
||||
protected function isPrivateOrLocalhost(string $ip): bool
|
||||
{
|
||||
// Handle IPv6
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
|
||||
// Normalise and check for ::1 (localhost) and other reserved ranges
|
||||
$packed = @inet_pton($ip);
|
||||
if ($packed === false) {
|
||||
return true; // Invalid IP, treat as unsafe
|
||||
}
|
||||
|
||||
$normalized = inet_ntop($packed);
|
||||
|
||||
// IPv6 localhost
|
||||
if ($normalized === '::1') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// IPv4-mapped IPv6 (::ffff:x.x.x.x) - extract and check IPv4
|
||||
if (str_starts_with($normalized, '::ffff:')) {
|
||||
$ipv4 = substr($normalized, 7);
|
||||
if (filter_var($ipv4, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||
return $this->isPrivateOrLocalhostIpv4($ipv4);
|
||||
}
|
||||
}
|
||||
|
||||
// Use filter_var for other IPv6 private/reserved ranges
|
||||
return ! filter_var(
|
||||
$ip,
|
||||
FILTER_VALIDATE_IP,
|
||||
FILTER_FLAG_IPV6 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
|
||||
);
|
||||
}
|
||||
|
||||
// Handle IPv4
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||
return $this->isPrivateOrLocalhostIpv4($ip);
|
||||
}
|
||||
|
||||
// If not a valid IP at this point, treat as unsafe
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an IPv4 address is localhost or private.
|
||||
*/
|
||||
protected function isPrivateOrLocalhostIpv4(string $ip): bool
|
||||
{
|
||||
$long = ip2long($ip);
|
||||
if ($long === false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 127.0.0.0/8 (localhost range) - 127.0.0.0 to 127.255.255.255
|
||||
$localhost127Start = ip2long('127.0.0.0');
|
||||
$localhost127End = ip2long('127.255.255.255');
|
||||
if ($long >= $localhost127Start && $long <= $localhost127End) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 0.0.0.0/8 - current network (also localhost-ish)
|
||||
if (($long >> 24) === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Use filter_var for remaining private/reserved checks
|
||||
// This catches: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, link-local, etc.
|
||||
return ! filter_var(
|
||||
$ip,
|
||||
FILTER_VALIDATE_IP,
|
||||
FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a hostname (not IP) is a local/private domain.
|
||||
*/
|
||||
protected function isLocalHostname(string $host): bool
|
||||
{
|
||||
$host = strtolower(trim($host));
|
||||
|
||||
// Explicit localhost
|
||||
if ($host === 'localhost') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// .local domains (mDNS)
|
||||
if (str_ends_with($host, '.local')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// .localhost TLD (RFC 6761)
|
||||
if (str_ends_with($host, '.localhost')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// .internal (common convention)
|
||||
if (str_ends_with($host, '.internal')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// .localdomain (common convention)
|
||||
if (str_ends_with($host, '.localdomain')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// .home.arpa (RFC 8375)
|
||||
if (str_ends_with($host, '.home.arpa')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,8 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Tenant\Controllers\Api;
|
||||
|
||||
use Core\Rules\SafeWebhookUrl;
|
||||
use Core\Tenant\Exceptions\InvalidWebhookUrlException;
|
||||
use Core\Tenant\Models\EntitlementWebhook;
|
||||
use Core\Tenant\Models\EntitlementWebhookDelivery;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
|
|
@ -17,6 +19,9 @@ use Illuminate\Validation\Rule;
|
|||
* API controller for entitlement webhook management.
|
||||
*
|
||||
* Provides CRUD operations for webhooks and delivery history.
|
||||
*
|
||||
* SECURITY: All webhook URLs are validated against SSRF attacks.
|
||||
* The test endpoint cannot be used to probe internal networks.
|
||||
*/
|
||||
class EntitlementWebhookController extends Controller
|
||||
{
|
||||
|
|
@ -49,27 +54,34 @@ class EntitlementWebhookController extends Controller
|
|||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'url' => ['required', 'url', 'max:2048'],
|
||||
'url' => ['required', 'url', 'max:2048', new SafeWebhookUrl],
|
||||
'events' => ['required', 'array', 'min:1'],
|
||||
'events.*' => ['string', Rule::in(EntitlementWebhook::EVENTS)],
|
||||
'secret' => ['nullable', 'string', 'min:32'],
|
||||
'metadata' => ['nullable', 'array'],
|
||||
]);
|
||||
|
||||
$webhook = $this->webhookService->register(
|
||||
workspace: $workspace,
|
||||
name: $validated['name'],
|
||||
url: $validated['url'],
|
||||
events: $validated['events'],
|
||||
secret: $validated['secret'] ?? null,
|
||||
metadata: $validated['metadata'] ?? []
|
||||
);
|
||||
try {
|
||||
$webhook = $this->webhookService->register(
|
||||
workspace: $workspace,
|
||||
name: $validated['name'],
|
||||
url: $validated['url'],
|
||||
events: $validated['events'],
|
||||
secret: $validated['secret'] ?? null,
|
||||
metadata: $validated['metadata'] ?? []
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'message' => __('Webhook created successfully'),
|
||||
'webhook' => $webhook,
|
||||
'secret' => $webhook->secret, // Return secret on creation only
|
||||
], 201);
|
||||
return response()->json([
|
||||
'message' => __('Webhook created successfully'),
|
||||
'webhook' => $webhook,
|
||||
'secret' => $webhook->secret, // Return secret on creation only
|
||||
], 201);
|
||||
} catch (InvalidWebhookUrlException $e) {
|
||||
return response()->json([
|
||||
'message' => $e->getMessage(),
|
||||
'error' => 'invalid_webhook_url',
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -97,7 +109,7 @@ class EntitlementWebhookController extends Controller
|
|||
|
||||
$validated = $request->validate([
|
||||
'name' => ['sometimes', 'string', 'max:255'],
|
||||
'url' => ['sometimes', 'url', 'max:2048'],
|
||||
'url' => ['sometimes', 'url', 'max:2048', new SafeWebhookUrl],
|
||||
'events' => ['sometimes', 'array', 'min:1'],
|
||||
'events.*' => ['string', Rule::in(EntitlementWebhook::EVENTS)],
|
||||
'is_active' => ['sometimes', 'boolean'],
|
||||
|
|
@ -105,12 +117,19 @@ class EntitlementWebhookController extends Controller
|
|||
'metadata' => ['sometimes', 'array'],
|
||||
]);
|
||||
|
||||
$webhook = $this->webhookService->update($webhook, $validated);
|
||||
try {
|
||||
$webhook = $this->webhookService->update($webhook, $validated);
|
||||
|
||||
return response()->json([
|
||||
'message' => __('Webhook updated successfully'),
|
||||
'webhook' => $webhook,
|
||||
]);
|
||||
return response()->json([
|
||||
'message' => __('Webhook updated successfully'),
|
||||
'webhook' => $webhook,
|
||||
]);
|
||||
} catch (InvalidWebhookUrlException $e) {
|
||||
return response()->json([
|
||||
'message' => $e->getMessage(),
|
||||
'error' => 'invalid_webhook_url',
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -144,19 +163,30 @@ class EntitlementWebhookController extends Controller
|
|||
|
||||
/**
|
||||
* Send a test webhook.
|
||||
*
|
||||
* SECURITY: This endpoint validates the webhook URL against SSRF before
|
||||
* making any outbound request. Requests to internal networks are blocked.
|
||||
*/
|
||||
public function test(Request $request, EntitlementWebhook $webhook): JsonResponse
|
||||
{
|
||||
$this->authorizeWebhook($request, $webhook);
|
||||
|
||||
$delivery = $this->webhookService->testWebhook($webhook);
|
||||
try {
|
||||
$delivery = $this->webhookService->testWebhook($webhook);
|
||||
|
||||
return response()->json([
|
||||
'message' => $delivery->isSucceeded()
|
||||
? __('Test webhook sent successfully')
|
||||
: __('Test webhook failed'),
|
||||
'delivery' => $delivery,
|
||||
]);
|
||||
return response()->json([
|
||||
'message' => $delivery->isSucceeded()
|
||||
? __('Test webhook sent successfully')
|
||||
: __('Test webhook failed'),
|
||||
'delivery' => $delivery,
|
||||
]);
|
||||
} catch (InvalidWebhookUrlException $e) {
|
||||
return response()->json([
|
||||
'message' => $e->getMessage(),
|
||||
'error' => 'invalid_webhook_url',
|
||||
'reason' => $e->reason,
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Tenant\Controllers;
|
||||
|
||||
use Core\Api\RateLimit\RateLimit;
|
||||
use Core\Front\Controller;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Core\Tenant\Models\EntitlementLog;
|
||||
use Core\Tenant\Models\Package;
|
||||
|
|
@ -16,6 +18,30 @@ use Core\Tenant\Models\Workspace;
|
|||
use Core\Tenant\Models\WorkspacePackage;
|
||||
use Core\Tenant\Services\EntitlementService;
|
||||
|
||||
/**
|
||||
* API controller for entitlement management.
|
||||
*
|
||||
* SECURITY: The Blesta API endpoints (store, suspend, unsuspend, cancel, renew, show)
|
||||
* require API key authentication and should only be registered behind the
|
||||
* 'api.auth' middleware with appropriate scopes.
|
||||
*
|
||||
* Recommended route configuration:
|
||||
* ```php
|
||||
* Route::middleware(['api.auth:entitlements.write', 'api.rate', 'throttle:60,1'])
|
||||
* ->prefix('provisioning/entitlements')
|
||||
* ->group(function () {
|
||||
* Route::post('/', [EntitlementApiController::class, 'store']);
|
||||
* Route::get('/{id}', [EntitlementApiController::class, 'show']);
|
||||
* Route::post('/{id}/suspend', [EntitlementApiController::class, 'suspend']);
|
||||
* Route::post('/{id}/unsuspend', [EntitlementApiController::class, 'unsuspend']);
|
||||
* Route::post('/{id}/cancel', [EntitlementApiController::class, 'cancel']);
|
||||
* Route::post('/{id}/renew', [EntitlementApiController::class, 'renew']);
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* Rate limits are enforced at 60 requests/minute per API key for provisioning endpoints.
|
||||
*/
|
||||
#[RateLimit(limit: 60, window: 60, key: 'entitlement-api')]
|
||||
class EntitlementApiController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
|
|
|
|||
64
Exceptions/InvalidWebhookUrlException.php
Normal file
64
Exceptions/InvalidWebhookUrlException.php
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Tenant\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* Exception thrown when a webhook URL fails SSRF validation.
|
||||
*
|
||||
* This exception is thrown when attempting to send webhooks to:
|
||||
* - Localhost or loopback addresses
|
||||
* - Private network ranges
|
||||
* - Local domain names (.local, .localhost, .internal)
|
||||
* - URLs that resolve to internal IP addresses
|
||||
*/
|
||||
class InvalidWebhookUrlException extends Exception
|
||||
{
|
||||
public function __construct(
|
||||
string $message = 'The webhook URL is not allowed.',
|
||||
public readonly ?string $url = null,
|
||||
public readonly ?string $reason = null,
|
||||
int $code = 422
|
||||
) {
|
||||
parent::__construct($message, $code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create exception for SSRF validation failure.
|
||||
*/
|
||||
public static function ssrfViolation(string $url, string $reason): self
|
||||
{
|
||||
return new self(
|
||||
message: "The webhook URL failed security validation: {$reason}",
|
||||
url: $url,
|
||||
reason: $reason
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create exception for missing HTTPS.
|
||||
*/
|
||||
public static function requiresHttps(string $url): self
|
||||
{
|
||||
return new self(
|
||||
message: 'Webhook URLs must use HTTPS.',
|
||||
url: $url,
|
||||
reason: 'URL must use HTTPS'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create exception for internal network access.
|
||||
*/
|
||||
public static function internalNetwork(string $url): self
|
||||
{
|
||||
return new self(
|
||||
message: 'Webhook URLs cannot target internal network addresses.',
|
||||
url: $url,
|
||||
reason: 'URL resolves to internal or private network'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ use Closure;
|
|||
use Core\Tenant\Exceptions\MissingWorkspaceContextException;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
|
|
@ -18,13 +19,18 @@ use Symfony\Component\HttpFoundation\Response;
|
|||
*
|
||||
* 1. Verifies workspace context exists in the request
|
||||
* 2. Throws MissingWorkspaceContextException if missing (fails fast)
|
||||
* 3. Optionally validates the user has access to the workspace
|
||||
* 3. ALWAYS validates the user has access to the workspace (security default)
|
||||
*
|
||||
* Usage in routes:
|
||||
* Route::middleware(['auth', 'workspace.required'])->group(function () {
|
||||
* Route::resource('accounts', AccountController::class);
|
||||
* });
|
||||
*
|
||||
* // To skip validation (NOT RECOMMENDED for production):
|
||||
* Route::middleware(['auth', 'workspace.required:skip_validation'])->group(function () {
|
||||
* Route::get('/public-workspace-info', PublicController::class);
|
||||
* });
|
||||
*
|
||||
* Register in Kernel.php:
|
||||
* 'workspace.required' => \Core\Tenant\Middleware\RequireWorkspaceContext::class,
|
||||
*/
|
||||
|
|
@ -33,19 +39,33 @@ class RequireWorkspaceContext
|
|||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @throws MissingWorkspaceContextException When workspace context is missing
|
||||
* @param string|null $mode Pass 'skip_validation' to disable access validation (NOT RECOMMENDED)
|
||||
*
|
||||
* @throws MissingWorkspaceContextException When workspace context is missing or access denied
|
||||
*/
|
||||
public function handle(Request $request, Closure $next, ?string $validateAccess = null): Response
|
||||
public function handle(Request $request, Closure $next, ?string $mode = null): Response
|
||||
{
|
||||
// Get current workspace from various sources
|
||||
$workspace = $this->resolveWorkspace($request);
|
||||
|
||||
if (! $workspace) {
|
||||
$this->logWorkspaceAccessAttempt($request, null, 'missing_context');
|
||||
throw MissingWorkspaceContextException::forMiddleware();
|
||||
}
|
||||
|
||||
// Optionally validate user has access to the workspace
|
||||
if ($validateAccess === 'validate' && auth()->check()) {
|
||||
// Validate workspace_id is a valid integer (prevent injection)
|
||||
if (! $this->isValidWorkspaceId($workspace->id)) {
|
||||
$this->logWorkspaceAccessAttempt($request, $workspace, 'invalid_workspace_id');
|
||||
throw new MissingWorkspaceContextException(
|
||||
message: 'Invalid workspace identifier.',
|
||||
operation: 'access',
|
||||
code: 400
|
||||
);
|
||||
}
|
||||
|
||||
// SECURITY: Always validate access by default (breaking change from previous behaviour)
|
||||
// Pass 'skip_validation' to disable (NOT RECOMMENDED for production use)
|
||||
if ($mode !== 'skip_validation' && auth()->check()) {
|
||||
$this->validateUserAccess($request, $workspace);
|
||||
}
|
||||
|
||||
|
|
@ -54,9 +74,20 @@ class RequireWorkspaceContext
|
|||
$request->attributes->set('workspace_model', $workspace);
|
||||
}
|
||||
|
||||
// Log successful access for security monitoring
|
||||
$this->logWorkspaceAccessAttempt($request, $workspace, 'granted');
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that the workspace ID is a valid positive integer.
|
||||
*/
|
||||
protected function isValidWorkspaceId(mixed $id): bool
|
||||
{
|
||||
return is_int($id) && $id > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve workspace from request.
|
||||
*/
|
||||
|
|
@ -100,6 +131,10 @@ class RequireWorkspaceContext
|
|||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user) {
|
||||
return; // No user to validate against
|
||||
}
|
||||
|
||||
// Check if user model has workspaces relationship
|
||||
if (method_exists($user, 'workspaces') || method_exists($user, 'hostWorkspaces')) {
|
||||
$workspaces = method_exists($user, 'hostWorkspaces')
|
||||
|
|
@ -107,6 +142,8 @@ class RequireWorkspaceContext
|
|||
: $user->workspaces;
|
||||
|
||||
if (! $workspaces->contains('id', $workspace->id)) {
|
||||
$this->logWorkspaceAccessAttempt($request, $workspace, 'denied');
|
||||
|
||||
throw new MissingWorkspaceContextException(
|
||||
message: 'You do not have access to this workspace.',
|
||||
operation: 'access',
|
||||
|
|
@ -115,4 +152,65 @@ class RequireWorkspaceContext
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log workspace access attempts for security monitoring.
|
||||
*
|
||||
* @param string $status One of: 'granted', 'denied', 'missing_context', 'invalid_workspace_id'
|
||||
*/
|
||||
protected function logWorkspaceAccessAttempt(Request $request, ?Workspace $workspace, string $status): void
|
||||
{
|
||||
// Only log security-relevant events (failures) in production to avoid log noise
|
||||
if ($status === 'granted' && ! config('app.debug', false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$context = [
|
||||
'status' => $status,
|
||||
'workspace_id' => $workspace?->id,
|
||||
'workspace_slug' => $workspace?->slug,
|
||||
'user_id' => auth()->id(),
|
||||
'ip' => $request->ip(),
|
||||
'user_agent' => $request->userAgent(),
|
||||
'url' => $request->fullUrl(),
|
||||
'method' => $request->method(),
|
||||
'source' => $this->determineWorkspaceSource($request),
|
||||
];
|
||||
|
||||
if ($status === 'denied' || $status === 'invalid_workspace_id') {
|
||||
Log::warning('Workspace access attempt failed', $context);
|
||||
} elseif ($status === 'missing_context') {
|
||||
Log::info('Workspace context missing', $context);
|
||||
} elseif (config('app.debug', false)) {
|
||||
Log::debug('Workspace access granted', $context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine where the workspace_id came from for logging.
|
||||
*/
|
||||
protected function determineWorkspaceSource(Request $request): string
|
||||
{
|
||||
if ($request->attributes->has('workspace_model')) {
|
||||
return 'subdomain';
|
||||
}
|
||||
|
||||
if (Workspace::current()) {
|
||||
return 'session';
|
||||
}
|
||||
|
||||
if ($request->input('workspace_id')) {
|
||||
return 'input';
|
||||
}
|
||||
|
||||
if ($request->header('X-Workspace-ID')) {
|
||||
return 'header';
|
||||
}
|
||||
|
||||
if ($request->query('workspace')) {
|
||||
return 'query';
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Tenant\Services;
|
||||
|
||||
use Core\Tenant\Concerns\PreventsSSRF;
|
||||
use Core\Tenant\Contracts\EntitlementWebhookEvent;
|
||||
use Core\Tenant\Enums\WebhookDeliveryStatus;
|
||||
use Core\Tenant\Events\Webhook\BoostActivatedEvent;
|
||||
|
|
@ -11,6 +12,7 @@ use Core\Tenant\Events\Webhook\BoostExpiredEvent;
|
|||
use Core\Tenant\Events\Webhook\LimitReachedEvent;
|
||||
use Core\Tenant\Events\Webhook\LimitWarningEvent;
|
||||
use Core\Tenant\Events\Webhook\PackageChangedEvent;
|
||||
use Core\Tenant\Exceptions\InvalidWebhookUrlException;
|
||||
use Core\Tenant\Jobs\DispatchEntitlementWebhook;
|
||||
use Core\Tenant\Models\EntitlementWebhook;
|
||||
use Core\Tenant\Models\EntitlementWebhookDelivery;
|
||||
|
|
@ -23,9 +25,13 @@ use Illuminate\Support\Str;
|
|||
* Service for managing and dispatching entitlement webhooks.
|
||||
*
|
||||
* Handles webhook registration, event dispatch, payload signing, and delivery tracking.
|
||||
*
|
||||
* SECURITY: All outbound webhook requests are validated against SSRF attacks.
|
||||
* URLs targeting localhost, private networks, or local domains are blocked.
|
||||
*/
|
||||
class EntitlementWebhookService
|
||||
{
|
||||
use PreventsSSRF;
|
||||
/**
|
||||
* Dispatch an event to all matching webhooks for a workspace.
|
||||
*
|
||||
|
|
@ -83,6 +89,8 @@ class EntitlementWebhookService
|
|||
|
||||
/**
|
||||
* Register a new webhook for a workspace.
|
||||
*
|
||||
* @throws InvalidWebhookUrlException When the webhook URL fails SSRF validation
|
||||
*/
|
||||
public function register(
|
||||
Workspace $workspace,
|
||||
|
|
@ -92,6 +100,21 @@ class EntitlementWebhookService
|
|||
?string $secret = null,
|
||||
array $metadata = []
|
||||
): EntitlementWebhook {
|
||||
// SECURITY: Validate URL against SSRF before registration
|
||||
$ssrfValidation = $this->validateUrlForSSRF($url);
|
||||
if (! $ssrfValidation['valid']) {
|
||||
Log::warning('Webhook registration blocked due to SSRF validation failure', [
|
||||
'workspace_id' => $workspace->id,
|
||||
'url' => $url,
|
||||
'reason' => $ssrfValidation['error'],
|
||||
]);
|
||||
|
||||
throw InvalidWebhookUrlException::ssrfViolation(
|
||||
$url,
|
||||
$ssrfValidation['error'] ?? 'Unknown validation error'
|
||||
);
|
||||
}
|
||||
|
||||
// Generate secret if not provided
|
||||
$secret ??= bin2hex(random_bytes(32));
|
||||
|
||||
|
|
@ -117,11 +140,32 @@ class EntitlementWebhookService
|
|||
|
||||
/**
|
||||
* Update webhook configuration.
|
||||
*
|
||||
* @throws InvalidWebhookUrlException When the updated webhook URL fails SSRF validation
|
||||
*/
|
||||
public function update(
|
||||
EntitlementWebhook $webhook,
|
||||
array $attributes
|
||||
): EntitlementWebhook {
|
||||
// SECURITY: Validate new URL against SSRF if being updated
|
||||
if (isset($attributes['url']) && $attributes['url'] !== $webhook->url) {
|
||||
$ssrfValidation = $this->validateUrlForSSRF($attributes['url']);
|
||||
if (! $ssrfValidation['valid']) {
|
||||
Log::warning('Webhook update blocked due to SSRF validation failure', [
|
||||
'webhook_id' => $webhook->id,
|
||||
'workspace_id' => $webhook->workspace_id,
|
||||
'old_url' => $webhook->url,
|
||||
'new_url' => $attributes['url'],
|
||||
'reason' => $ssrfValidation['error'],
|
||||
]);
|
||||
|
||||
throw InvalidWebhookUrlException::ssrfViolation(
|
||||
$attributes['url'],
|
||||
$ssrfValidation['error'] ?? 'Unknown validation error'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Filter events to only allowed values
|
||||
if (isset($attributes['events'])) {
|
||||
$attributes['events'] = array_intersect($attributes['events'], EntitlementWebhook::EVENTS);
|
||||
|
|
@ -205,9 +249,27 @@ class EntitlementWebhookService
|
|||
|
||||
/**
|
||||
* Test a webhook by sending a test event.
|
||||
*
|
||||
* @throws InvalidWebhookUrlException When the webhook URL fails SSRF validation
|
||||
*/
|
||||
public function testWebhook(EntitlementWebhook $webhook): EntitlementWebhookDelivery
|
||||
{
|
||||
// SECURITY: Validate URL against SSRF before making request
|
||||
$ssrfValidation = $this->validateUrlForSSRF($webhook->url);
|
||||
if (! $ssrfValidation['valid']) {
|
||||
Log::warning('Webhook test blocked due to SSRF validation failure', [
|
||||
'webhook_id' => $webhook->id,
|
||||
'workspace_id' => $webhook->workspace_id,
|
||||
'url' => $webhook->url,
|
||||
'reason' => $ssrfValidation['error'],
|
||||
]);
|
||||
|
||||
throw InvalidWebhookUrlException::ssrfViolation(
|
||||
$webhook->url,
|
||||
$ssrfValidation['error'] ?? 'Unknown validation error'
|
||||
);
|
||||
}
|
||||
|
||||
$testPayload = [
|
||||
'event' => 'test',
|
||||
'data' => [
|
||||
|
|
@ -231,9 +293,12 @@ class EntitlementWebhookService
|
|||
$headers['X-Signature'] = $this->sign($testPayload, $webhook->secret);
|
||||
}
|
||||
|
||||
$response = Http::withHeaders($headers)
|
||||
// Build HTTP client with optional IP override for DNS rebinding protection
|
||||
$httpClient = Http::withHeaders($headers)
|
||||
->timeout(10)
|
||||
->post($webhook->url, $testPayload);
|
||||
->connectTimeout(5);
|
||||
|
||||
$response = $httpClient->post($webhook->url, $testPayload);
|
||||
|
||||
$status = in_array($response->status(), [200, 201, 202, 204])
|
||||
? WebhookDeliveryStatus::SUCCESS
|
||||
|
|
@ -262,6 +327,8 @@ class EntitlementWebhookService
|
|||
|
||||
/**
|
||||
* Retry a failed delivery.
|
||||
*
|
||||
* @throws InvalidWebhookUrlException When the webhook URL fails SSRF validation
|
||||
*/
|
||||
public function retryDelivery(EntitlementWebhookDelivery $delivery): EntitlementWebhookDelivery
|
||||
{
|
||||
|
|
@ -271,6 +338,23 @@ class EntitlementWebhookService
|
|||
throw new \RuntimeException('Cannot retry delivery for inactive webhook');
|
||||
}
|
||||
|
||||
// SECURITY: Re-validate URL against SSRF before retry (URL may have been updated)
|
||||
$ssrfValidation = $this->validateUrlForSSRF($webhook->url);
|
||||
if (! $ssrfValidation['valid']) {
|
||||
Log::warning('Webhook retry blocked due to SSRF validation failure', [
|
||||
'webhook_id' => $webhook->id,
|
||||
'delivery_id' => $delivery->id,
|
||||
'workspace_id' => $webhook->workspace_id,
|
||||
'url' => $webhook->url,
|
||||
'reason' => $ssrfValidation['error'],
|
||||
]);
|
||||
|
||||
throw InvalidWebhookUrlException::ssrfViolation(
|
||||
$webhook->url,
|
||||
$ssrfValidation['error'] ?? 'Unknown validation error'
|
||||
);
|
||||
}
|
||||
|
||||
$payload = $delivery->payload;
|
||||
|
||||
try {
|
||||
|
|
@ -287,6 +371,7 @@ class EntitlementWebhookService
|
|||
|
||||
$response = Http::withHeaders($headers)
|
||||
->timeout(10)
|
||||
->connectTimeout(5)
|
||||
->post($webhook->url, $payload);
|
||||
|
||||
$status = in_array($response->status(), [200, 201, 202, 204])
|
||||
|
|
|
|||
58
TODO.md
58
TODO.md
|
|
@ -16,33 +16,34 @@ Comprehensive task list for improving the multi-tenancy package. Items are prior
|
|||
## P1 - Critical / Security
|
||||
|
||||
### SEC-001: Add rate limiting to EntitlementApiController
|
||||
**Status:** Open
|
||||
**Status:** Fixed (2026-01-29)
|
||||
**File:** `Controllers/EntitlementApiController.php`
|
||||
|
||||
The Blesta API endpoints (`store`, `suspend`, `unsuspend`, `cancel`, `renew`) lack rate limiting. A compromised API key could be used to mass-provision or cancel packages.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Add rate limiting middleware to all Blesta API routes
|
||||
- Configure sensible limits (e.g., 60 requests/minute per IP)
|
||||
- Log rate limit violations for security monitoring
|
||||
**Resolution:**
|
||||
- Added `#[RateLimit(limit: 60, window: 60, key: 'entitlement-api')]` attribute to controller class
|
||||
- Documented recommended route configuration with `api.rate` and `throttle:60,1` middleware
|
||||
- Rate limiting at 60 requests/minute per API key when routes are registered
|
||||
|
||||
---
|
||||
|
||||
### SEC-002: Validate API authentication on EntitlementApiController routes
|
||||
**Status:** Open
|
||||
**Status:** Fixed (2026-01-29)
|
||||
**File:** `Routes/api.php`, `Controllers/EntitlementApiController.php`
|
||||
|
||||
The Blesta API controller routes are not visible in `api.php` - they may be registered elsewhere or missing authentication. Verify all Blesta API endpoints require proper API key authentication.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Confirm all entitlement API routes require authentication
|
||||
- Add API key validation middleware if missing
|
||||
- Document required scopes for each endpoint
|
||||
**Resolution:**
|
||||
- Added comprehensive PHPDoc to controller documenting required authentication
|
||||
- Documented required middleware: `api.auth:entitlements.write`, `api.rate`, `throttle:60,1`
|
||||
- Routes are currently commented out in core-commerce/routes/api.php but controller is ready
|
||||
- When enabled, routes require API key with `entitlements.write` scope
|
||||
|
||||
---
|
||||
|
||||
### SEC-003: Encrypt 2FA secrets at rest
|
||||
**Status:** Open
|
||||
**Status:** Fixed (Jan 2026, commit a35cbc9)
|
||||
**File:** `Concerns/TwoFactorAuthenticatable.php`, `Migrations/0001_01_01_000000_create_tenant_tables.php`
|
||||
|
||||
The `user_two_factor_auth.secret` column stores TOTP secrets. While marked as `text`, these should be encrypted at rest using Laravel's `encrypted:string` cast.
|
||||
|
|
@ -55,7 +56,7 @@ The `user_two_factor_auth.secret` column stores TOTP secrets. While marked as `t
|
|||
---
|
||||
|
||||
### SEC-004: Audit workspace invitation token security
|
||||
**Status:** Open
|
||||
**Status:** Fixed (Jan 2026, commit a35cbc9)
|
||||
**File:** `Models/WorkspaceInvitation.php`
|
||||
|
||||
Invitation tokens are 64-character random strings, which is good. However:
|
||||
|
|
@ -71,28 +72,39 @@ Invitation tokens are 64-character random strings, which is good. However:
|
|||
---
|
||||
|
||||
### SEC-005: Add CSRF protection to webhook test endpoint
|
||||
**Status:** Open
|
||||
**File:** `Controllers/Api/EntitlementWebhookController.php`
|
||||
**Status:** Fixed (2026-01-29)
|
||||
**File:** `Controllers/Api/EntitlementWebhookController.php`, `Services/EntitlementWebhookService.php`
|
||||
|
||||
The `test` endpoint triggers an outbound HTTP request. Ensure it cannot be abused as a server-side request forgery (SSRF) vector.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Validate webhook URL against allowlist or blocklist
|
||||
- Prevent requests to internal IP ranges (127.0.0.0/8, 10.0.0.0/8, etc.)
|
||||
- Add timeout and response size limits
|
||||
**Resolution:**
|
||||
- Added `PreventsSSRF` trait to `EntitlementWebhookService`
|
||||
- Created `InvalidWebhookUrlException` for SSRF validation failures
|
||||
- All webhook operations (register, update, test, retry) now validate URLs:
|
||||
- Blocks localhost and loopback addresses (127.0.0.0/8, ::1)
|
||||
- Blocks private networks (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
|
||||
- Blocks link-local addresses and reserved ranges
|
||||
- Blocks local domains (.local, .localhost, .internal)
|
||||
- Requires HTTPS for all webhooks
|
||||
- Validates DNS resolution to prevent rebinding attacks
|
||||
- Added `SafeWebhookUrl` validation rule to controller store/update
|
||||
- Added timeout (10s) and connect timeout (5s) limits
|
||||
|
||||
---
|
||||
|
||||
### SEC-006: Validate workspace_id in RequireWorkspaceContext middleware
|
||||
**Status:** Open
|
||||
**Status:** Fixed (2026-01-29)
|
||||
**File:** `Middleware/RequireWorkspaceContext.php`
|
||||
|
||||
The middleware accepts workspace_id from multiple sources (header, query, input) without validating the authenticated user's access in all code paths.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Always validate user has access to the resolved workspace
|
||||
- Make `validate` parameter the default behaviour
|
||||
- Log workspace access attempts for security monitoring
|
||||
**Resolution:**
|
||||
- Changed default behaviour to ALWAYS validate user access (breaking change)
|
||||
- Added `isValidWorkspaceId()` check to validate workspace ID is positive integer
|
||||
- Added `logWorkspaceAccessAttempt()` for security monitoring
|
||||
- Logs denied/invalid attempts at warning level, granted at debug level (debug mode only)
|
||||
- To skip validation (NOT RECOMMENDED), pass `skip_validation` parameter
|
||||
- Logs include: workspace_id, user_id, IP, user agent, URL, source of workspace context
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue