Skip to content

Security Hardening 2026 08 Round Three Developer Guide

Ed Mozley edited this page Aug 12, 2026 · 3 revisions

Security hardening 2026-08 β€” Round three Developer Guide

The nine findings deliberately deferred when round two merged, now closed. Reported by Erlend Volden. The plain-language version is Security hardening 2026-08 β€” round three; rounds one and two are on Security and Security hardening 2026-08.

βœ… Status: merged into main at d26f9bbb and 980b0939. Not yet in a tagged release.

⚠️ §8 Outstanding is not a wishlist. The CSRF token layer and the LMS content directory are open, in main, today.

Every finding below is written as Risk β†’ Proof of concept β†’ Mitigation β†’ Why this fixes it. The proof-of-concept requests are the real ones; they are included because a finding you cannot reproduce is a finding you cannot verify, and because each has a corresponding assertion in the test suite.


1. πŸ“ The files involved

πŸ”΄ Cross-tenant access control (S2)

File What changed
api/tickets/delete_user.php Added analystCanAccessUser() above the ticket/asset counts
api/v1/resources/users.php Scoped the list (apiKeyTenantFilter), the GET and the PATCH (apiKeyCanAccessTenantRow)
api/integrations/escalate_ticket.php Moved the ticket check to the boundary, ahead of the preview branch
api/messaging/test_channel.php Added analystCanAccessChannel() + the missing includes/tenancy.php require
api/messaging/slack_diagnose.php Same pair

