Skip to content

SSL Verification Global Setting

Ed Mozley edited this page Jul 22, 2026 · 3 revisions

SSL Verification β€” the Global Setting

How outbound HTTPS certificate verification went from off by default and scattered to on by default, working out of the box, and governed by a single switch. Shipped as #919, resolving GitHub issue #49 (raised by @chris18890).

This is the developer/architecture companion to the operator guide HTTPS Certificates & CA Bundles and the in-app help at Workflows β†’ Help β†’ HTTPS certificates (workflow/help-ssl.php).


1. The problem

FreeITSM makes a great many outbound HTTPS calls β€” Microsoft Graph and other mailboxes, AI providers, SSO/OIDC discovery, Intune and vCenter syncs, webhooks, knowledge/change share emails, password-reset mail. On every one of those, verifying the far end's certificate is what stops the app being tricked into handing ticket data, customer email addresses or a webhook signing secret to an impostor (a "man-in-the-middle").

The shipped default was the opposite of secure:

// config.php β€” as shipped, before #919
define('SSL_VERIFY_PEER', false);

That constant was threaded into 66 cURL call sites across 33 files. So out of the box, nothing verified. Worse, the comment directly above it called the setting "INSECURE… ONLY for testing" β€” the shipped default contradicted its own warning.

Why was it false? Because on a stock Windows/WAMP install, PHP has no list of trusted certificate authorities (no CA bundle), so flipping it to true would make every outbound call fail with unable to get local issuer certificate. The insecure default was a workaround for a setup problem.

On top of that, verification was configured inconsistently in three different places:

Where How it behaved Verified on a stock install?
Webhooks (webhook_delivery.php) hard-coded true Yes β€” always
AI providers (shared settings panel) SSL_VERIFY_PEER AND a per-module tick-box No β€” the false global forced it off
Everything else the SSL_VERIFY_PEER constant No
Intune / RFP Builder their own per-module tick-box, default on, independent of the global Yes

The per-module "Verify SSL" tick-boxes were the visible symptom. On the AI panel the value shown was global AND row, so on a normal install the box rendered unchecked and appeared not to save β€” because the global false was dragging it down. Intune and RFP, meanwhile, verified independently. Same label, three behaviours.


2. What we discovered (don't re-litigate these)

Before writing a line, the CA-bundle mechanism was probed empirically on the real environment (PHP 8.4.0 / libcurl 8.10.1 on Windows), because the whole design hinges on how cURL finds a bundle:

Mechanism Result Verdict
curl.cainfo via ini_set() at runtime no effect it is PHP_INI_SYSTEM β€” only settable in php.ini
putenv('CURL_CA_BUNDLE=…') before curl_init ignored this libcurl build does not honour the env var
CURLOPT_CAINFO per handle works the only reliable programmatic lever

The consequence is the load-bearing fact of the whole change: there is no global, zero-code way to point cURL at a bundle. Making verify-on work without hand-editing php.ini requires setting the bundle on each handle with CURLOPT_CAINFO. That is why this became a shared helper applied to all 66 sites, rather than a one-line config tweak.

A second discovery: because the AI panel's effective value was global AND toggle, the shipped false meant every AI call β€” Anthropic, OpenAI, OpenRouter β€” was already skipping verification, and the tick-box could never rescue it. Flipping the global is what makes AI verification actually happen. So issue #49's request was a bigger security win than it first appeared.


3. The design

One switch, one helper, one bundled fallback.

// config.php β€” after #919
define('SSL_VERIFY_PEER', true);                 // the single global switch
require_once(__DIR__ . '/includes/ssl.php');
if (!defined('SSL_CA_BUNDLE')) {
    define('SSL_CA_BUNDLE', sslResolveCaBundle());
}
// includes/ssl.php β€” every outbound handle calls this after curl_init()
sslApplyCurl($ch);            // verify per SSL_VERIFY_PEER, attach CA bundle
sslApplyCurl($ch, true);      // ALWAYS verify (webhooks) β€” no off switch

sslResolveCaBundle() picks, in order:

  1. a bundle an admin/OS already configured in php.ini (curl.cainfo / openssl.cafile) β€” honour it;
  2. on Windows only, the includes/cacert.pem now shipped with the app (PHP-on-Windows has none);
  3. otherwise '' β€” on Linux with nothing configured, leave cURL on its OS trust store rather than override it with a possibly-staler copy.

So a fresh Windows/WAMP install now verifies out of the box with no php.ini surgery. This was proven end-to-end under the Apache php.ini (which has no curl.cainfo) β€” the exact case that failed before now returns TLS OK (verified).

The per-module tick-boxes were removed entirely. aiSettingsLoad() still returns a verify_ssl key (now simply mirroring the global) so the many legacy callers don't break, but it no longer drives any handle.


4. ⚠️ Pitfalls

⚠️ vCenter / ESXi with a self-signed certificate now FAILS. Those calls verified nothing before (they rode the false global); they are now checked. Add the host's certificate to cacert.pem, or the sync errors. The same applies to any internal appliance presenting a private certificate. This is the one behaviour change that can break a working setup.

πŸ”‘ Never hand-write CURLOPT_SSL_VERIFYPEER again. Any new outbound call must go through sslApplyCurl($ch). A raw site silently opts out of the global policy and the bundled CA β€” reintroducing exactly the scattered inconsistency this change removed.

  • Two php.ini files on WAMP. Apache's serves the browser; the CLI's serves background workers (mailbox poll, webhook delivery). Historically only one got the bundle configured β€” a "half-fix". The shipped cacert.pem now covers both regardless, because it is applied in code, not php.ini.
  • Don't override Linux's OS trust store. The Windows-only fallback in sslResolveCaBundle() is deliberate: on Linux, cURL's system bundle is fresher and correct, so we return '' and leave it alone.
  • Webhooks keep an always-verify path (sslApplyCurl($ch, true)). They carry record data to third parties over the public internet β€” the worst place to stop checking identities β€” so there is deliberately no off switch, even if an admin sets the global to false.
  • The bundle goes stale slowly. includes/cacert.pem is the Mozilla/curl list (public certs, safe to commit). Re-download from https://curl.se/ca/cacert.pem roughly yearly.
  • Corporate TLS-inspection proxies: the fix is to append your internal root to the bundle, never to disable verification.

5. πŸ“ Files changed β€” before & after

Colour key: πŸ—„οΈ config Β· 🧩 new Β· βš™οΈ engine Β· πŸ”Œ API Β· πŸ–₯️ UI Β· 🎨 CSS/JS Β· πŸ“„ docs

Core of the change

File Before After
πŸ—„οΈ config.php SSL_VERIFY_PEER = false (verification off everywhere); no CA bundle concept SSL_VERIFY_PEER = true; loads ssl.php and defines SSL_CA_BUNDLE
πŸ—„οΈ docker/config.php separate Docker bootstrap, also false, and did not load ssl.php β€” so after the flip it would have fatally called an undefined sslApplyCurl() true, loads ssl.php, defines SSL_CA_BUNDLE (its Debian base image supplies the OS CA store, so no shipped cert needed)
🧩 includes/ssl.php did not exist the single helper: sslApplyCurl() + sslResolveCaBundle()
🧩 includes/cacert.pem did not exist Mozilla CA bundle shipped with the app so Windows verifies out of the box
βš™οΈ includes/webhook_delivery.php hard-coded CURLOPT_SSL_VERIFYPEER => true, no CA bundle β†’ failed on stock Windows sslApplyCurl($ch, true) β€” still always verifies, now with the bundle attached

The tick-boxes removed

File Before After
πŸ–₯️ includes/ai_settings_panel.php rendered a "Verify SSL certificate" checkbox on every AI settings page checkbox gone
🎨 assets/js/ai-settings.js loaded/saved the checkbox state references removed
βš™οΈ includes/ai_settings.php stored/read a per-namespace *_verify_ssl row, ANDed with the global no row; verify_ssl in the return now just mirrors the global (kept so callers don't break)
πŸ”Œ api/system/ai/get_settings.php, save_settings.php returned / persisted verify_ssl field dropped
πŸ–₯️ asset-management/settings/index.php Intune Verify SSL checkbox + warning + JS removed; Intune follows the global
πŸ–₯️ contracts/settings/index.php RFP Builder Verify SSL toggle + warning + JS (RFP UI lives here) removed; RFP follows the global
πŸ“„ workflow/help-ssl.php "AI providers have a Verify SSL toggle" rewritten: one global switch, app ships a bundle

The 31 outbound-cURL call sites

All of these previously read SSL_VERIFY_PEER (or a per-module variable) directly and set no CA bundle β€” so on a stock install they either skipped verification or, once forced on, failed. After: each calls sslApplyCurl($ch), verifying per the global switch with the CA bundle attached.

Area Files
βš™οΈ Mail / Graph includes/gmail.php, includes/mailbox_graph.php, includes/template_email.php, api/tickets/check_mailbox_email.php, api/tickets/send_email.php, api/tickets/verify_mailbox_folder.php
βš™οΈ AI includes/ai_provider.php, includes/rfp_ai.php, includes/knowledge/kb_ai.php, includes/services/knowledge.php, api/knowledge/ai_chat.php, api/knowledge/generate_embedding.php, api/workflow/_ai_helpers.php, api/workflow/test_ai_key.php, api/forms/test_ai_key.php, api/cmdb/test_ai_key.php, api/rfp-builder/test_ai_connection.php, api/tickets/test_reply_cleanup_key.php
βš™οΈ SSO / OIDC includes/oidc.php, auth/oauth_callback.php, auth/google_oauth_callback.php, api/system/test_oidc_discovery.php, api/system/debug-tools/D003_selfservice_sso.php
βš™οΈ Assets includes/intune.php, api/assets/get_vcenter.php, api/assets/debug_vcenter.php
βš™οΈ Messaging / mail-out includes/messaging/MessagingProvider.php, api/messaging/test_channel.php, api/knowledge/send_share_email.php, api/change-management/send_share_email.php, api/auth/request_password_reset.php

Documentation

File Before After
πŸ“„ CHANGELOG.local.md β€” entry #919
πŸ“„ this wiki + HTTPS Certificates & CA Bundles described the per-module toggles describe the single global switch + shipped bundle

6. Verifying it

The reusable check β€” load the app's real config.php, then make a verified call the way every site now does:

require 'config.php';
$ch = curl_init('https://api.anthropic.com/v1/messages');
curl_setopt($ch, CURLOPT_NOBODY, true);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
sslApplyCurl($ch);
echo curl_exec($ch) !== false ? "TLS OK (verified)\n" : "FAIL: " . curl_error($ch) . "\n";

Run it under both the Apache and CLI php.ini (see HTTPS Certificates & CA Bundles for why both matter). With the shipped bundle it should print TLS OK even when curl.cainfo is empty.

Confirm no raw sites remain: a repo-wide search for CURLOPT_SSL_VERIFYPEER should return only includes/ssl.php (the helper) and workflow/help-ssl.php (documentation).

The setup checklist (setup/index.php, #920) automates this: it runs the same probe live and reports working (green, naming the bundle), on-but-cannot-verify (red β€” no working CA bundle, with a pointer to the help), or couldn't test (amber, no outbound network) β€” distinguishing a real certificate problem from an air-gapped box so it never cries wolf.

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally