Skip to content
Merged
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
14 changes: 12 additions & 2 deletions apps/federation/lib/BackgroundJob/GetSharedSecret.php
Original file line number Diff line number Diff line change
Expand Up @@ -159,11 +159,21 @@ protected function run($argument) {
if ($status === Http::STATUS_FORBIDDEN) {
$this->logger->info($target . ' refused to exchange a shared secret with you.', ['app' => 'federation']);
} else {
$this->logger->logException($e, ['app' => 'federation']);
// NB: do not log the exception verbatim - the token is sent as a
// GET query parameter, so the Guzzle message embeds the full
// request URI including "?token=...", leaking the plaintext token.
$this->logger->error(
'Could not exchange a shared secret with ' . $target . ' (HTTP ' . $status . ')',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

String interpolation seems better:

"Could not .... with {$target} (HTTP {$status})"

['app' => 'federation']
);
}
} catch (\Exception $e) {
$status = Http::STATUS_INTERNAL_SERVER_ERROR;
$this->logger->logException($e, ['app' => 'federation']);
// NB: see above - the token must not reach the log via the request URI.
$this->logger->error(
'Could not exchange a shared secret with ' . $target,
['app' => 'federation']
);
}

// if we received a unexpected response we try again later
Expand Down
3 changes: 1 addition & 2 deletions apps/federation/lib/Controller/OCSAuthAPIController.php
Original file line number Diff line number Diff line change
Expand Up @@ -159,9 +159,8 @@ public function getSharedSecret($url, $token) {
}

if ($this->isValidToken($url, $token) === false) {
$expectedToken = $this->dbHandler->getToken($url);
$this->logger->error(
'remote server (' . $url . ') didn\'t send a valid token (got "' . $token . '" but expected "'. $expectedToken . '") while getting shared secret',
'remote server (' . $url . ') didn\'t send a valid token while getting shared secret',
['app' => 'federation']
);
return ['statuscode' => Http::STATUS_FORBIDDEN];
Expand Down
34 changes: 34 additions & 0 deletions apps/federation/tests/API/OCSAuthAPITest.php
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,40 @@ public function dataTestGetSharedSecret() {
];
}

/**
* Verify that when an invalid token is supplied, the error log message does NOT
* contain either the attacker-supplied token or the expected stored token.
* Leaking either value allows an attacker with log-read access to replay the
* valid token and obtain the shared secret (CVE-class: information disclosure).
*/
public function testGetSharedSecretDoesNotLogTokenValues() {
$url = 'https://trusted.example.com';
$attackerToken = 'attacker_supplied_token_XYZ';
$storedToken = 'super_secret_expected_token_ABC';

$this->trustedServers
->method('isTrustedServer')->with($url)->willReturn(true);

// dbHandler->getToken is used by isValidToken; return the stored token
$this->dbHandler
->method('getToken')->with($url)->willReturn($storedToken);

// Capture every call to logger->error and assert no token values appear
$this->logger
->expects($this->once())
->method('error')
->with(
$this->logicalAnd(
$this->logicalNot($this->stringContains($attackerToken)),
$this->logicalNot($this->stringContains($storedToken))
),
$this->anything()
);

$result = $this->ocsAuthApi->getSharedSecret($url, $attackerToken);
$this->assertSame(Http::STATUS_FORBIDDEN, $result['statuscode']);
}

/**
* @dataProvider dataTestIsTokenValid
*/
Expand Down
50 changes: 50 additions & 0 deletions apps/federation/tests/BackgroundJob/GetSharedSecretTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@

namespace OCA\Federation\Tests\BackgroundJob;

use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Response;
use OCA\Federation\BackgroundJob\GetSharedSecret;
use OCA\Federation\DbHandler;
use OCA\Federation\TrustedServers;
Expand Down Expand Up @@ -203,4 +206,51 @@ public function dataTestRun() {
[Http::STATUS_CONFLICT],
];
}

/**
* On a non-403 HTTP failure the shared secret is sent as a GET query
* parameter, so the Guzzle exception message embeds the full request URI
* including "?token=...". Logging that exception verbatim would leak the
* plaintext token, so the token value must never reach the logger.
*/
public function testRunDoesNotLogTokenOnClientException() {
$target = 'https://remote.example.com';
$source = 'https://local.example.com';
$token = 'super-secret-federation-token';

$argument = ['url' => $target, 'token' => $token];

$this->urlGenerator->expects($this->once())->method('getAbsoluteURL')->with('/')
->willReturn($source);

// Build a real Guzzle ClientException whose request URI carries the
// token in the query string, exactly as the live client would produce.
$request = new Request(
'GET',
$target . '/ocs/v2.php/apps/federation/api/v1/shared-secret?url=' . $source . '&token=' . $token . '&format=json'
);
$exception = new ClientException(
'Client error: `' . $request->getUri() . '` resulted in a `409 Conflict` response',
$request,
new Response(Http::STATUS_CONFLICT)
);

$this->httpClient->expects($this->once())->method('get')
->willThrowException($exception);

// The token must not appear in any exception logged verbatim...
$this->logger->expects($this->never())->method('logException');
// ...nor in any error/info message string.
$assertNoToken = function ($message) use ($token) {
$this->assertStringNotContainsString($token, $message);
return true;
};
$this->logger->method('error')->with($this->callback($assertNoToken));
$this->logger->method('info')->with($this->callback($assertNoToken));

self::invokePrivate($this->getSharedSecret, 'run', [$argument]);

// A 409 is an unexpected response, so the job is retained for retry.
$this->assertTrue(self::invokePrivate($this->getSharedSecret, 'retainJob'));
}
}
15 changes: 15 additions & 0 deletions changelog/unreleased/41578
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
Security: Remove plaintext federation auth token from error log

When getSharedSecret received an invalid token it logged both the
submitted value and the expected valid token in plaintext. Since the
endpoint is public, any unauthenticated caller could trigger this log
entry at will for any trusted server URL, exposing the valid token to
anyone with log-read access.

A second leak in the same code path has also been closed: the
GetSharedSecret background job sends the token as a GET query
parameter, so on an unexpected HTTP response the Guzzle exception -
whose message embeds the full request URI including "?token=..." -
was logged verbatim. Both log sites no longer emit the token value.

https://github.com/owncloud/core/pull/41578