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.php with 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 global ServerName is 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_HTTPS is 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_HTTPS is on. In Docker that is TRUST_PROXY_HTTPS=1 in docker-compose.yml, and for other installs it is define('TRUST_PROXY_HTTPS', true); in config.php. Then System → Single Sign-On shows the https:// 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