🟠 Fail-open access guard (S3's cousins)

File What changed
includes/services/tasks.php ticketAccessible()'s bare catch { return true; } β†’ tenancyDegradeAllowed($e)

🟠 MFA throttling (S6)

File What changed
includes/mfa_throttle.php New. The whole durable-counter implementation
api/myaccount/verify_login_otp.php Analyst code step: lock check, durable record, reset on success
api/self-service/verify_login_otp.php Portal twin of the same
includes/db_verify_schema.php mfa_failed_count, mfa_locked_until on analysts and users
database/freeitsm.sql Same two columns on both tables

🟑 Uploads, disclosure and server config (S9 + the two upload gaps)

File What changed
includes/uploads.php uploadPrepareWebServableDir() new; uploadStoreFile() fails closed without finfo; <handlers><clear/> removed from the generator
includes/ticket_merge.php Merge snapshot written via uploadStoreBytes() instead of file_put_contents()
api/system/save_branding.php SVG refused; UPLOAD_TYPES_IMAGE; no more move_uploaded_file()
api/tickets/get_attachment.php realpath() containment
api/self-service/get_attachment.php Prefix comparison tightened from strpos to strncmp with a separator
setup/index.php, lang/en/setup.php PHP version masked behind the existing safe_detail mechanism
includes/functions.php header_remove('X-Powered-By')
change-management/attachments/, contracts/rfp-builder/uploads/, recordings/, tickets/attachments/, war-room/attachments/ β€” web.config Γ—5 <handlers><clear/> removed
system/uploads/branding/.htaccess, web.config Web-servable policy; web.config now un-ignored so it ships

🟒 Database Verification honesty (F10)

File What changed
api/system/db_verify.php Header corrected; points at the preview
includes/db_verify_preview.php New. DB_VERIFY_DESTRUCTIVE register + inspection
api/system/db_verify_preview.php New. Read-only endpoint
system/db-verify/index.php, lang/en/system.php Preview button, backup notice, rendering

βšͺ Verification

File What changed
tests/security-findings/run.php +248 lines; 131 β†’ 183 checks

2. πŸ”΄ S2a β€” delete_user.php: no guard, and a counting oracle

Risk

No tenancy check of any kind. Any session holding tickets module access could delete any row in users.

The second half is subtler and survives a careless fix. The pre-delete referential checks are unscoped:

$stmt = $conn->prepare("SELECT COUNT(*) FROM tickets WHERE user_id = ?");
// …
'error' => "Cannot delete: this user is the requester on $ticketCount ticket(s)."

That is an accurate, authenticated read of another company's workload, returned inside a refusal.

Proof of concept

As an analyst scoped to company 4, against a contact owned by company 7:

curl -X POST .../api/tickets/delete_user.php -b "PHPSESSID=$SID" \
     -H 'Content-Type: application/json' -d '{"id":240}'

Before: deletion succeeds, or the refusal reports company 7's exact ticket count. After: {"success":false,"error":"User not found"}, and row 240 is untouched.

Mitigation

if (!analystCanAccessUser($conn, (int)$_SESSION['analyst_id'], $id)) {
    echo json_encode(['success' => false, 'error' => 'User not found']);
    exit;
}

placed immediately after connectToDatabase() and before the first SELECT COUNT(*).

Why this fixes it

Three separate properties, and the fix needs all three:

  1. It runs at all. analystCanAccessUser() handles single-company installs internally (returns true), so no isMultiTenant() short-circuit is needed at the call site β€” unlike save_user.php's create path, which calls analystCanAccessTenant() directly and does need one.
  2. It runs first. A guard beneath the counts would return the correct HTTP response while still having performed β€” and reported β€” the measurement. The test asserts the source position of the guard relative to the first count, and that assertion fails when the guard is moved down.
  3. It is indistinguishable from absence. User not found is the same string used for a non-existent id, verified by issuing both and diffing the responses.

3. πŸ”΄ S2b β€” the v1 users resource: three gaps, one reported

Risk

Reported as the PATCH /users/{id} twin of S1. In fact apiUsersList, apiUsersGet and apiUsersUpdate were all unscoped. apiUsersCreate was scoped, which is what made the file read as careful.

The PATCH is a privilege-escalation primitive rather than a data-integrity one: rewrite email on another company's requester, then drive the portal's password-reset flow against the address you now control.

The list is the larger disclosure β€” bulk enumeration of every requester with ?q= search.

Proof of concept

With an API key whose company_ids is [4]:

curl .../api/v1/users?per_page=100          -H "Authorization: Bearer $KEY"   # β†’ every user on the install
curl .../api/v1/users/240                    -H "Authorization: Bearer $KEY"   # β†’ company 7's requester
curl -X PATCH .../api/v1/users/240 -H "Authorization: Bearer $KEY" \
     -H 'Content-Type: application/json' -d '{"display_name":"PWNED"}'

After: the list reports meta.total = 1; both single-record calls return 404 not_found; row 240 still reads Smoke Victim B.

⚠️ The first negative-control run passed for the wrong reason. The key had permissions in the wrong shape, so all three calls returned 403 forbidden β€” a permissions failure, not the tenancy check. Every one of these would have looked "fixed". The control that caught it was the unscoped key: it returns meta.total = 35 and reads row 240 successfully, proving the scoped key's 1 is a filter rather than a broken endpoint.

Mitigation

// list
[$scopeSql, $scopeArgs] = apiKeyTenantFilter($conn, $apiKey, '');
$whereSql = implode(' AND ', $where) . $scopeSql;
$args     = array_merge($args, $scopeArgs);

// get + update
if (!apiKeyCanAccessTenantRow($conn, $apiKey, 'users', (int)$params[0])) {
    apiError(404, 'not_found', 'Requester not found.');
}

Why this fixes it

apiKeyCanAccessTenantRow() already existed, already handled company_scope === null (unscoped key) and single-company installs, and was already used by tickets and problems. Nothing new was reasoned about; the correct helper was simply never called here.

apiKeyTenantFilter($conn, $apiKey, '') with an empty alias emits a bare tenant_id predicate and already encodes the NULL-means-Default rule, so mailbox-less and unassigned requesters land where they should.

Note that the PATCH's existing check tested the destination company when the body set tenant_id. That is the identical shape as S1: guarding where a record is going while leaving open which record you may touch guards nothing.


4. πŸ”΄ S2c β€” escalate_ticket.php: an invariant that covered one path

Risk

The file's own design note says every check lives in integrationsEscalate(). True for the write. The preview branch returns at line ~146; the service is called at ~178.

The preview payload is not a summary β€” it is $doc->toPlainText() over the ticket subject, requester name and email, priority, type, and the body of the initial inbound message, plus integrationsTicketAttachments() filenames.

Proof of concept

curl -X POST .../api/integrations/escalate_ticket.php -b "PHPSESSID=$SID" \
     -H 'Content-Type: application/json' -d '{"ticket_id":262,"preview":1}'

No connection_id, no configured tracker β€” the preview needs neither.

Before: company 6's ticket content. After: {"success":false,"error":"That ticket no longer exists."} Positive control: the same analyst previewing ticket 103 (their own company) still receives the full preview payload.

Mitigation

analystCanAccessTicket() immediately after the integrationsSchemaReady() guard β€” before the ticket SELECT, so nothing is read at all.

Why this fixes it

Moving the check to the boundary makes it path-independent. The service keeps its own check because it is reachable from elsewhere; two checks on the write path is correct when one is a boundary and one is a service.

The test for this is position-sensitive and got it wrong first. It compared the guard's offset against the first occurrence of isPreview β€” which is the assignment at line 45, necessarily above the guard. It reported a correct fix as broken. It now matches if ($isPreview), the branch itself.


5. 🟠 S2d β€” the two messaging diagnostics

Risk

Both gate on "may you administer channels?" (messagingAdminMayAdministerChannel() for the admin-Slack path, otherwise tickets module + Cap::TICKETS_MESSAGING) and neither on "may you administer this channel?".

slack_diagnose.php returns workspace name, bot identity, granted OAuth scopes and configured URLs. test_channel.php's simulate mode drives a synthetic message through the real ingest.

Proof of concept

curl -X POST .../api/messaging/slack_diagnose.php -b "PHPSESSID=$SID" \
     -H 'Content-Type: application/json' -d '{"id":<a channel pinned to another company>}'

Mitigation

$isAdminSlackPath = messagingAdminMayAdministerChannel($conn, $id);
if (!$isAdminSlackPath) { requireModuleAccessJson('tickets'); requireCapabilityJson(Cap::TICKETS_MESSAGING); }
if (!$isAdminSlackPath && !analystCanAccessChannel($conn, (int)$_SESSION['analyst_id'], $id)) { … }

Both files also gained require_once '../../includes/tenancy.php' β€” neither had it, and neither includes/functions.php nor includes/messaging/messaging.php pulls it in transitively, so the call would have been a fatal.

Why this fixes it

analystCanAccessChannel() already encodes the rule, including the part that is easy to get wrong: messaging_channels.tenant_id IS NULL means shared intake, not Default-owned β€” the opposite of tickets and assets. A hand-written check would very likely have made shared channels private to the default company and broken multi-company intake.

The admin-Slack exemption is preserved deliberately. is_admin is not can_access_all_tenants β€” all-access is a separate flag on analysts β€” so applying the check to the admin path would have locked administrators out of System β†’ Integrations β†’ Slack, which is theirs by design.


6. 🟠 S6 β€” the MFA counter

Risk

$_SESSION['mfa_attempts'] (analyst) and $_SESSION['ss_mfa_attempts'] (portal). The session is attacker-controlled state.

The previous comment defended this: discarding the session forces another password step, which is rate-limited. That argument fails on one line β€” auth/login.php:317:

$resetStmt = $conn->prepare("UPDATE analysts SET failed_login_count = 0, locked_until = NULL WHERE id = ?");

A successful password step clears the counters. An attacker holding a valid password never trips lockout; every iteration begins with a success. Cost of unlimited six-digit guesses: one extra request per five, β‰ˆ20%.

Proof of concept

Loop: POST /auth/login.php (valid password) β†’ 5 Γ— POST /api/myaccount/verify_login_otp.php β†’ discard cookie jar β†’ repeat.

Reproduced in the suite without HTTP, which is the more durable form:

for ($i = 1; $i <= $threshold; $i++) { $r = mfaThrottleRecordFailure($conn, 'analysts', $tmpId); }
// simulate exactly what a successful password step does:
$conn->prepare("UPDATE analysts SET failed_login_count = 0, locked_until = NULL WHERE id = ?")->execute([$tmpId]);
check("a SUCCESSFUL PASSWORD STEP does not clear the MFA lock",
      mfaThrottleMinutesRemaining($conn, 'analysts', $tmpId) > 0);

Mitigation

includes/mfa_throttle.php β€” mfaThrottleMinutesRemaining(), mfaThrottleRecordFailure(), mfaThrottleReset(), against mfa_failed_count / mfa_locked_until on analysts and users.

Why this fixes it

  • Location. The count is on the account row. Discarding the session no longer discards it.
  • Separation. mfa_* are deliberately distinct from failed_login_count / locked_until. Nothing on the password path touches them; only mfaThrottleReset() clears them, and it is called only after verifyTotpCode() returns true. The separation is the fix β€” reusing the existing columns would have reinstated the bug.
  • Threshold floor. max_failed_logins = 0 means "do not lock accounts on bad passwords". Read literally that would mean unlimited MFA guessing, so the threshold falls back to MFA_THROTTLE_FALLBACK_THRESHOLD = 5 rather than to infinity β€” otherwise the bug is re-enabled through a settings screen.
  • Atomic increment. SET mfa_failed_count = mfa_failed_count + 1 in the statement; read-then-write would let concurrent requests each read 4 and each write 5.
  • Table whitelist. MFA_THROTTLE_TABLES β€” the table name is chosen, never passed, so the helper is not an injection point.

⚠️ The degrade rule goes deliberately the "wrong" way. Missing columns β†’ log once and fall back to the session counter, not fail closed. Failing closed would refuse every MFA code on an un-migrated install, locking out exactly the users who enabled MFA, with no route back in. Fail-closed is right for a data-access guard and wrong for the lock on the front door. The session counter is retained for precisely this case β€” it is not dead code.


6b. 🟠 S3's cousins β€” TasksService::ticketAccessible()

Found late, and only because the wiki was fact-checked. The round-two outstanding list carried the entry "S3's cousins and S9's remaining items, listed above". Every item on that list that named a file was closed in this round; the cross-reference was not, because nothing in it was greppable. It surfaced when Ed asked whether the summary sentence on Security β€” "two significant items remain open" β€” was accurate. It was not.

Risk

includes/services/tasks.php:451:

} catch (Exception $e) {
    return true; // tenant_id column missing on a part-migrated install
}

