diff --git a/include/csrf.php b/include/csrf.php index 4fb02d860..19ffc8af4 100644 --- a/include/csrf.php +++ b/include/csrf.php @@ -164,6 +164,10 @@ function csrf_check(bool $fatal = true) : bool { return true; } + if (csrf_is_saml_callback()) { + return true; + } + $submitted = filter_input(INPUT_POST, CactiCsrfGuard::INPUT_NAME, FILTER_UNSAFE_RAW); $submitted = (is_string($submitted) ? $submitted : ''); @@ -178,6 +182,31 @@ function csrf_check(bool $fatal = true) : bool { return false; } +/** + * SAML ACS requests carry an IdP assertion rather than a Cacti form token. + * Only the dedicated controller may request this exemption, and only for an + * enabled SAML provider while Login Providers is the active auth method. + * The provider still requires its saved request ID and validates signatures. + */ +function csrf_is_saml_callback() : bool { + if (!defined('CACTI_SAML_POST_CALLBACK') || + ($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { + return false; + } + + if ((int) read_config_option('auth_method') !== AUTH_METHOD_PROVIDERS) { + return false; + } + + try { + $provider = \Cacti\Auth\LoginProviderFactory::fromRealm((int) gfrv('realm')); + + return $provider instanceof \Cacti\Auth\SamlLoginProvider; + } catch (\Throwable $error) { + return false; + } +} + /** * @param mixed $tokens One token, or a ';' separated list as csrf-magic sent. * diff --git a/lib/Auth/SamlPostRelay.php b/lib/Auth/SamlPostRelay.php new file mode 100644 index 000000000..9830dff69 --- /dev/null +++ b/lib/Auth/SamlPostRelay.php @@ -0,0 +1,60 @@ + $query + * @param array $post */ + public static function isCallbackPost(string $method, array $query, array $post): bool { + $realm = filter_var($query['realm'] ?? null, FILTER_VALIDATE_INT); + + return $method === 'POST' + && in_array($query['action'] ?? '', ['acs', 'callback'], true) + && $realm !== false && $realm >= 1000 + && is_string($post['SAMLResponse'] ?? null) + && $post['SAMLResponse'] !== ''; + } + + /** @param array $query + * @param array $post */ + public static function relay(array $query, array $post): never { + $nonce = bin2hex(random_bytes(16)); + header('Content-Type: text/html; charset=UTF-8'); + header('Cache-Control: no-store'); + header('Referrer-Policy: no-referrer'); + header("Content-Security-Policy: default-src 'none'; script-src 'nonce-$nonce'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'"); + print self::render($query, $post, $nonce); + exit; + } + + /** @param array $query + * @param array $post */ + public static function render(array $query, array $post, string $nonce): string { + if (!self::isCallbackPost('POST', $query, $post)) { + throw new \InvalidArgumentException('Invalid SAML callback'); + } + $target = 'login_sso.php?action=' . $query['action'] . '&realm=' . (int) $query['realm']; + $fields = ['SAMLResponse' => (string) $post['SAMLResponse'], 'cacti_saml_relay' => '1']; + + if (is_string($post['RelayState'] ?? null)) { + $fields['RelayState'] = $post['RelayState']; + } + $escape = static fn (string $value): string => htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $html = '
'; + + foreach ($fields as $name => $value) { + $html .= ''; + } + // The browser supplies its localized default submit label without a session. + $html .= '
'; + + return $html; + } +} diff --git a/login_sso.php b/login_sso.php index 3398a1aa0..d411cb5a6 100644 --- a/login_sso.php +++ b/login_sso.php @@ -29,6 +29,30 @@ * - metadata: publish this Cacti instance's SAML2 SP metadata (SAML2 only) */ +// Run before session_start(): a cross-site IdP POST does not carry Cacti's +// Strict cookie. Opening a new session here would replace the original cookie +// and lose the request ID saved before the IdP redirect. These protocol fields +// cannot use the ordinary request wrappers until the bootstrap has loaded. +require_once(__DIR__ . '/lib/Auth/SamlPostRelay.php'); +$samlQuery = [ + 'action' => filter_input(INPUT_GET, 'action', FILTER_UNSAFE_RAW), + 'realm' => filter_input(INPUT_GET, 'realm', FILTER_VALIDATE_INT) +]; +$samlFields = [ + 'SAMLResponse' => filter_input(INPUT_POST, 'SAMLResponse', FILTER_UNSAFE_RAW), + 'RelayState' => filter_input(INPUT_POST, 'RelayState', FILTER_UNSAFE_RAW), + 'cacti_saml_relay' => filter_input(INPUT_POST, 'cacti_saml_relay', FILTER_UNSAFE_RAW) +]; +$samlPost = \Cacti\Auth\SamlPostRelay::isCallbackPost($_SERVER['REQUEST_METHOD'] ?? 'GET', $samlQuery, $samlFields); + +if ($samlPost && $samlFields['cacti_saml_relay'] !== '1') { + \Cacti\Auth\SamlPostRelay::relay($samlQuery, $samlFields); +} + +if ($samlPost) { + define('CACTI_SAML_POST_CALLBACK', true); +} + require_once('./include/auth.php'); use Cacti\Auth\LoginProviderFactory; diff --git a/tests/Unit/Security/Auth/SamlCallbackValidationTest.php b/tests/Unit/Security/Auth/SamlCallbackValidationTest.php new file mode 100644 index 000000000..8d6a6a56e --- /dev/null +++ b/tests/Unit/Security/Auth/SamlCallbackValidationTest.php @@ -0,0 +1,19 @@ + ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + $output = stream_get_contents($pipes[1]); + $error = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + expect(proc_close($process))->toBe(0)->and($error)->toBe(''); + $result = json_decode($output, true, 512, JSON_THROW_ON_ERROR); + $exempt = !in_array($mode, ['inactive', 'disabled', 'openid', 'other-page'], true); + expect($result['exempt'])->toBe($exempt)->and($result['csrf'])->toBe($exempt); + expect($result['login'])->toBe($mode === 'success')->and($result['replay'])->toBeFalse(); + + if ($mode === 'success') { + expect($result['username'])->toBe('alice@example.test'); + } +})->with(['success', 'mismatch', 'missing', 'unsigned', 'tampered', 'inactive', 'disabled', 'openid', 'other-page']); diff --git a/tests/Unit/Security/Auth/SamlPostRelayTest.php b/tests/Unit/Security/Auth/SamlPostRelayTest.php new file mode 100644 index 000000000..fd3c20963 --- /dev/null +++ b/tests/Unit/Security/Auth/SamlPostRelayTest.php @@ -0,0 +1,46 @@ + $action, 'realm' => '1001'], ['SAMLResponse' => 'fixture']))->toBeTrue(); + } + + foreach ([ + ['GET', ['action' => 'acs', 'realm' => 1001], ['SAMLResponse' => 'fixture']], + ['POST', ['action' => 'login', 'realm' => 1001], ['SAMLResponse' => 'fixture']], + ['POST', ['action' => 'acs', 'realm' => 1], ['SAMLResponse' => 'fixture']], + ['POST', ['action' => ['acs'], 'realm' => 1001], ['SAMLResponse' => 'fixture']], + ['POST', ['action' => 'acs', 'realm' => [1001]], ['SAMLResponse' => 'fixture']], + ['POST', ['action' => 'acs', 'realm' => 1001], ['SAMLResponse' => ['fixture']]], + ['POST', ['action' => 'acs', 'realm' => 1001], ['SAMLResponse' => '']], + ] as [$method, $query, $post]) { + expect(SamlPostRelay::isCallbackPost($method, $query, $post))->toBeFalse(); + } +}); + +test('relay forwards only protocol fields and escapes HTML without changing their values', function () { + $assertion = 'fixture"<&'; + $html = SamlPostRelay::render(['action' => 'acs', 'realm' => '1001'], ['SAMLResponse' => $assertion, 'RelayState' => '', '__csrf_magic' => 'do-not-forward', 'cacti_saml_relay' => '0'], 'fixture-nonce'); + $document = new DOMDocument(); + $document->loadHTML($html); + $fields = []; + + foreach ($document->getElementsByTagName('input') as $input) { + if ($input->getAttribute('type') === 'hidden') { + $fields[$input->getAttribute('name')] = $input->getAttribute('value'); + } + } + expect($fields)->toBe(['SAMLResponse' => $assertion, 'cacti_saml_relay' => '1', 'RelayState' => '']); + expect($document->getElementsByTagName('form')->item(0)->getAttribute('action'))->toBe('login_sso.php?action=acs&realm=1001'); + expect($document->getElementsByTagName('script')->item(0)->getAttribute('nonce'))->toBe('fixture-nonce'); + expect($html)->not->toContain('do-not-forward'); +}); + +test('the relay runs before the authentication bootstrap opens a session', function () { + $source = file_get_contents(dirname(__DIR__, 4) . '/login_sso.php'); + expect(strpos($source, 'SamlPostRelay::relay('))->toBeLessThan(strpos($source, "require_once('./include/auth.php')")); +}); diff --git a/tests/e2e/tests/login-smoke.spec.ts b/tests/e2e/tests/login-smoke.spec.ts index ded38c810..00720c049 100644 --- a/tests/e2e/tests/login-smoke.spec.ts +++ b/tests/e2e/tests/login-smoke.spec.ts @@ -15,6 +15,39 @@ import { test, expect } from '@playwright/test'; +test('SAML POST relay preserves the original Strict session cookie', async ({ page, context, baseURL }) => { + const origin = new URL(baseURL!).origin; + const callback = new URL('/login_sso.php?action=acs&realm=1001', origin).href; + const originalSession = 'saml-relay-fixture-session'; + await context.addCookies([{ name: 'Cacti', value: originalSession, url: origin, sameSite: 'Strict', httpOnly: true }]); + await page.route('**/__saml-start', route => route.fulfill({ contentType: 'text/html', body: 'Fixture' })); + const requests: Array<{ cookie: string; fields: URLSearchParams }> = []; + await page.route(callback, async route => { + const request = route.request(); + const fields = new URLSearchParams(request.postData() ?? ''); + requests.push({ cookie: (await request.allHeaders()).cookie ?? '', fields }); + if (fields.get('cacti_saml_relay') === '1') { + await route.fulfill({ contentType: 'text/html', body: 'Callback reached' }); + } else { + // Exercise login_sso.php's actual pre-bootstrap relay response. + await route.continue(); + } + }); + await page.route('http://idp.fixture/**', route => route.fulfill({ + contentType: 'text/html', + body: `
`, + })); + await page.goto('/__saml-start'); + await page.goto('http://idp.fixture/'); + await expect(page).toHaveTitle('Callback reached'); + expect(requests).toHaveLength(2); + expect(requests[0].cookie).not.toContain(`Cacti=${originalSession}`); + expect(requests[1].cookie).toContain(`Cacti=${originalSession}`); + expect(requests[1].fields.get('SAMLResponse')).toBe('owned-fixture-assertion'); + expect(requests[1].fields.get('RelayState')).toBe('owned-fixture-state'); + expect((await context.cookies(origin)).find(cookie => cookie.name === 'Cacti')?.value).toBe(originalSession); +}); + for (const scenario of [ { name: 'wrong password', username: 'admin', password: 'wrong-fixture-password' }, { name: 'unknown user', username: 'missingfixtureuser', password: 'wrong-fixture-password' }, diff --git a/tests/fixtures/SamlCallbackProbe.php b/tests/fixtures/SamlCallbackProbe.php new file mode 100644 index 000000000..d95b7710f --- /dev/null +++ b/tests/fixtures/SamlCallbackProbe.php @@ -0,0 +1,92 @@ + 'POST', 'HTTPS' => 'on', 'SERVER_PORT' => 443, 'HTTP_HOST' => 'cacti.fixture', 'SERVER_NAME' => 'cacti.fixture', 'SCRIPT_NAME' => '/login_sso.php', 'REQUEST_URI' => '/login_sso.php?action=acs&realm=1001']); +$key = openssl_pkey_new(['private_key_bits' => 2048]); +$csr = openssl_csr_new(['commonName' => 'fixture'], $key, ['digest_alg' => 'sha256']); +$cert = openssl_csr_sign($csr, null, $key, 1, ['digest_alg' => 'sha256']); +openssl_pkey_export($key, $privateKey); +openssl_x509_export($cert, $certificate); +$providerRow = ['id' => 1, 'type' => $mode === 'openid' ? 4 : 3, 'name' => 'Fixture', 'enabled' => 'on', 'parameters' => json_encode(['idp_entity_id' => 'https://idp.fixture', 'idp_sso_url' => 'https://idp.fixture/sso', 'idp_slo_url' => 'https://idp.fixture/slo', 'idp_x509cert' => $certificate, 'want_assertions_signed' => true])]; +$now = gmdate('Y-m-d\TH:i:s\Z'); +$before = gmdate('Y-m-d\TH:i:s\Z', time() - 60); +$after = gmdate('Y-m-d\TH:i:s\Z', time() + 300); +$acs = 'https://cacti.fixture/login_sso.php?action=acs&realm=1001'; +$audience = 'https://cacti.fixture/login_sso.php?action=metadata&realm=1001'; +$assertion = 'https://idp.fixturealice@example.test' . $audience . 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport'; + +if ($mode !== 'unsigned') { + $assertion = OneLogin\Saml2\Utils::addSign($assertion, $privateKey, $certificate); + // addSign() targets protocol messages; assertions put Issuer before Signature. + $document = new DOMDocument(); + $document->loadXML($assertion); + $issuer = $document->getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Issuer')->item(0); + $document->documentElement->insertBefore($issuer, $document->documentElement->firstChild); + $assertion = $document->saveXML(); +} +$assertion = preg_replace('/^<\?xml[^>]*>\s*/', '', $assertion); + +if ($mode === 'tampered') { + $assertion = str_replace('alice@example.test', 'mallory@example.test', $assertion); +} +$response = 'https://idp.fixture' . $assertion . ''; +$_POST = ['SAMLResponse' => base64_encode($response)]; + +if ($mode !== 'missing') { + $_SESSION['sess_saml_request_1'] = ['request_id' => $mode === 'mismatch' ? '_other_request' : '_fixture_request', 'remember' => false]; +} +require $root . '/include/csrf.php'; +$exempt = csrf_is_saml_callback(); +$csrf = csrf_check(false); +$login = false; +$username = ''; +$replay = false; + +if ($exempt && !in_array($mode, ['openid', 'disabled', 'inactive', 'other-page'], true)) { + $provider = new Cacti\Auth\SamlLoginProvider($providerRow); + $result = $provider->complete(); + $login = $result->success; + $username = $result->username; + $replay = $provider->complete()->success; +} +print json_encode(['exempt' => $exempt, 'csrf' => $csrf, 'login' => $login, 'username' => $username, 'replay' => $replay]);