|
@@ -23,6 +23,17 @@ final class AuditController
|
|
|
private const ALLOWED_ACTOR_KINDS = ['user', 'admin-token', 'reporter', 'consumer', 'system'];
|
|
private const ALLOWED_ACTOR_KINDS = ['user', 'admin-token', 'reporter', 'consumer', 'system'];
|
|
|
private const ALLOWED_ACTOR_VIA = ['oidc', 'local', 'admin-token', 'service', 'reporter', 'consumer', 'system'];
|
|
private const ALLOWED_ACTOR_VIA = ['oidc', 'local', 'admin-token', 'service', 'reporter', 'consumer', 'system'];
|
|
|
|
|
|
|
|
|
|
+ // SEC_REVIEW F31: cap free-form filter strings so a caller can't push
|
|
|
|
|
+ // multi-megabyte garbage through the query planner. Audit values written
|
|
|
|
|
+ // by the system are well under these limits (action names ~30 chars,
|
|
|
|
|
+ // entity types/ids short identifiers); 128 is comfortable headroom.
|
|
|
|
|
+ private const MAX_FILTER_LENGTH = 128;
|
|
|
|
|
+
|
|
|
|
|
+ // SEC_REVIEW F31: cap pagination depth so a Viewer cannot force
|
|
|
|
|
+ // `LIMIT n OFFSET huge` deep scans. 10 000 rows is well past any
|
|
|
|
|
+ // human-driven browse and bounds the worst-case scan cost.
|
|
|
|
|
+ private const MAX_OFFSET = 10000;
|
|
|
|
|
+
|
|
|
public function __construct(private readonly AuditRepository $audit)
|
|
public function __construct(private readonly AuditRepository $audit)
|
|
|
{
|
|
{
|
|
|
}
|
|
}
|
|
@@ -65,15 +76,27 @@ final class AuditController
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
if (isset($params['action']) && is_string($params['action']) && $params['action'] !== '') {
|
|
if (isset($params['action']) && is_string($params['action']) && $params['action'] !== '') {
|
|
|
- $filters['action'] = $params['action'];
|
|
|
|
|
|
|
+ if (strlen($params['action']) > self::MAX_FILTER_LENGTH) {
|
|
|
|
|
+ $errors['action'] = 'must be at most ' . self::MAX_FILTER_LENGTH . ' characters';
|
|
|
|
|
+ } else {
|
|
|
|
|
+ $filters['action'] = $params['action'];
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
if (isset($params['entity_type']) && is_string($params['entity_type']) && $params['entity_type'] !== '') {
|
|
if (isset($params['entity_type']) && is_string($params['entity_type']) && $params['entity_type'] !== '') {
|
|
|
- $filters['entity_type'] = $params['entity_type'];
|
|
|
|
|
|
|
+ if (strlen($params['entity_type']) > self::MAX_FILTER_LENGTH) {
|
|
|
|
|
+ $errors['entity_type'] = 'must be at most ' . self::MAX_FILTER_LENGTH . ' characters';
|
|
|
|
|
+ } else {
|
|
|
|
|
+ $filters['entity_type'] = $params['entity_type'];
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
if (isset($params['entity_id']) && is_string($params['entity_id']) && $params['entity_id'] !== '') {
|
|
if (isset($params['entity_id']) && is_string($params['entity_id']) && $params['entity_id'] !== '') {
|
|
|
- $filters['entity_id'] = $params['entity_id'];
|
|
|
|
|
|
|
+ if (strlen($params['entity_id']) > self::MAX_FILTER_LENGTH) {
|
|
|
|
|
+ $errors['entity_id'] = 'must be at most ' . self::MAX_FILTER_LENGTH . ' characters';
|
|
|
|
|
+ } else {
|
|
|
|
|
+ $filters['entity_id'] = $params['entity_id'];
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// Subject filter (used by reporter/consumer detail pages):
|
|
// Subject filter (used by reporter/consumer detail pages):
|
|
@@ -84,6 +107,8 @@ final class AuditController
|
|
|
if ($subjectKind !== '' || $subjectId !== '') {
|
|
if ($subjectKind !== '' || $subjectId !== '') {
|
|
|
if ($subjectKind === '' || $subjectId === '') {
|
|
if ($subjectKind === '' || $subjectId === '') {
|
|
|
$errors['subject'] = 'subject_kind and subject_id must be supplied together';
|
|
$errors['subject'] = 'subject_kind and subject_id must be supplied together';
|
|
|
|
|
+ } elseif (strlen($subjectKind) > self::MAX_FILTER_LENGTH || strlen($subjectId) > self::MAX_FILTER_LENGTH) {
|
|
|
|
|
+ $errors['subject'] = 'subject_kind and subject_id must each be at most ' . self::MAX_FILTER_LENGTH . ' characters';
|
|
|
} else {
|
|
} else {
|
|
|
$filters['subject_kind'] = $subjectKind;
|
|
$filters['subject_kind'] = $subjectKind;
|
|
|
$filters['subject_id'] = $subjectId;
|
|
$filters['subject_id'] = $subjectId;
|
|
@@ -108,11 +133,18 @@ final class AuditController
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ $offset = ($page - 1) * $pageSize;
|
|
|
|
|
+ if ($offset > self::MAX_OFFSET) {
|
|
|
|
|
+ // SEC_REVIEW F31: deep-pagination guard. Past MAX_OFFSET we
|
|
|
|
|
+ // refuse rather than silently reset, so a client that wanted
|
|
|
|
|
+ // an old row notices and switches to a `to=` cursor instead.
|
|
|
|
|
+ $errors['page'] = 'pagination depth exceeded; use `to=` to cursor into older rows';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
if ($errors !== []) {
|
|
if ($errors !== []) {
|
|
|
return self::validationFailed($response, $errors);
|
|
return self::validationFailed($response, $errors);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- $offset = ($page - 1) * $pageSize;
|
|
|
|
|
$result = $this->audit->search($filters, $pageSize, $offset);
|
|
$result = $this->audit->search($filters, $pageSize, $offset);
|
|
|
|
|
|
|
|
return self::json($response, 200, [
|
|
return self::json($response, 200, [
|