This is the guard deciding whether an actor's company scope may reach a ticket β€” the ActorContext mirror of apiKeyCanAccessTicket(). It is the exact fail-open shape F9 removed from seven catches in includes/tenancy.php and S3 removed from the master switch above them, surviving in the service layer where neither sweep looked.

The stated intention is legitimate: a part-migrated install missing tickets.tenant_id must keep working. The implementation forgives every Exception β€” HY000 lock-wait timeout, 2006 server gone away, 1045 permissions β€” none of which is evidence about who may read the row.

Proof of concept

Not request-shaped; load-shaped, which is what makes it nasty:

-- session A
START TRANSACTION; SELECT * FROM tickets WHERE id = 262 FOR UPDATE; -- hold

Any concurrent GET /api/v1/tasks?ticket_id=262 from a key scoped to a different company hits the lock wait, ticketAccessible() throws, returns true, and the tasks are served. Intermittent, invisible in a quiet environment, and it leaves no trace because the old code logged nothing.

Mitigation

} catch (Exception $e) {
    return tenancyDegradeAllowed($e);
}

includes/tenancy.php is already required at line 35, so no new dependency.

Why this fixes it

tenancyDegradeAllowed() is precisely the distinction the original comment was reaching for and could not express in a bare catch:

if ($e instanceof PDOException && dbErrorIsMissingSchema($e)) return true;  // genuinely part-migrated
error_log('tenancy: denying access after an unexpected database error: ' . $e->getMessage());
return false;

