Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/ChangeLog.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## [Unreleased]

### Fixed

- Debug mode (`debugMode=true`) now validates the ticket for a service given as `TARGET`, and no longer fails with a type error when `renew` is set (Marko Ivančić)

## [7.0.6] - 2026-11-10

### Security
Expand Down
7 changes: 5 additions & 2 deletions src/Controller/LoginController.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;

use function filter_var;
use function http_build_query;
use function in_array;
use function var_export;
Expand Down Expand Up @@ -311,8 +312,10 @@ public function handleDebugMode(
$xmlResponse = $this->validate(
request: $request,
method: 'serviceValidate',
renew: $request->get('renew', false),
target: $request->get('target'),
// The query parameter is a string, validate() takes a bool

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@cicnavi

Thanks for catching this! Could we go one step further and remove the $request->get(...) / $request->query->get(...) calls from handleDebugMode() entirely? login() could pass the values it has already parsed straight into it.

login() already gets bool $renew, ?string $TARGET and ?string $service through #[MapQueryParameter], so Symfony has already read and validated them. Right now handleDebugMode() reads them from the request a second time, which repeats the parsing. That's also why filter_var is needed. Reading them twice causes a few problems:

  • Different sources: Request::get() checks route attributes first, then the query string, then the POST body. login() only reads the query string. A POST body or route attribute with TARGET / renew could therefore reach the validation even though the ticket was never issued with it.
  • Different parsing: an invalid value such as renew=yes-please is rejected by login()'s mapper, but filter_var quietly turns it into false. Array input (TARGET[]=x) causes a TypeError.
  • Different result: in any of these cases, debug mode isn't validating the ticket under the conditions it was issued with (forceAuthn = $renew, service = $service ?? $TARGET). Checking exactly that is the whole point of debug mode.
  • Deprecation: Request::get() is deprecated in Symfony 7.4.

If login() passes its mapped values, there's only one source of truth. filter_var and its import can go, and every value is type-checked by Symfony up front. Adding the new parameters at the end with defaults keeps existing callers of this public method working:

// login(): also map pgtUrl
#[MapQueryParameter] ?string $pgtUrl = null,
// ...
[$templateName, $statusCode, $DebugModeXmlString] = $this->handleDebugMode(
    $request,
    $debugMode,
    $serviceTicket,
    $renew,
    $TARGET,
    $service,
    $pgtUrl,
);

// handleDebugMode()
public function handleDebugMode(
    Request $request,
    ?string $debugMode,
    array $serviceTicket,
    bool $renew = false,
    ?string $target = null,
    ?string $service = null,
    ?string $pgtUrl = null,
): array {
    // ...
    $xmlResponse = $this->validate(
        request: $request,
        method:  'serviceValidate',
        renew:   $renew,
        target:  $target,
        ticket:  $serviceTicket['id'],
        service: $service,
        pgtUrl:  $pgtUrl,
    );

renew: filter_var($request->get('renew', false), FILTER_VALIDATE_BOOL),
// The same parameter login() reads the service from when there is no 'service'
target: $request->get('TARGET'),
ticket: $serviceTicket['id'],
service: $request->get('service'),
pgtUrl: $request->get('pgtUrl'),
Expand Down
61 changes: 61 additions & 0 deletions tests/src/Controller/LoginControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use SimpleSAML\Module\casserver\Controller\LoginController;
use SimpleSAML\Session;
use SimpleSAML\Utils;
use SimpleSAML\XHTML\Template;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

Expand Down Expand Up @@ -848,4 +849,64 @@ public function testAuthenticatedPostSubmitsViaPostWithTicket(): void
$this->assertArrayHasKey('ticket', $params);
$this->assertStringStartsWith('ST-', $params['ticket']);
}


/**
* @return array<string, array{array<string, string>}>
*/
public static function debugModeServiceValidateProvider(): array
{
$service = 'https://example.com/ssp/module.php/cas/linkback.php';
return [
'service' => [['service' => $service, 'debugMode' => 'true']],
'TARGET' => [['TARGET' => $service, 'debugMode' => 'true']],
'service with renew' => [['service' => $service, 'debugMode' => 'true', 'renew' => 'true']],
'TARGET with renew' => [['TARGET' => $service, 'debugMode' => 'true', 'renew' => 'true']],
];
}


/**
* Debug mode validates the ticket it has just issued against the service it was issued for, whichever query
* parameter named the service, and whether or not renew was requested.
*
* @param array<string, string> $requestParams
*
* @throws \Exception
*/
#[DataProvider('debugModeServiceValidateProvider')]
public function testDebugModeValidatesTheIssuedTicket(array $requestParams): void
{
$moduleConfig = $this->moduleConfig;
$moduleConfig['debugMode'] = true;
$casconfig = Configuration::loadFromArray($moduleConfig);

$controllerMock = $this->getMockBuilder(LoginController::class)
->setConstructorArgs([$this->sspConfig, $casconfig, $this->authSimpleMock, $this->httpUtils])
->onlyMethods(['getSession'])
->getMock();
$controllerMock->expects($this->once())->method('getSession')->willReturn($this->sessionMock);
$this->sessionMock->expects($this->exactly(2))->method('getSessionId')->willReturn(session_create_id());
$this->authSimpleMock->expects($this->atLeastOnce())->method('isAuthenticated')->willReturn(true);
$this->authSimpleMock->expects($this->once())->method('getAuthData')->with('Expire')->willReturn(9999999999);
$this->authSimpleMock->expects($this->once())->method('getAuthDataArray')->willReturn([
'Attributes' => ['eduPersonPrincipalName' => ['testuser@example.com']],
'Expire' => 9999999999,
]);

$loginRequest = Request::create(
uri: Module::getModuleURL('casserver/login'),
parameters: $requestParams,
);
$response = $this->callLogin($controllerMock, $loginRequest, $requestParams);

$this->assertInstanceOf(Template::class, $response);
$this->assertSame(Response::HTTP_OK, $response->data['statusCode']);
$xml = simplexml_load_string((string)$response->data['DebugModeXml']);
$xml->registerXPathNamespace('cas', 'serviceResponse');
$this->assertSame(
'testuser@example.com',
(string)$xml->xpath('//cas:authenticationSuccess/cas:user')[0],
);
}
}
Loading