Four interrelated gaps in the same rate-limiting path:
- #80: RATE_LIMIT_DEFAULT/RATE_LIMIT_API were defined in config.php but
RateLimitMiddleware never read them (hardcoded class constants
instead), and they weren't in .env.example — a deployer editing them
saw zero effect with no documented way to actually change the limit.
- #81: Bearer traffic was rate-limited purely by a shared IP bucket
(the session-based half was a no-op for stateless clients, since a
fresh session starts on every request). Two different API keys from
the same host/NAT egress IP shared ONE bucket, so a chatty or
misbehaving key could 429 a completely unrelated key's traffic.
- #82: X-RateLimit-* headers reported the meaningless session counter
for Bearer clients instead of whatever bucket actually governed them.
- #83: RateLimitMiddleware::check() called session_start()
unconditionally, before ApiKeyAuth even runs — continuous session-file
churn and an unnecessary Set-Cookie on every stateless API request,
using un-hardened cookie defaults since it runs before
AuthMiddleware's hardening (which Bearer requests never reach anyway).
Fixed as one pass since they're the same code path: config.php now
reads RATE_LIMIT_DEFAULT/RATE_LIMIT_API from .env (added there too,
documented); the middleware now extracts the raw Bearer token
(independent of ApiKeyAuth, so no DB round-trip needed before rate
limiting, and it works whether or not the token later turns out
valid) and rate-limits it via its own per-token bucket instead of
starting a session — the existing IP-based bucket still applies
underneath as defense-in-depth against volumetric abuse from one
network path, but each distinct key now gets real isolated headroom.
getStatus()/addHeaders() report that per-token bucket for Bearer
requests instead of the session counter.
Verified: a Bearer request creates zero session files (confirmed via
real session-directory file count before/after); two different keys
from different IPs are fully isolated (one exhausting its own 120/min
bucket has zero effect on the other); a config-driven RATE_LIMIT_API
override (e.g. 5) is correctly honored for session-based (non-Bearer)
traffic; X-RateLimit-* status correctly reflects the per-key bucket
for a Bearer request.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
AuthMiddleware::authenticate() only re-read Remote-User/Remote-Groups
(and thus is_admin, via UserModel::syncUserFromAuthelia) when
$_SESSION['user'] didn't exist yet. Once a session existed, every
subsequent request only checked the idle timer — never re-validating
against current Authelia/LLDAP state. An admin's group membership
revoked in LLDAP, or a logout at the Authelia proxy, left their
already-open session with full access for up to SESSION_TIMEOUT (5h
default), with no way to force early revocation short of clearing the
server-side session store.
Added PRIVILEGE_RESYNC_INTERVAL (default 5 min, matching
UserModel's own cache TTL) and a resyncPrivileges() check on every
already-authenticated request past that interval: re-reads the current
request's forward-auth headers (enforcing the trusted-proxy check
again, same as a fresh login), and either destroys the session and
redirects to re-auth if the user no longer has any required group, or
re-syncs is_admin/groups/display_name/email if they do. Best-effort if
this particular request doesn't carry forward-auth headers at all
(skips silently rather than force-logging out, retried next interval).
UserModel::syncUserFromAuthelia() has its own 5-minute in-process
cache keyed only by username (not by the groups being synced), so a
naive re-call during a resync would have kept returning the
pre-revocation cached result for up to 5 more minutes — invalidated
that cache entry immediately beforehand to guarantee a real re-sync.
Verified against real MariaDB across a fresh login, a same-interval
request confirming no premature resync, an admin-privilege-revocation
mid-session (is_admin flips to false in both session and DB, verified
via a direct query), and a full group-membership revocation (session
destroyed, redirected to re-auth, confirmed the request never reaches
past that point).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
api/upload_attachment.php enforced a per-file size cap but nothing
bounded the total number of attachments on a single ticket or their
cumulative size over time — an authenticated low-privilege user could
slowly fill the uploads/ disk by attaching many files across tickets,
bounded only by the general rate limiter (which throttles request
rate, not storage volume).
Added MAX_ATTACHMENTS_PER_TICKET (50) and
MAX_TOTAL_ATTACHMENT_SIZE_PER_TICKET (100MB) config defaults, enforced
before move_uploaded_file() using AttachmentModel::getAttachmentCount()
and getTotalSizeForTicket() — both already existed in the model with
zero callers, apparently added for exactly this purpose but never
wired in.
Verified against a local MariaDB instance: with 3 existing 1MB
attachments and a 3-attachment cap, the count check correctly rejects
a 4th; with a 5MB total cap, a 2.5MB upload that would push the ticket
over the limit is correctly rejected while a small one that fits is
not.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
config/requirements.php only checked PHP version and 6 extensions. A
deployment on a host with a low default memory_limit (e.g. shared-
hosting-style 128M) passed the startup requirements check cleanly and
only surfaced as a mysterious failure under real load — a large CSV
export, an oversized dashboard query on a big install.
Added min_memory_limit_mb (256) and min_max_execution_time (30s)
thresholds to config/requirements.php, checked as warnings (not hard
failures, since a low limit doesn't break every request) in both
scripts/check_requirements.php (CI) and api/health.php (production
monitoring). -1/0 (unlimited) always passes.
Verified the ini-size parsing and warning logic directly with
low/high/unlimited memory_limit and max_execution_time values.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
- Database.php: pin MySQL session time_zone to the configured named zone
(mysql.time_zone tables now loaded on the DB) with a fixed-offset
fallback, so NOW()/TIMESTAMP and PHP agree regardless of the DB server's
SYSTEM tz. Best-effort, never fatals the connection.
- NotificationHelper: redact comment-body previews for internal/
confidential tickets in sendCommentNotification and notifyWatchers so
they are not leaked to the shared Matrix notify list (new $visibility
param; callers wired in the API batch).
- config.php: die with a clear error if parse_ini_file fails instead of
silently falling back to insecure defaults (empty DB pass / proxies).
- CacheHelper: create cache dir 0700 and cache files 0600 so other local
users cannot read or poison security-relevant cached data.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Trusted-proxy hardening (defense-in-depth for Authelia forward-auth):
- AuthMiddleware now only honors Remote-* identity headers when REMOTE_ADDR
is in a configured TRUSTED_PROXIES allowlist; otherwise it refuses with 403
and logs an 'untrusted_proxy' security event. Previously anything that could
reach PHP directly could spoof Remote-User/Remote-Groups and log in as admin.
- New config TRUSTED_PROXIES (comma-separated, from .env). Empty = enforcement
off, so this is backward compatible until the allowlist is set on a host.
Requirements checks (so a PHP upgrade dropping an extension can't silently
break features like avatars again):
- config/requirements.php: single source of truth for min PHP version and
required extensions (ldap, mysqli, curl, mbstring, fileinfo, json).
- scripts/check_requirements.php: CI script that fails the build if the
environment doesn't satisfy them.
- New 'requirements' CI job installs those extensions and runs the check;
deploy now depends on it.
- api/health.php: adds php_extensions + php_version checks so production
monitoring surfaces the drift (returns 503 if a required extension is gone).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Previously only dashboard/ticket assets were tracked, so changes to base.css
and base.js were never reflected in the cache-busting version string. Browsers
served stale cached copies, meaning the header padding-top fix never reached
users. Touch base files to bump mtime and force a cache miss immediately.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Comment pagination:
- CommentModel: add getCommentCount(), paginated getCommentsByTicketId()
with getThreadedCommentsPaged() for threading + LIMIT/OFFSET
- TicketController: load first 50 root comments + total count on page load
- api/get_comments.php: new AJAX endpoint for Load More (index.php routed)
- TicketView: Load More button + buildCommentEl() JS renderer for AJAX comments;
passes totalComments/commentOffset/isAdmin to window.ticketData
Matrix integration:
- NotificationHelper: add sendStatusChangeNotification(), sendCommentNotification(),
sendMentionNotification(), sendAssignmentNotification() alongside existing
sendTicketNotification(); internal fire() helper replaces duplicated cURL logic
- SynapseHelper: new helper that resolves SSO usernames → Matrix IDs by querying
Synapse Admin REST API directly (no caching, no stale data)
- config.php: add SYNAPSE_ADMIN_URL, SYNAPSE_ADMIN_TOKEN, MATRIX_NOTIFY_COMMENTS,
MATRIX_NOTIFY_ASSIGNMENTS config keys (all from .env)
- api/update_ticket.php: fire status-change notification after successful save
- api/add_comment.php: resolve @mentioned usernames via SynapseHelper and fire
mention notification; fire general comment notification when MATRIX_NOTIFY_COMMENTS=1
- api/assign_ticket.php: fire assignment notification (resolves assignee via Synapse)
when MATRIX_NOTIFY_ASSIGNMENTS=1
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Bug fixes:
- bulk-delete action called undefined bulkDelete() — wired to the
existing showBulkDeleteModal() so the confirmation modal actually shows
UX:
- Template loader now checks for existing title/description and asks
for confirmation before overwriting user-typed content
- Visibility select shows a dynamic hint paragraph that updates when
the user changes the selection (public/internal/confidential)
Architecture:
- TICKET_STATUSES added to config as single source of truth; all
hardcoded ['Open','Pending','In Progress','Closed'] arrays in
DashboardView now read from config; bulk-status modal in dashboard.js
reads window.TICKET_STATUSES (set from PHP) with array fallback
- ASSET_VERSION now auto-computed from max mtime of dashboard/ticket
CSS+JS files so browsers always pick up changes on deploy; manual
override still available via ASSET_VERSION in .env
- Removed 10 dead standalone stat methods from StatsModel (getOpenTicketCount,
getClosedTicketCount, getTicketsByPriority, etc.) — all superseded by
the consolidated fetchAllStats() queries, never called externally
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
P1-A: Fix CSP - add fonts.googleapis.com to style-src, fonts.gstatic.com to font-src
P1-B: CSRF token rotation - add rotateToken() to CsrfMiddleware; bootstrap.php rotates
after successful validation and stores in $GLOBALS['_new_csrf_token']; add
apiRespond() helper to append token to responses; lt.api interceptor in
layout_footer.php auto-updates window.CSRF_TOKEN from responses
P1-C: Styled 403/404 error views with TDS layout instead of raw text; index.php now
uses requireAdmin() helper eliminating 7 duplicated guard blocks (P3-D)
P2-A: Remove duplicate JS-generated keyboard help modal from keyboard-shortcuts.js;
'?' key now routes to static #lt-keys-help modal in footer
P2-B: Asset versioning driven by config ASSET_VERSION key; base.css and base.js get
?v= cache-busting in layout_header.php
P2-C: Add data-theme="dark" to <html> tag to prevent FOUC on light-mode users
P2-E: Escape status value in dashboard.js hover preview class attribute via lt.escHtml()
P2-F: Replace bespoke showLoadingOverlay() with lt-spinner / lt-loading-text from
base.css; add .lt-loading-overlay wrapper CSS to dashboard.css
P2-G: Add keyboard-shortcuts.js to all 7 admin views so J/K nav and ? help work
P3-A: APP_NAME, APP_SUBTITLE, APP_VERSION driven from config.php; layout header/footer
use config values instead of hardcoded strings
P3-G: Replace custom initTableSorting() with lt.sortTable.init() which manages aria-sort
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add helpers/NotificationHelper.php: shared Matrix webhook sender
that reads MATRIX_WEBHOOK_URL and MATRIX_NOTIFY_USERS from config
- Remove sendDiscordWebhook() from TicketController; call
NotificationHelper::sendTicketNotification() instead
- Replace 60-line Discord embed block in create_ticket_api.php
with a single NotificationHelper call
- config/config.php: DISCORD_WEBHOOK_URL → MATRIX_WEBHOOK_URL +
new MATRIX_NOTIFY_USERS key (comma-separated Matrix user IDs)
- .env.example: updated env var names and comments
Payload sent to hookshot includes notify_users array so the
JS transform can build proper @mention links for each user.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Security:
- Fix IDOR in delete/update comment (add ticket visibility check)
- XSS defense-in-depth in DashboardView active filters
- Replace innerHTML with DOM construction in toast.js
- Remove redundant real_escape_string in check_duplicates
- Add rate limiting to get_template, download_attachment, audit_log,
saved_filters, user_preferences endpoints
Bug fixes:
- Session timeout now reads from config instead of hardcoded 18000
- TicketController uses $GLOBALS['config'] instead of duplicate .env parsing
- Add DISCORD_WEBHOOK_URL to centralized config
- Cleanup script uses hashmap for O(1) ticket ID lookups
Dead code removal (~100 lines):
- Remove dead getTicketComments() from TicketModel (wrong bind_param type)
- Remove dead getCategories()/getTypes() from DashboardController
- Remove ~80 lines dead Discord webhook code from update_ticket API
Consolidation:
- Create api/bootstrap.php for shared API setup (auth, CSRF, rate limit)
- Convert 6 API endpoints to use bootstrap
- Extract escapeHtml/getTicketIdFromUrl into shared utils.js
- Batch save for user preferences (1 request instead of 7)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Change session.cookie_samesite from Strict to Lax for Authelia compatibility
- Redesign sidebar toggle with separate collapse/expand buttons
- Add script to create missing ticket_dependencies table
- Add .env.example template
- Add check for missing .env with helpful error message
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add TIMEZONE config option (default: America/New_York)
- Set PHP default timezone from config
- Add timezone offset and abbreviation for JavaScript
- Update stat card filters to use server timezone
- Add timezone config to Dashboard and Ticket views
Timezone can be changed via TIMEZONE env variable.
All dates now consistent with server timezone (EST by default).
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Updated parse_ini_file to use INI_SCANNER_TYPED
- Added quote stripping for all .env value parsing
- Fixes database connection and Discord webhook issues when values are quoted
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>