diff --git a/lib/Controller/IdDocsController.php b/lib/Controller/IdDocsController.php index f7d6823084..b83a5af186 100644 --- a/lib/Controller/IdDocsController.php +++ b/lib/Controller/IdDocsController.php @@ -21,6 +21,7 @@ use OCP\AppFramework\Http\Attribute\NoCSRFRequired; use OCP\AppFramework\Http\Attribute\OpenAPI; use OCP\AppFramework\Http\Attribute\PublicPage; +use OCP\AppFramework\Http\Attribute\RequestHeader; use OCP\AppFramework\Http\DataResponse; use OCP\IL10N; use OCP\IRequest; @@ -63,6 +64,7 @@ public function __construct( #[AnonRateLimit(limit: 30, period: 60)] #[NoAdminRequired] #[NoCSRFRequired] + #[RequestHeader(name: 'libresign-sign-request-uuid', description: 'The UUID of the sign request, used to identify the request', indirect: true)] #[RequireSignRequestUuid(skipIfAuthenticated: true)] #[OpenAPI(tags: ['identity'])] #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/id-docs', requirements: ['apiVersion' => '(v1)'])] @@ -113,6 +115,7 @@ public function addFiles(array $files): DataResponse { #[AnonRateLimit(limit: 30, period: 60)] #[NoAdminRequired] #[NoCSRFRequired] + #[RequestHeader(name: 'libresign-sign-request-uuid', description: 'The UUID of the sign request, used to identify the request', indirect: true)] #[RequireSignRequestUuid(skipIfAuthenticated: true)] #[OpenAPI(tags: ['identity'])] #[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/id-docs/{nodeId}', requirements: ['apiVersion' => '(v1)'])] @@ -153,6 +156,7 @@ public function delete(int $nodeId, ?string $uuid = null): DataResponse { #[PublicPage] #[AnonRateLimit(limit: 30, period: 60)] #[NoCSRFRequired] + #[RequestHeader(name: 'libresign-sign-request-uuid', description: 'The UUID of the sign request, used to identify the request', indirect: true)] #[RequireSignRequestUuid(skipIfAuthenticated: true)] #[OpenAPI(tags: ['identity'])] #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/id-docs', requirements: ['apiVersion' => '(v1)'])] diff --git a/lib/Controller/SignFileController.php b/lib/Controller/SignFileController.php index bd35b85e7f..261b6dbe29 100644 --- a/lib/Controller/SignFileController.php +++ b/lib/Controller/SignFileController.php @@ -40,6 +40,7 @@ use OCP\AppFramework\Http\Attribute\NoCSRFRequired; use OCP\AppFramework\Http\Attribute\OpenAPI; use OCP\AppFramework\Http\Attribute\PublicPage; +use OCP\AppFramework\Http\Attribute\RequestHeader; use OCP\AppFramework\Http\DataResponse; use OCP\IL10N; use OCP\IRequest; @@ -488,6 +489,7 @@ public function requestCodeBySignerUuid( */ #[NoAdminRequired] #[NoCSRFRequired] + #[RequestHeader(name: 'libresign-sign-request-uuid', description: 'The UUID of the sign request, used to identify the request', indirect: true)] #[RequireSigner] #[PublicPage] #[OpenAPI(tags: ['signing'])] diff --git a/lib/Controller/SignatureElementsController.php b/lib/Controller/SignatureElementsController.php index bbb1f0fad1..4fdb810246 100644 --- a/lib/Controller/SignatureElementsController.php +++ b/lib/Controller/SignatureElementsController.php @@ -239,6 +239,7 @@ public function useSavedSignatureElements(string $token = ''): DataResponse { #[NoAdminRequired] #[PublicPage] #[NoCSRFRequired] + #[RequestHeader(name: 'libresign-sign-request-uuid', description: 'The UUID of the sign request, used to identify the request', indirect: true)] #[RequireSignRequestUuid(skipIfAuthenticated: true)] #[OpenAPI(tags: ['appearance'])] #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/signature/elements/preview/{nodeId}', requirements: ['apiVersion' => '(v1)'])] @@ -322,6 +323,7 @@ public function getSignatureElement(int $nodeId): DataResponse { #[NoAdminRequired] #[PublicPage] #[NoCSRFRequired] + #[RequestHeader(name: 'libresign-sign-request-uuid', description: 'The UUID of the sign request, used to identify the request', indirect: true)] #[RequireSignRequestUuid(skipIfAuthenticated: true)] #[OpenAPI(tags: ['appearance'])] #[ApiRoute(verb: 'PATCH', url: '/api/{apiVersion}/signature/elements/{nodeId}', requirements: ['apiVersion' => '(v1)'])] @@ -380,6 +382,7 @@ public function patchSignatureElement(int $nodeId, string $type = '', array $fil #[NoAdminRequired] #[NoCSRFRequired] #[PublicPage] + #[RequestHeader(name: 'libresign-sign-request-uuid', description: 'The UUID of the sign request, used to identify the request', indirect: true)] #[RequireSignRequestUuid(skipIfAuthenticated: true)] #[OpenAPI(tags: ['appearance'])] #[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/signature/elements/{nodeId}', requirements: ['apiVersion' => '(v1)'])] diff --git a/openapi-full.json b/openapi-full.json index 567fa483c0..17f075f57f 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -8367,6 +8367,14 @@ "default": "v1" } }, + { + "name": "libresign-sign-request-uuid", + "in": "header", + "description": "The UUID of the sign request, used to identify the request", + "schema": { + "type": "string" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -8510,6 +8518,14 @@ "default": null } }, + { + "name": "libresign-sign-request-uuid", + "in": "header", + "description": "The UUID of the sign request, used to identify the request", + "schema": { + "type": "string" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -8634,6 +8650,14 @@ "default": null } }, + { + "name": "libresign-sign-request-uuid", + "in": "header", + "description": "The UUID of the sign request, used to identify the request", + "schema": { + "type": "string" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -13891,6 +13915,14 @@ "format": "int64" } }, + { + "name": "libresign-sign-request-uuid", + "in": "header", + "description": "The UUID of the sign request, used to identify the request", + "schema": { + "type": "string" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -14379,6 +14411,14 @@ "format": "int64" } }, + { + "name": "libresign-sign-request-uuid", + "in": "header", + "description": "The UUID of the sign request, used to identify the request", + "schema": { + "type": "string" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -14638,6 +14678,14 @@ "format": "int64" } }, + { + "name": "libresign-sign-request-uuid", + "in": "header", + "description": "The UUID of the sign request, used to identify the request", + "schema": { + "type": "string" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -14750,6 +14798,14 @@ "format": "int64" } }, + { + "name": "libresign-sign-request-uuid", + "in": "header", + "description": "The UUID of the sign request, used to identify the request", + "schema": { + "type": "string" + } + }, { "name": "OCS-APIRequest", "in": "header", diff --git a/openapi.json b/openapi.json index bdd25f2ca3..ad7808898c 100644 --- a/openapi.json +++ b/openapi.json @@ -7630,6 +7630,14 @@ "default": "v1" } }, + { + "name": "libresign-sign-request-uuid", + "in": "header", + "description": "The UUID of the sign request, used to identify the request", + "schema": { + "type": "string" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -7773,6 +7781,14 @@ "default": null } }, + { + "name": "libresign-sign-request-uuid", + "in": "header", + "description": "The UUID of the sign request, used to identify the request", + "schema": { + "type": "string" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -7897,6 +7913,14 @@ "default": null } }, + { + "name": "libresign-sign-request-uuid", + "in": "header", + "description": "The UUID of the sign request, used to identify the request", + "schema": { + "type": "string" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -13154,6 +13178,14 @@ "format": "int64" } }, + { + "name": "libresign-sign-request-uuid", + "in": "header", + "description": "The UUID of the sign request, used to identify the request", + "schema": { + "type": "string" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -13642,6 +13674,14 @@ "format": "int64" } }, + { + "name": "libresign-sign-request-uuid", + "in": "header", + "description": "The UUID of the sign request, used to identify the request", + "schema": { + "type": "string" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -13901,6 +13941,14 @@ "format": "int64" } }, + { + "name": "libresign-sign-request-uuid", + "in": "header", + "description": "The UUID of the sign request, used to identify the request", + "schema": { + "type": "string" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -14013,6 +14061,14 @@ "format": "int64" } }, + { + "name": "libresign-sign-request-uuid", + "in": "header", + "description": "The UUID of the sign request, used to identify the request", + "schema": { + "type": "string" + } + }, { "name": "OCS-APIRequest", "in": "header", diff --git a/src/types/openapi/openapi-full.ts b/src/types/openapi/openapi-full.ts index 84d80aa12f..457ff0cd3e 100644 --- a/src/types/openapi/openapi-full.ts +++ b/src/types/openapi/openapi-full.ts @@ -4340,6 +4340,8 @@ export interface operations { length?: number | null; }; header: { + /** @description The UUID of the sign request, used to identify the request */ + "libresign-sign-request-uuid"?: string; /** @description Required to be true for the API request to pass */ "OCS-APIRequest": boolean; }; @@ -4384,6 +4386,8 @@ export interface operations { parameters: { query?: never; header: { + /** @description The UUID of the sign request, used to identify the request */ + "libresign-sign-request-uuid"?: string; /** @description Required to be true for the API request to pass */ "OCS-APIRequest": boolean; }; @@ -4438,6 +4442,8 @@ export interface operations { uuid?: string | null; }; header: { + /** @description The UUID of the sign request, used to identify the request */ + "libresign-sign-request-uuid"?: string; /** @description Required to be true for the API request to pass */ "OCS-APIRequest": boolean; }; @@ -6671,6 +6677,8 @@ export interface operations { parameters: { query?: never; header: { + /** @description The UUID of the sign request, used to identify the request */ + "libresign-sign-request-uuid"?: string; /** @description Required to be true for the API request to pass */ "OCS-APIRequest": boolean; }; @@ -6886,6 +6894,8 @@ export interface operations { parameters: { query?: never; header: { + /** @description The UUID of the sign request, used to identify the request */ + "libresign-sign-request-uuid"?: string; /** @description Required to be true for the API request to pass */ "OCS-APIRequest": boolean; }; @@ -6987,6 +6997,8 @@ export interface operations { parameters: { query?: never; header: { + /** @description The UUID of the sign request, used to identify the request */ + "libresign-sign-request-uuid"?: string; /** @description Required to be true for the API request to pass */ "OCS-APIRequest": boolean; }; @@ -7033,6 +7045,8 @@ export interface operations { parameters: { query?: never; header: { + /** @description The UUID of the sign request, used to identify the request */ + "libresign-sign-request-uuid"?: string; /** @description Required to be true for the API request to pass */ "OCS-APIRequest": boolean; }; diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 592e6f3506..2f6d85fac3 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -3778,6 +3778,8 @@ export interface operations { length?: number | null; }; header: { + /** @description The UUID of the sign request, used to identify the request */ + "libresign-sign-request-uuid"?: string; /** @description Required to be true for the API request to pass */ "OCS-APIRequest": boolean; }; @@ -3822,6 +3824,8 @@ export interface operations { parameters: { query?: never; header: { + /** @description The UUID of the sign request, used to identify the request */ + "libresign-sign-request-uuid"?: string; /** @description Required to be true for the API request to pass */ "OCS-APIRequest": boolean; }; @@ -3876,6 +3880,8 @@ export interface operations { uuid?: string | null; }; header: { + /** @description The UUID of the sign request, used to identify the request */ + "libresign-sign-request-uuid"?: string; /** @description Required to be true for the API request to pass */ "OCS-APIRequest": boolean; }; @@ -6109,6 +6115,8 @@ export interface operations { parameters: { query?: never; header: { + /** @description The UUID of the sign request, used to identify the request */ + "libresign-sign-request-uuid"?: string; /** @description Required to be true for the API request to pass */ "OCS-APIRequest": boolean; }; @@ -6324,6 +6332,8 @@ export interface operations { parameters: { query?: never; header: { + /** @description The UUID of the sign request, used to identify the request */ + "libresign-sign-request-uuid"?: string; /** @description Required to be true for the API request to pass */ "OCS-APIRequest": boolean; }; @@ -6425,6 +6435,8 @@ export interface operations { parameters: { query?: never; header: { + /** @description The UUID of the sign request, used to identify the request */ + "libresign-sign-request-uuid"?: string; /** @description Required to be true for the API request to pass */ "OCS-APIRequest": boolean; }; @@ -6471,6 +6483,8 @@ export interface operations { parameters: { query?: never; header: { + /** @description The UUID of the sign request, used to identify the request */ + "libresign-sign-request-uuid"?: string; /** @description Required to be true for the API request to pass */ "OCS-APIRequest": boolean; }; diff --git a/tests/php/Unit/Middleware/InjectionMiddlewareTest.php b/tests/php/Unit/Middleware/InjectionMiddlewareTest.php index bb6b94bc3b..4846bd0406 100644 --- a/tests/php/Unit/Middleware/InjectionMiddlewareTest.php +++ b/tests/php/Unit/Middleware/InjectionMiddlewareTest.php @@ -115,6 +115,31 @@ public function setUp(): void { $this->userId = null; } + /** @return array */ + public static function uuidSourceProvider(): array { + return [ + 'header without route UUID' => [null, 'header-uuid', 'header-uuid'], + 'route UUID without header' => ['route-uuid', '', 'route-uuid'], + 'route UUID takes precedence over header' => ['route-uuid', 'header-uuid', 'route-uuid'], + 'neither source provided' => [null, '', ''], + 'unsupported UUID is forwarded to the authorization validator' => [null, 'invalid-uuid', 'invalid-uuid'], + ]; + } + + #[DataProvider('uuidSourceProvider')] + public function testUuidSourcePrecedence(?string $routeUuid, string $headerUuid, ?string $expectedUuid): void { + $this->request->expects($this->once()) + ->method('getHeader') + ->with('libresign-sign-request-uuid') + ->willReturn($headerUuid); + $this->request->expects($this->once()) + ->method('getParam') + ->with('uuid', $headerUuid) + ->willReturn($routeUuid ?? $headerUuid); + + self::assertSame($expectedUuid, self::invokePrivate($this->getInjectionMiddleware(), 'getUuidFromRequest')); + } + public function testGuestSigningUuidReplacesEarlierRequestInSession(): void { $this->userSession->method('getUser')->willReturn(null); $this->session->method('get')->with('libresign-uuid')->willReturn('earlier-sign-request'); diff --git a/tests/php/openapi/OpenApiRequestHeadersTest.php b/tests/php/openapi/OpenApiRequestHeadersTest.php new file mode 100644 index 0000000000..1596cb4573 --- /dev/null +++ b/tests/php/openapi/OpenApiRequestHeadersTest.php @@ -0,0 +1,158 @@ + ['openapi.json'], + 'administration scope' => ['openapi-administration.json'], + 'full scope' => ['openapi-full.json'], + ]; + } + + #[DataProvider('specificationProvider')] + public function testCustomHeadersMatchActualMiddlewareConsumers(string $file): void { + $expectedRoutes = self::discoverUuidConsumerRoutes(); + $expected = array_keys(array_filter( + $expectedRoutes, + static fn (string $scope): bool => $file === 'openapi-full.json' + || ($file === 'openapi-administration.json' ? $scope === 'administration' : $scope === 'default'), + )); + + $operations = OpenApiFunctionalTags::loadOperations(dirname(__DIR__, 3) . '/' . $file); + $present = []; + foreach ($operations as $operation) { + $route = OpenApiFunctionalTags::routeKey($operation); + $parameters = $operation['raw']['parameters'] ?? []; + $headers = array_values(array_filter( + $parameters, + static fn (array $parameter): bool => ($parameter['in'] ?? null) === 'header' + && strtolower((string)($parameter['name'] ?? '')) === self::SIGN_REQUEST_HEADER, + )); + self::assertCount(in_array($route, $expected, true) ? 1 : 0, $headers, "Unexpected sign-request header in {$file}: {$route}"); + if ($headers !== []) { + $present[] = $route; + self::assertSame(self::SIGN_REQUEST_HEADER, $headers[0]['name']); + self::assertSame(self::DESCRIPTION, $headers[0]['description']); + // A request parameter named uuid is also accepted by InjectionMiddleware. + self::assertFalse($headers[0]['required'] ?? false, "The header can be replaced by a request UUID for {$route}"); + self::assertSame('string', $headers[0]['schema']['type'] ?? null); + } + } + + sort($present); + sort($expected); + self::assertSame($expected, $present, "Missing or unexpected sign-request UUID routes in {$file}"); + } + + /** @return array */ + private static function discoverUuidConsumerRoutes(): array { + $files = glob(dirname(__DIR__, 3) . '/lib/Controller/*Controller.php'); + self::assertNotFalse($files); + + $routes = []; + foreach ($files as $file) { + $source = file_get_contents($file); + self::assertNotFalse($source); + foreach (self::consumerRoutesInSource($source, $file) as $route => $scope) { + self::assertArrayNotHasKey($route, $routes, "Duplicate source route {$route}"); + $routes[$route] = $scope; + } + } + return $routes; + } + + /** + * Read controller attributes, not RequestHeader declarations or generated OpenAPI. + * Every ApiRoute must be parsed so a new syntax cannot silently skip consumers. + * + * @return array + */ + private static function consumerRoutesInSource(string $source, string $file): array { + $matches = []; + preg_match_all('/((?:^[ \t]*#\[[^\r\n]*\]\r?\n)+)[ \t]*public function [A-Za-z_]\w*/m', $source, $matches, PREG_SET_ORDER); + $foundRoutes = 0; + $routes = []; + + foreach ($matches as $match) { + $attributes = $match[1]; + if (!str_contains($attributes, '#[ApiRoute(')) { + continue; + } + $foundRoutes++; + + if (str_contains($attributes, 'SCOPE_IGNORE')) { + continue; + } + $consumesUuid = false; + foreach (self::UUID_CONSUMERS as $attribute) { + if (preg_match('/#\[' . $attribute . '(?:\]|\()/', $attributes)) { + $consumesUuid = true; + break; + } + } + if (!$consumesUuid) { + continue; + } + + self::assertSame(1, preg_match("/#\\[ApiRoute\\(verb:\\s*'([A-Z]+)',\\s*url:\\s*'([^']+)'/", $attributes, $route), "Cannot read ApiRoute in {$file}"); + // Routes already carrying {uuid} do not need a separate header. + if (str_contains($route[2], '{uuid}')) { + continue; + } + $key = $route[1] . ' ' . self::OCS_PREFIX . $route[2]; + self::assertArrayNotHasKey($key, $routes, "Duplicate route in {$file}: {$key}"); + $routes[$key] = str_contains($attributes, 'SCOPE_ADMINISTRATION') ? 'administration' : 'default'; + } + + self::assertSame(substr_count($source, '#[ApiRoute('), $foundRoutes, "Unparsed ApiRoute in {$file}; update the source discovery"); + return $routes; + } + + public function testNewUuidConsumerIsDiscoveredWithoutUpdatingAnOperationList(): void { + $source = <<<'PHP' + 'default'], + self::consumerRoutesInSource($source, 'synthetic controller'), + ); + } +} diff --git a/tests/php/openapi/phpunit.xml b/tests/php/openapi/phpunit.xml index 4ca765e58c..8845dd9e2c 100644 --- a/tests/php/openapi/phpunit.xml +++ b/tests/php/openapi/phpunit.xml @@ -15,6 +15,7 @@ OpenApiFunctionalTagsTest.php OpenApiGenerationTest.php + OpenApiRequestHeadersTest.php