Skip to content

Commit 57abec8

Browse files
committed
[FIX] CmiXapi: Handle xAPI document resource preconditions in the proxy
The xAPI proxy did not forward If-Match/If-None-Match to the LRS. Both headers are missing from the allowlist in createProxyRequest(), so a content player could not announce the document revision it expects. For the xAPI document resources the specification requires an LRS to reject a PUT on an already existing document that carries neither header with 409 Conflict, which checkResponse() then replaced by a generic "412 Wrong Response". A specification compliant LRS therefore rejected every rewrite of a state document and the content never learned why. Learning Locker ignores both headers, so the defect only surfaced on strict LRS. Both headers are now relayed, the ETag of a document is exposed to the content via CORS, and the conditional status codes reach the content unchanged instead of being masked. TinCanJS, which is bundled with common content players, sends no precondition at all on a state write unless the caller supplied the SHA1 of the document it read before, so a write rejected with 409 is repeated once with an If-Match built from the current ETag.
1 parent 71da519 commit 57abec8

5 files changed

Lines changed: 299 additions & 3 deletions

File tree

components/ILIAS/CmiXapi/classes/XapiProxy/XapiProxyRequest.php

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,20 @@ class XapiProxyRequest
3838
private string $cmdPart2plus = "";
3939
private bool $checkGetStatements = true;
4040

41+
/**
42+
* The xAPI document resources are mutable, so the specification guards them with optimistic
43+
* concurrency (xAPI 1.0.3, "Concurrency"): a content player announces the document revision
44+
* it expects, and an LRS must reject a PUT on an already existing document that carries
45+
* neither of these headers with 409 Conflict.
46+
* @var list<string>
47+
*/
48+
private const CONDITIONAL_REQUEST_HEADERS = ['If-Match', 'If-None-Match'];
49+
50+
/**
51+
* @var list<string>
52+
*/
53+
private const DOCUMENT_RESOURCES = ['activities/state', 'activities/profile', 'agents/profile'];
54+
4155
public function __construct(XapiProxy $xapiproxy)
4256
{
4357
$this->dic = $GLOBALS['DIC'];
@@ -279,6 +293,25 @@ private function handleProxy(\Psr\Http\Message\RequestInterface $request, $fakeP
279293
$this->xapiproxy->log()->error($this->msg($e->getMessage()));
280294
}
281295

296+
$responses['default'] = $this->repeatDocumentWriteWithPrecondition(
297+
$httpclient,
298+
$request,
299+
$responses['default'],
300+
$uriDefault,
301+
$authDefault,
302+
$body,
303+
$req_opts
304+
);
305+
$responses['fallback'] = $this->repeatDocumentWriteWithPrecondition(
306+
$httpclient,
307+
$request,
308+
$responses['fallback'],
309+
$uriFallback,
310+
$authFallback,
311+
$body,
312+
$req_opts
313+
);
314+
282315
$defaultOk = $this->xapiProxyResponse->checkResponse($responses['default'], $endpointDefault);
283316
$fallbackOk = $this->xapiProxyResponse->checkResponse($responses['fallback'], $endpointFallback);
284317

@@ -318,6 +351,16 @@ private function handleProxy(\Psr\Http\Message\RequestInterface $request, $fakeP
318351
} catch (\Exception $e) {
319352
$this->xapiproxy->log()->error($this->msg($e->getMessage()));
320353
}
354+
$responses['default'] = $this->repeatDocumentWriteWithPrecondition(
355+
$httpclient,
356+
$request,
357+
$responses['default'],
358+
$uriDefault,
359+
$authDefault,
360+
$body,
361+
$req_opts
362+
);
363+
321364
if ($this->xapiProxyResponse->checkResponse($responses['default'], $endpointDefault)) {
322365
try {
323366
$this->xapiProxyResponse->handleResponse(
@@ -372,10 +415,98 @@ private function createProxyRequest(\Psr\Http\Message\RequestInterface $request,
372415
$headers['Connection'] = $request->getHeader('Connection');
373416
}
374417

418+
foreach (self::CONDITIONAL_REQUEST_HEADERS as $conditionalHeader) {
419+
if ($request->hasHeader($conditionalHeader)) {
420+
$headers[$conditionalHeader] = $request->getHeader($conditionalHeader);
421+
}
422+
}
423+
375424
//$this->xapiproxy->log()->debug($this->msg($body));
376425

377426
$req = new Request(strtoupper($request->getMethod()), $uri, $headers, $body);
378427

379428
return $req;
380429
}
430+
431+
/**
432+
* TinCanJS, which is bundled with common content players, only sends If-Match on a state
433+
* write when the caller supplied the SHA1 of the document it read before, and no precondition
434+
* at all otherwise. A specification compliant LRS answers such a write with 409 Conflict. In
435+
* that case the current ETag is looked up and the write is repeated once with an If-Match
436+
* built from it. An LRS that does not demand a precondition never answers 409 and therefore
437+
* never causes the additional roundtrip.
438+
* @param array{state: string, value?: \GuzzleHttp\Psr7\Response, reason?: mixed} $response
439+
* @param array<string, mixed> $req_opts
440+
* @return array{state: string, value?: \GuzzleHttp\Psr7\Response, reason?: mixed}
441+
*/
442+
private function repeatDocumentWriteWithPrecondition(
443+
Client $httpclient,
444+
\Psr\Http\Message\RequestInterface $request,
445+
array $response,
446+
Uri $uri,
447+
string $auth,
448+
string $body,
449+
array $req_opts
450+
): array {
451+
if ($response['state'] !== 'fulfilled' || $response['value']->getStatusCode() !== 409) {
452+
return $response;
453+
}
454+
if (!$this->requiresSynthesizedPrecondition($request)) {
455+
return $response;
456+
}
457+
$etag = $this->fetchDocumentEtag($httpclient, $request, $uri, $auth, $req_opts);
458+
if ($etag === '') {
459+
return $response;
460+
}
461+
462+
$this->xapiproxy->log()->debug($this->msg('lrs requires a precondition for ' . $uri . ', repeating request with If-Match: ' . $etag));
463+
464+
try {
465+
/** @var \GuzzleHttp\Psr7\Response $repeated */
466+
$repeated = $httpclient->send(
467+
$this->createProxyRequest($request, $uri, $auth, $body)->withHeader('If-Match', $etag),
468+
$req_opts
469+
);
470+
} catch (\Exception $e) {
471+
$this->xapiproxy->log()->error($this->msg($e->getMessage()));
472+
return $response;
473+
}
474+
475+
return ['state' => 'fulfilled', 'value' => $repeated];
476+
}
477+
478+
private function requiresSynthesizedPrecondition(\Psr\Http\Message\RequestInterface $request): bool
479+
{
480+
return strtoupper($request->getMethod()) === 'PUT'
481+
&& in_array($this->xapiproxy->cmdParts()[3] ?? '', self::DOCUMENT_RESOURCES, true)
482+
&& !$request->hasHeader('If-Match')
483+
&& !$request->hasHeader('If-None-Match');
484+
}
485+
486+
/**
487+
* Returns the current ETag of an xAPI document, or an empty string if it does not exist.
488+
* Some LRS send an ETag along with the 404 of a missing document, so only a 200 is trusted.
489+
* @param array<string, mixed> $req_opts
490+
*/
491+
private function fetchDocumentEtag(
492+
Client $httpclient,
493+
\Psr\Http\Message\RequestInterface $request,
494+
Uri $uri,
495+
string $auth,
496+
array $req_opts
497+
): string {
498+
$headers = ['Authorization' => $auth];
499+
if ($request->hasHeader('X-Experience-API-Version')) {
500+
$headers['X-Experience-API-Version'] = $request->getHeader('X-Experience-API-Version');
501+
}
502+
503+
try {
504+
$probe = $httpclient->send(new Request('GET', $uri, $headers), $req_opts);
505+
} catch (\Exception $e) {
506+
$this->xapiproxy->log()->error($this->msg($e->getMessage()));
507+
return '';
508+
}
509+
510+
return $probe->getStatusCode() === 200 ? $probe->getHeaderLine('ETag') : '';
511+
}
381512
}

components/ILIAS/CmiXapi/classes/XapiProxy/XapiProxyResponse.php

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,14 @@
2626

2727
class XapiProxyResponse
2828
{
29+
/**
30+
* 304, 409 and 412 are the conditional answers of the xAPI document resources. They are
31+
* regular protocol answers which the content has to evaluate on its own, so they are
32+
* relayed unchanged instead of being replaced by a generic proxy error.
33+
* @var list<int>
34+
*/
35+
private const RELAYED_STATUS_CODES = [200, 204, 304, 404, 409, 412];
36+
2937
// private $dic;
3038
private XapiProxy $xapiproxy;
3139
//private $xapiProxyRequest;
@@ -40,7 +48,7 @@ public function checkResponse(array $response, string $endpoint): bool
4048
{
4149
if ($response['state'] == 'fulfilled') {
4250
$status = $response['value']->getStatusCode();
43-
if ($status === 200 || $status === 204 || $status === 404) {
51+
if (in_array($status, self::RELAYED_STATUS_CODES, true)) {
4452
return true;
4553
} else {
4654
$this->xapiproxy->log()->error("LRS error {$endpoint}: " . $response['value']->getBody());
@@ -241,6 +249,9 @@ public function emit(\GuzzleHttp\Psr7\Response $response): void
241249
}
242250
}
243251

252+
// the content may only read the relayed ETag of a document if it is exposed
253+
header('Access-Control-Expose-Headers: ETag, Last-Modified, X-Experience-API-Version', true, $statusCode);
254+
244255
// statusline
245256
header(sprintf(
246257
'HTTP/%s %d%s',

components/ILIAS/CmiXapi/resources/xapiproxy.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
header('Access-Control-Allow-Origin: ' . $_SERVER["HTTP_ORIGIN"]);
3636
header('Access-Control-Allow-Credentials: true');
3737
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
38-
header('Access-Control-Allow-Headers: X-Experience-API-Version,Accept,Authorization,Etag,Cache-Control,Content-Type,DNT,If-Modified-Since,Keep-Alive,Origin,User-Agent,X-Mx-ReqToken,X-Requested-With');
38+
header('Access-Control-Allow-Headers: X-Experience-API-Version,Accept,Authorization,Etag,Cache-Control,Content-Type,DNT,If-Match,If-Modified-Since,If-None-Match,Keep-Alive,Origin,User-Agent,X-Mx-ReqToken,X-Requested-With');
3939
exit;
4040
}
4141

@@ -54,7 +54,7 @@
5454
header('Access-Control-Allow-Origin: ' . $_SERVER["HTTP_ORIGIN"]);
5555
header('Access-Control-Allow-Credentials: true');
5656
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
57-
header('Access-Control-Allow-Headers: X-Experience-API-Version,Accept,Authorization,Etag,Cache-Control,Content-Type,DNT,If-Modified-Since,Keep-Alive,Origin,User-Agent,X-Mx-ReqToken,X-Requested-With');
57+
header('Access-Control-Allow-Headers: X-Experience-API-Version,Accept,Authorization,Etag,Cache-Control,Content-Type,DNT,If-Match,If-Modified-Since,If-None-Match,Keep-Alive,Origin,User-Agent,X-Mx-ReqToken,X-Requested-With');
5858
exit;
5959
}
6060

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<?php
2+
3+
/**
4+
* This file is part of ILIAS, a powerful learning management system
5+
* published by ILIAS open source e-Learning e.V.
6+
*
7+
* ILIAS is licensed with the GPL-3.0,
8+
* see https://www.gnu.org/licenses/gpl-3.0.en.html
9+
* You should have received a copy of said license along with the
10+
* source code, too.
11+
*
12+
* If this is not the case or you just want to try ILIAS, you'll find
13+
* us at:
14+
* https://www.ilias.de
15+
* https://github.com/ILIAS-eLearning
16+
*
17+
*********************************************************************/
18+
19+
declare(strict_types=1);
20+
21+
use GuzzleHttp\Psr7\Request;
22+
use ILIAS\DI\Container;
23+
use PHPUnit\Framework\TestCase;
24+
use XapiProxy\XapiProxy;
25+
use XapiProxy\XapiProxyRequest;
26+
27+
class XapiProxyRequestTest extends TestCase
28+
{
29+
protected function setUp(): void
30+
{
31+
$GLOBALS['DIC'] = new Container();
32+
}
33+
34+
/**
35+
* @param array<string, string> $headers
36+
* @dataProvider preconditionCases
37+
*/
38+
public function testPreconditionIsOnlySynthesizedForUnconditionalDocumentWrites(
39+
string $method,
40+
string $resource,
41+
array $headers,
42+
bool $expected
43+
): void {
44+
// XapiProxy declares its own method(), so the stub is configured through expects()
45+
$proxy = $this->createMock(XapiProxy::class);
46+
$proxy->expects($this->any())->method('cmdParts')->willReturn(['', '', '', $resource, '']);
47+
48+
$method_under_test = new ReflectionMethod(XapiProxyRequest::class, 'requiresSynthesizedPrecondition');
49+
$method_under_test->setAccessible(true);
50+
51+
$this->assertSame(
52+
$expected,
53+
$method_under_test->invoke(
54+
new XapiProxyRequest($proxy),
55+
new Request($method, 'https://ilias.example.org/xapiproxy.php/' . $resource, $headers)
56+
)
57+
);
58+
}
59+
60+
/**
61+
* @return array<string, array{0: string, 1: string, 2: array<string, string>, 3: bool}>
62+
*/
63+
public static function preconditionCases(): array
64+
{
65+
return [
66+
'unconditional state write' => ['PUT', 'activities/state', [], true],
67+
'unconditional activity profile write' => ['PUT', 'activities/profile', [], true],
68+
'unconditional agent profile write' => ['PUT', 'agents/profile', [], true],
69+
'client sends If-Match' => ['PUT', 'activities/state', ['If-Match' => '"abc"'], false],
70+
'client sends If-None-Match' => ['PUT', 'activities/state', ['If-None-Match' => '*'], false],
71+
'merging POST is not guarded' => ['POST', 'activities/state', [], false],
72+
'document read is not guarded' => ['GET', 'activities/state', [], false],
73+
'document delete is not guarded' => ['DELETE', 'activities/state', [], false],
74+
'statements are immutable' => ['PUT', 'statements', [], false],
75+
];
76+
}
77+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<?php
2+
3+
/**
4+
* This file is part of ILIAS, a powerful learning management system
5+
* published by ILIAS open source e-Learning e.V.
6+
*
7+
* ILIAS is licensed with the GPL-3.0,
8+
* see https://www.gnu.org/licenses/gpl-3.0.en.html
9+
* You should have received a copy of said license along with the
10+
* source code, too.
11+
*
12+
* If this is not the case or you just want to try ILIAS, you'll find
13+
* us at:
14+
* https://www.ilias.de
15+
* https://github.com/ILIAS-eLearning
16+
*
17+
*********************************************************************/
18+
19+
declare(strict_types=1);
20+
21+
use GuzzleHttp\Psr7\Response;
22+
use PHPUnit\Framework\TestCase;
23+
use XapiProxy\XapiProxy;
24+
use XapiProxy\XapiProxyResponse;
25+
26+
class XapiProxyResponseTest extends TestCase
27+
{
28+
/**
29+
* @dataProvider statusCodes
30+
*/
31+
public function testConditionalAnswersAreRelayedInsteadOfBeingReplacedByAProxyError(
32+
int $status,
33+
bool $expected
34+
): void {
35+
// XapiProxy declares its own method(), so the stub is configured through expects()
36+
$proxy = $this->createMock(XapiProxy::class);
37+
$proxy->expects($this->any())->method('log')->willReturn($this->createMock(ilLogger::class));
38+
39+
$this->assertSame(
40+
$expected,
41+
(new XapiProxyResponse($proxy))->checkResponse(
42+
['state' => 'fulfilled', 'value' => new Response($status)],
43+
'https://lrs.example.org/xapi'
44+
)
45+
);
46+
}
47+
48+
/**
49+
* @return array<string, array{0: int, 1: bool}>
50+
*/
51+
public static function statusCodes(): array
52+
{
53+
return [
54+
'ok' => [200, true],
55+
'no content' => [204, true],
56+
'not modified' => [304, true],
57+
'document does not exist' => [404, true],
58+
'precondition required by the lrs' => [409, true],
59+
'precondition failed' => [412, true],
60+
'bad request' => [400, false],
61+
'server error' => [500, false],
62+
];
63+
}
64+
65+
public function testConnectionErrorsRemainAnError(): void
66+
{
67+
$proxy = $this->createMock(XapiProxy::class);
68+
$proxy->expects($this->any())->method('log')->willReturn($this->createMock(ilLogger::class));
69+
70+
$this->assertFalse(
71+
(new XapiProxyResponse($proxy))->checkResponse(
72+
['state' => 'rejected', 'reason' => new Exception('connection refused')],
73+
'https://lrs.example.org/xapi'
74+
)
75+
);
76+
}
77+
}

0 commit comments

Comments
 (0)