SSO Redirect URI Was Http Behind A Proxy - edmozley/freeitsm GitHub Wiki
Behind a reverse proxy, the SSO redirect address said http
Reported in #152 · Fixed in #1983-#1986 · Ships in 2.7.1
What you saw
FreeITSM ran behind a reverse proxy that handled HTTPS: nginx, Caddy, Traefik or an AWS load balancer in front, talking plain HTTP to the container. TRUST_PROXY_HTTPS was on.
- System → Single Sign-On told you to register
http://your-domain/api/auth/oidc_callback.phpwith your identity provider. - You registered the
https://address instead, because that is the real one, and pressed Sign in with SSO. - Microsoft Entra refused with AADSTS50011: the redirect URI 'http://...' specified in the request does not match. Google and Okta refuse a plain-HTTP redirect address for a public domain too.
So single sign-on could not work at all behind a proxy, whatever you registered.
What was actually wrong
When a proxy does the HTTPS, the web server behind it only ever sees plain HTTP. PHP's $_SERVER['HTTPS'] is empty on every request, however the visitor connected.
The proxy does say how the visitor really connected, in a header: X-Forwarded-Proto: https. FreeITSM already had a function that reads it safely, requestIsHttps(), written in the August security review so that the session cookie gets its Secure flag behind a proxy. It only believes the header when TRUST_PROXY_HTTPS is set, because any visitor can send that header themselves.
The SSO code never called it. It asked the web server directly:
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
So behind a proxy the cookie was right and the redirect address was wrong, on the same request. The reporter found exactly this, including the helper that should have been used.
It was 18 places, not one
That line had been written out by hand all over the code. Searching for it found it 18 times in 16 files, each building an address the same way, plus a cookie that got its Secure flag from the same test:
| What | Where it showed |
|---|---|
| SSO redirect address | the sign-in redirect, the settings page, the help page, a debug tool |
| After signing out of SSO | the "come back here" address sent to the provider, for staff and portal |
| Password reset (staff) | the link in the email |
| Self-service emails | the verify-your-email link and the reset link |
| CSAT surveys | the survey link in the email |
| Calendar sync | the "open this ticket / task" link in each event |
| QR asset labels | the address printed in the code, when no public address is set |
| System → API | the base URL on the API page and the docs page |
| System → Webhooks | the cron URL to copy |
| Workflow webhook test | the app address in the test payload |
| Landing page cookie | sent without Secure |
Behind a proxy, every one of them said http://.
And two that PHP never sees
Building a test stack turned up a third kind. Ask for /login.php and the rules in .htaccess send you to the tidier /login. Ask for a folder without its slash, like /system/sso, and Apache adds one. Apache makes those redirects itself, and no PHP runs. Apache writes them as full addresses in the scheme it was spoken to in, which behind a proxy is http. The browser then sends plain HTTP to the HTTPS port and gets an error page instead of the sign-in screen.
The reporter never hit this one because they went to /login directly. Anyone following an old bookmark to /login.php would have.
How it was fixed
One function, used everywhere
A small helper sits next to the one that already worked:
function requestScheme(): string
{
return requestIsHttps() ? 'https' : 'http';
}
All 18 hand-written tests now call it, and the landing cookie uses requestIsHttps(). There is now one rule about when to believe the proxy, instead of eighteen copies of a rule that did not ask.
Apache's own redirects (Docker image)
The first attempt was to have Apache rewrite http:// to https:// in the Location header on the way out (Header edit Location). It does nothing to these redirects, and it is worth knowing why: a redirect made by a rewrite rule goes out through Apache's error-response path, which rebuilds the headers after mod_headers has run. Both Header edit and Header always edit were tried in the running container and neither touched it.
What works is telling Apache its own public scheme. With ServerName https://localhost inside the site, Apache builds its redirects as https. The host and port still come from the request, so the name itself does not matter. Two details that cost a test each:
- it has to go inside the
<VirtualHost>. A scheme on the globalServerNameis not passed down to the site, and changes nothing; - it applies to every request on port 80. So the container's start-up script puts it in place only when
TRUST_PROXY_HTTPSis set, which already means "the proxy is the only way in", and removes it otherwise.
This part only applies to the Docker image. On your own Apache or IIS behind a proxy, see Running behind a reverse proxy.
What was deliberately NOT changed
Six other places already read X-Forwarded-Proto, but without checking TRUST_PROXY_HTTPS: the public-address fallback, the web chat and SMS webhook code, and the calendar feed URLs. Those were never broken behind a proxy, so they were left alone.
The obvious tidy-up, switching them to the same helper, would have broken working installs. Someone testing web chat through ngrok has never set TRUST_PROXY_HTTPS. The webhook code rebuilds the exact address the provider called, to check the provider's signature, so under the stricter rule the signature check would start failing. Making the rule consistent would have caused the kind of bug this fix was meant to remove. If they are ever brought into line, it needs its own change and its own testing.
dockerTlsRequestIsHttps() also still asks the web server directly, on purpose: it answers "is this container serving HTTPS", not "did the visitor use HTTPS".
Files changed
| File | What changed | |
|---|---|---|
| 🔑 | includes/session_security.php |
new requestScheme() |
| 🔐 | includes/oidc.php · system/sso/index.php · system/help/sso.php |
the SSO redirect address |
| 🔐 | auth/analyst_logout.php · self-service/logout.php |
the address after signing out of SSO |
| ✉️ | api/auth/request_password_reset.php · includes/self_service_email.php · includes/csat.php |
links in emails |
| 🔗 | includes/calendar_sync/push.php · includes/asset_labels.php |
calendar event and QR label links |
| ⚙️ | system/api/index.php · system/api/docs.php · system/webhooks/index.php · api/workflow/webhook_test.php |
addresses shown to copy |
| 🍪 | includes/landing.php |
the landing cookie's Secure flag |
| 🧰 | api/system/debug-tools/D003_selfservice_sso.php · D009_guarded_paths.php |
debug tools |
| 🐳 | docker/apache-proxy.conf · docker/entrypoint.sh · Dockerfile |
Apache's own redirects |
| 🧪 | docker/proxy-test/ |
the test stack |
How it was proved
The first worry was that there was no way to reproduce it. Nothing on a Windows development box looks like a TLS-terminating proxy. It turns out a proxy only matters because of what PHP sees: an empty HTTPS, port 80, and X-Forwarded-Proto: https. A ten-line script that sets exactly those and calls the real oidcRedirectUri() reproduced it in a second, with no Entra, nginx or Docker:
requestIsHttps(): true
oidcRedirectUri(): http://itsm.example.com/api/auth/oidc_callback.php
That one output shows the whole bug: the safe helper knew, and the SSO code did not ask.
Then for real, against the broken version first, a stack that looks like the report: nginx doing HTTPS in front of the Docker image with TRUST_PROXY_HTTPS=1, kept in the repo as docker/proxy-test. A script signs in through the proxy and collects every address the app builds:
| Check | Before | After |
|---|---|---|
| SSO settings page | http | https |
| SSO help page | http | https |
SSO sign-in, redirect_uri sent to the provider |
http | https |
| API page and API docs | http | https |
| Webhooks cron URL | http | https |
/login.php → /login |
http | https |
/system/sso → /system/sso/ |
- | https |
The SSO sign-in was tested against Google's real discovery document with a made-up client id. That is enough, because the question is only what FreeITSM puts in the redirect, not whether a login completes.
The control: the same stack with TRUST_PROXY_HTTPS=0. Every check went back to http, and the session cookie lost Secure. A header the visitor can send still changes nothing unless you have said a proxy is in front.
The email links (password reset, CSAT, self-service) were checked with the script rather than by sending mail, through the same helper, in four cases: flag on, flag on with a chained https, http header, flag off with the header, and real HTTPS with no header.
Not proved: a full sign-in against a real Entra tenant, and a proxy other than nginx. Traefik, Caddy and AWS load balancers all send X-Forwarded-Proto the same way, but none of them was run.
What this means for you
- Behind a proxy that does HTTPS? Make sure
TRUST_PROXY_HTTPSis on. In Docker that isTRUST_PROXY_HTTPS=1indocker-compose.yml, and for other installs it isdefine('TRUST_PROXY_HTTPS', true);inconfig.php. Then System → Single Sign-On shows thehttps://address to register. - Already registered
https://with your identity provider? No change needed there. Sign-in simply starts working. - Not behind a proxy? Nothing changes.
See also
- Running behind a reverse proxy - the setup, in one place
- Single sign-on
- Security hardening, developer guide - where
requestIsHttps()came from - Sign-in redirected to the wrong address (#117) - a different SSO-behind-a-proxy fault, on the proxy's side that time
- Bugs resolved