Missing schema still degrades to allowed, so part-migrated installs are unaffected. Everything else denies and logs, so the next occurrence is diagnosable rather than silent.

The standing check

A named assertion would only ever cover the one instance that was found. The suite therefore also sweeps includes/services/*.php for any catch block returning true unconditionally within four lines:

check("no service-layer catch block returns true unconditionally", $failOpen === [], …);

Sabotage-verified: reverting the fix fails both the named check and the sweep, the sweep independently relocating the line.


7. 🟑 S9 and the upload gaps

7.1 ticket_merge.php hand-wrote .html into the web root

Risk. file_put_contents($baseDir . '/' . $relPath, $html) under a name derived from the ticket reference. The one caller of the attachment tree that never adopted includes/uploads.php, because the bytes are ours rather than a stranger's β€” which is not the relevant property. An .html under tickets/attachments/ is same-origin script execution wherever anything serves it, and .htaccess does not exist on nginx.

Mitigation. uploadStoreBytes($html, $ref . '.html', $dir, ATTACHMENT_POLICY_STORE).

Why this fixes it. .html is not in UPLOAD_TYPES_ATTACHMENT, so the file is quarantined to .bin under a 32-hex random name β€” inert to every server and absent from ATTACHMENT_SERVE_TYPES, so attachmentSendHeaders() can only return it as an octet-stream download. email_attachments.filename still stores SDREF.html, so the analyst downloads the same readable file. The feature is unchanged; the copy on disk is not executable and not guessable.

7.2 save_branding.php accepted SVG and moved its own file

Risk. A private $allowed map including 'svg' => ['image/svg+xml', 'text/xml', 'application/xml'], then move_uploaded_file() to logo.svg β€” a predictable path. Inside <img> the script is inert; navigated to directly it is a top-level same-origin document.

Mitigation. uploadPrepareWebServableDir() + uploadStoreFile($_FILES['logo'], $dir, UPLOAD_TYPES_IMAGE, 2 * 1024 * 1024).

Why this fixes it. UPLOAD_TYPES_IMAGE has excluded SVG since the F-round; branding simply was not using it. The test asserts !array_key_exists('svg', UPLOAD_TYPES_IMAGE) directly rather than trusting the endpoint.

⚠️ uploadPrepareDir() would have broken branding. It writes a deny-all, and the logo must be fetchable as <img src>. Hence the servable variant. Verified live: the stored PNG returns 200, a planted .php in the same directory returns 403 and does not execute.

⚠️ The CSP layer is conditional and was measured, not assumed. The headers sit inside <IfModule mod_headers.c>. On the WAMP this was tested on, mod_headers is absent and the response carried only Content-Type: image/png. So: layer 1 (no SVG accepted) is the defence; layer 2 (no execute) is verified; layer 3 (CSP/sandbox) is a bonus where available. An SVG logo already on disk is therefore still an SVG and still script-capable on such a server β€” the code comment says so rather than implying otherwise.

7.3 uploadStoreFile() trusted files it could not inspect

Risk. if ($mime !== null && !in_array(...)) β€” a null from uploadDetectMime() means this server cannot tell, and was being read as fine. Without fileinfo, gate 2 did not run and the extension whitelist did the work of both gates. uploadStoreBytes() had always failed closed; the two siblings disagreed.

Mitigation / why. null now throws, with a message naming the extension to enable. Resolved in the direction of the stricter sibling.

7.4 setup/ disclosed the PHP build; every response disclosed it too

Risk. setup/index.php must answer unauthenticated. It printed phpversion() verbatim. The F2 round added a safe_detail redaction pass but was looking for paths and account names, so the version checks never opted in.

Mitigation. safe_detail on all three version branches (php_version_ok_masked, _too_low_masked, _eol_masked), plus header_remove('X-Powered-By') in includes/functions.php.

Why this fixes it. The verdict survives β€” someone mid-install still learns their PHP is too old β€” while the CVE-mappable string does not. Verified behaviourally with a positive control: anonymous fetch contains no 8.4.0; the admin fetch still does, proving the page renders and the redaction is conditional rather than the page being broken.

7.5 get_attachment.php had no containment

Risk. $filePath = $base . '/' . $attachment['file_path'] straight from the row. Not request-reachable today, which is why it was missing.

Mitigation. realpath() both sides, compare with strncmp($realFile, $realBase . DIRECTORY_SEPARATOR, strlen($realBase) + 1), readfile($realFile).

Why this fixes it. Writers generate random names; the reader refuses to leave the directory regardless of how a row got there. realpath() also resolves symlinks.

The portal twin had containment with a classic prefix bug: strpos($filePath, $baseDir) !== 0 also accepts a sibling directory whose name merely starts the same way (tickets/attachments-old). Harmless only because no such directory exists β€” which is not a property a check should depend on. Both now use the separator-terminated comparison.

7.6 <handlers><clear/> returned 500.19

Risk. The handlers section is locked at server level on a default IIS install (overrideModeDefault="Deny"). A web.config clearing it makes IIS refuse to read the file entirely and answer HTTP 500.19 for everything beneath. A directory that 500s has not been denied; it has been broken, and it reads as FreeITSM being at fault.

Mitigation. Removed from all five committed web.config files and from uploadPrepareDir(). denyUrlSequences with "." remains β€” not a locked section, clean 404.5, and it already refused every file in those directories.

Why this fixes it. The removed element contributed nothing except the error. For system/uploads/branding/ β€” which must serve β€” the servable variant denies by fileExtensions instead.


8. 🟒 F10 β€” Database Verification

Risk

The header asserted "It is idempotent and never drops anything". False. The file contains DROP COLUMN for seven columns (tickets.status, tickets.priority, tickets.requester_email, tickets.requester_name, ticket_rota_entries.location, warroom_messages.team_id, warroom_presence.team_id, assets.supplier), plus DROP INDEX and DROP FOREIGN KEY.

Every drop is guarded and follows a migration that copies the data first. But "the data was moved first" and "nothing is ever dropped" are different promises, and the second was the one influencing whether an administrator took a backup.

Mitigation

  • Header rewritten: creates, alters and drops; take a backup; points at the preview.
  • backup_notice on the page itself.
  • api/system/db_verify_preview.php + includes/db_verify_preview.php: read-only inspection reporting tables to create, columns to add, and destructive operations whose targets are currently present.

Why this fixes it β€” and what the preview is not

It is an inspection, not the migration with a flag. Two reasons, both structural:

  1. MySQL implicitly commits on DDL, so wrapping the run in a transaction and rolling back cannot work for schema changes. The usual dry-run mechanism is unavailable.
  2. A true statement-level dry run means threading $dryRun through ~200 exec() calls in a 2,300-line procedural migration β€” a new conditional in front of every migration in the app, to make a preview marginally more precise. That is the most dangerous refactor available in this codebase.

So the preview contains no ALTER, no CREATE and no DROP β€” a property the test asserts by scanning the file, and one you can confirm by reading it rather than by trusting a conditional.

The cost, stated rather than buried: DB_VERIFY_DESTRUCTIVE is maintained by hand and can fall behind.

βœ… Which is not hypothetical β€” it already happened, during this change. The register was written with six columns. The drift test compares it against every DROP COLUMN in db_verify.php in both directions, and failed with undeclared: supplier. assets.supplier had been missed because the enumerating grep was truncated at 20 lines. The test found it, not a person. Both directions are asserted, and the under-reporting direction is the one that matters to an administrator.


9. πŸ”΅ Verification

tests/security-findings/run.php β€” 185 passed, 0 failed (131 before). Run with a base URL for the live checks:

php tests/security-findings/run.php http://localhost/freeitsm-app/

All other suites green: db-verify-indexes 27, forms-logic 85, forms-lookup 27, integrations 353, knowledge-gaps 16, search 41, knowledge-visibility 57, ldap 14.

Behavioural where behaviour is reachable. The MFA section creates a real analyst row, drives the counter to its threshold, simulates the exact UPDATE a successful password step performs, asserts the lock survives, then asserts a correct code clears it β€” and removes the fixture.

Live, with positive controls, against real scope. A scoped analyst (company 4) and a scoped API key (company_ids = [4]) were created for the negative controls; an unscoped key and same-company requests provided the positives. Fixtures removed afterwards.

Sabotage-verified. Reverting a fix must fail the suite, or the suite is decoration:

Sabotage Result
mfaThrottleMinutesRemaining() β†’ return 0 2 failures, including "a SUCCESSFUL PASSWORD STEP does not clear the MFA lock"
Move analystCanAccessUser() below the counts 1 failure β€” "...ABOVE the ticket/asset counts"

The second matters most: it catches a guard that is present but positioned wrongly, which is the failure mode a source-grep would certify as fixed.

⚠️ Three test bugs worth recording

All three were the suite matching text inside the comment that documents the fix β€” the trap withoutComments() exists for, hit again in three new ways:

  1. Five web.config checks failed because each file explains why it has no <handlers> section. Fixed by stripping XML comments.
  2. db_verify.php "no longer claims it never drops" failed because the corrected comment quotes the old claim while refuting it. Now matches the original sentence idempotent and never drops.
  3. The DROP-COLUMN scan reported a column named for, from the prose "this file contains DROP COLUMN for tickets.status". Now scans comment-stripped source.

The lesson generalises: a test that reads source must read the source, not the documentation wrapped around it β€” and documentation that quotes the vulnerability it fixed is worth keeping, so the test must be the thing that adapts.


10. πŸ”΄ Outstanding

Everything in scope for this round is done. This list is not.

  • S4 β€” CSRF. request_guard.php refuses text/plain only; application/x-www-form-urlencoded is equally CORS-safelisted and parses. The token layer across the ~369 endpoints reading php://input remains the actual fix. Open, live.
  • lms/content. Deliberately excluded from uploadPrepareWebServableDir(): SCORM packages are HTML and JavaScript that must execute, so the sandbox CSP would break playback rather than harden it. Needs a separate origin or a serving endpoint β€” a design job. Open.
  • A pre-existing logo.svg is untouched by the upload whitelist and is still script-capable on servers without mod_headers. Operator action.
  • F4 (SLA snapshotting) and F11 (subject access / erasure) β€” features, unscheduled.

Related pages

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally