mirror of
https://github.com/Cacti/cacti.git
synced 2026-10-23 03:27:02 +09:00
fix(auth): [develop] preserve SAML POST callback sessions (#8333)
* fix(auth): preserve SAML POST callback sessions Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * fix(auth): filter SAML inputs before bootstrap Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> --------- Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
This commit is contained in:
1 parent
a12fe59627
commit
ce6ab6e0b3
7 files changed
+303
No files matched your search
@@ -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.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
namespace Cacti\Auth;
|
||||
|
||||
/**
|
||||
* Return the IdP's POST to this origin before Cacti opens its session.
|
||||
* The relay preserves Strict cookies; authentication still belongs to the
|
||||
* provider's signed-response and InResponseTo validation.
|
||||
*/
|
||||
final class SamlPostRelay {
|
||||
/** @param array<string, mixed> $query
|
||||
* @param array<string, mixed> $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<string, mixed> $query
|
||||
* @param array<string, mixed> $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<string, mixed> $query
|
||||
* @param array<string, mixed> $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 = '<!doctype html><html><head><meta charset="UTF-8"></head><body><form id="saml-post-relay" method="POST" action="' . $escape($target) . '">';
|
||||
|
||||
foreach ($fields as $name => $value) {
|
||||
$html .= '<input type="hidden" name="' . $escape($name) . '" value="' . $escape($value) . '">';
|
||||
}
|
||||
// The browser supplies its localized default submit label without a session.
|
||||
$html .= '<noscript><input type="submit"></noscript></form><script nonce="' . $escape($nonce) . '">document.getElementById("saml-post-relay").submit();</script></body></html>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
test('SAML callback exemption preserves signature and request-ID validation', function (string $mode) {
|
||||
$root = dirname(__DIR__, 4);
|
||||
$process = proc_open([PHP_BINARY, $root . '/tests/fixtures/SamlCallbackProbe.php', $mode], [1 => ['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']);
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use Cacti\Auth\SamlPostRelay;
|
||||
|
||||
require_once dirname(__DIR__, 4) . '/lib/Auth/SamlPostRelay.php';
|
||||
|
||||
test('only SAML POST callbacks with a scalar assertion and valid realm are eligible', function () {
|
||||
foreach (['acs', 'callback'] as $action) {
|
||||
expect(SamlPostRelay::isCallbackPost('POST', ['action' => $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' => '<fixture>', '__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' => '<fixture>']);
|
||||
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')"));
|
||||
});
|
||||
@@ -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: '<!doctype html><title>Fixture</title>' }));
|
||||
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: '<!doctype html><title>Callback reached</title>' });
|
||||
} 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: `<!doctype html><form method="POST" action="${callback.replace(/&/g, '&')}"><input name="SAMLResponse" value="owned-fixture-assertion"><input name="RelayState" value="owned-fixture-state"></form><script>document.forms[0].submit()</script>`,
|
||||
}));
|
||||
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' },
|
||||
|
||||
Vendored
+92
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
// Runs in its own process so protocol helpers and session state stay isolated.
|
||||
$root = dirname(__DIR__, 2);
|
||||
$mode = $argv[1];
|
||||
require $root . '/include/vendor/autoload.php';
|
||||
define('CACTI_PATH_INCLUDE', $root . '/include');
|
||||
define('CACTI_PATH_LIBRARY', $root . '/lib');
|
||||
define('CACTI_PATH_URL', '/');
|
||||
define('CACTI_WEB', true);
|
||||
define('CACTI_CSRF_SECRET', '');
|
||||
define('POLLER_VERBOSITY_DEBUG', 5);
|
||||
define('AUTH_METHOD_PROVIDERS', 4);
|
||||
define('PROVIDER_TYPE_LDAP', 1);
|
||||
define('PROVIDER_TYPE_AD', 2);
|
||||
define('PROVIDER_TYPE_SAML2', 3);
|
||||
define('PROVIDER_TYPE_OPENID', 4);
|
||||
|
||||
if ($mode !== 'other-page') {
|
||||
define('CACTI_SAML_POST_CALLBACK', true);
|
||||
}
|
||||
function read_config_option($name) {
|
||||
return $name === 'base_url' ? 'https://cacti.fixture' : ($GLOBALS['mode'] === 'inactive' ? 0 : 4);
|
||||
}
|
||||
function gfrv($name) {
|
||||
return 1001;
|
||||
}
|
||||
function cacti_sizeof($value) {
|
||||
return is_array($value) ? count($value) : 0;
|
||||
}
|
||||
function cacti_log(...$args) {
|
||||
}
|
||||
function cacti_decrypt_secret($value) {
|
||||
return $value;
|
||||
}
|
||||
function __($text) {
|
||||
return $text;
|
||||
}
|
||||
function db_fetch_row_prepared(...$args) {
|
||||
return $GLOBALS['mode'] === 'disabled' ? [] : $GLOBALS['providerRow'];
|
||||
}
|
||||
|
||||
session_start();
|
||||
$_SERVER = array_merge($_SERVER, ['REQUEST_METHOD' => '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 = '<saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_fixture_assertion" Version="2.0" IssueInstant="' . $now . '"><saml:Issuer>https://idp.fixture</saml:Issuer><saml:Subject><saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">alice@example.test</saml:NameID><saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer"><saml:SubjectConfirmationData InResponseTo="_fixture_request" Recipient="' . $acs . '" NotOnOrAfter="' . $after . '"/></saml:SubjectConfirmation></saml:Subject><saml:Conditions NotBefore="' . $before . '" NotOnOrAfter="' . $after . '"><saml:AudienceRestriction><saml:Audience>' . $audience . '</saml:Audience></saml:AudienceRestriction></saml:Conditions><saml:AuthnStatement AuthnInstant="' . $now . '" SessionIndex="_fixture_session"><saml:AuthnContext><saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml:AuthnContextClassRef></saml:AuthnContext></saml:AuthnStatement></saml:Assertion>';
|
||||
|
||||
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 = '<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_fixture_response" Version="2.0" IssueInstant="' . $now . '" Destination="' . $acs . '" InResponseTo="_fixture_request"><saml:Issuer>https://idp.fixture</saml:Issuer><samlp:Status><samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></samlp:Status>' . $assertion . '</samlp:Response>';
|
||||
$_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]);
|
||||
Reference in new issue
Block a user