diff --git a/server/app/adminapi/controller/IamV2Controller.php b/server/app/adminapi/controller/IamV2Controller.php index 8f16e5ed8..fef8a75a0 100644 --- a/server/app/adminapi/controller/IamV2Controller.php +++ b/server/app/adminapi/controller/IamV2Controller.php @@ -134,7 +134,7 @@ final class IamV2Controller extends BaseAdminController $delivery = IamV2Client::https((string) Config::get('iam.erp_callback_url') ?: $erpOrigin . '/api/erp/iam/v2/launch/callback'); if (!in_array($delivery, [$erpOrigin . '/api/erp/iam/v2/launch/callback', $erpOrigin . '/basic-api/api/erp/iam/v2/launch/callback'], true)) { throw new RuntimeException('INVALID_FIXED_URL'); } if (($grant['deliveryUrl'] ?? '') !== $delivery) { throw new RuntimeException('INVALID_FIXED_URL'); } - return response(IamV2Flow::form($delivery, ['transaction_id' => $tx, 'grant' => $grant['grant']]))->header(['Content-Type' => 'text/html; charset=utf-8', 'Cache-Control' => 'no-store', 'Referrer-Policy' => 'no-referrer']); + return response(IamV2Flow::form($delivery, ['transaction_id' => $tx, 'grant' => $grant['grant']]))->header(['Content-Type' => 'text/html; charset=utf-8', 'Cache-Control' => 'no-store', 'Referrer-Policy' => 'strict-origin']); } catch (\Throwable $e) { return redirect('/admin/user/setting?iam_result=launch_failed')->header(['Cache-Control' => 'no-store']); } } private function selfConfig(): array { $c = (array) Config::get('iam'); $c['redirect_uri'] = IamV2Client::origin($c['public_url']) . '/adminapi/iam/v2/self-link/callback'; return $c; } @@ -184,7 +184,7 @@ final class IamV2Controller extends BaseAdminController if ($id !== $f['requestId'] || !preg_match('/^[A-Za-z0-9_-]{32,256}$/D', $state)) { throw new RuntimeException('ACCOUNT_MISMATCH'); } $proof = IamV2Source::proof($s, 'password_setup', [(int) $s['admin_id']], [], $id, ['state' => $state]); $url = IamV2Client::origin((string) Config::get('iam.portal_url')) . '/portal/password-setup/authorize'; - return response(IamV2Flow::form($url, ['request_id' => $id, 'state' => $state, 'proof' => $proof]))->header(['Content-Type' => 'text/html; charset=utf-8', 'Cache-Control' => 'no-store', 'Referrer-Policy' => 'no-referrer']); + return response(IamV2Flow::form($url, ['request_id' => $id, 'state' => $state, 'proof' => $proof]))->header(['Content-Type' => 'text/html; charset=utf-8', 'Cache-Control' => 'no-store', 'Referrer-Policy' => 'strict-origin']); } catch (\Throwable $e) { return redirect('/admin/user/setting?iam_result=password_failed'); } } private function targetOrigin(): void diff --git a/server/tests/IamV2RouteContractTest.php b/server/tests/IamV2RouteContractTest.php index f57815c3c..6d1454a94 100644 --- a/server/tests/IamV2RouteContractTest.php +++ b/server/tests/IamV2RouteContractTest.php @@ -29,4 +29,9 @@ requireContract(strpos($login, '->assertLinked(') < strpos($login, '$result = (a $provisioner = file_get_contents($root . '/app/adminapi/service/iam/IamAccountProvisioner.php'); requireContract(strpos($provisioner, 'return IamLoginService::boundAdminId') < strpos($provisioner, 'IAM_BINDING_REVOKED'), 'explicit central binding precedes local auto-create tombstone'); requireContract(str_contains($controller, "\$this->fail(\$code, ['error' => ['code' => \$code]], 0, 0)"), 'IAM structured errors suppress shared toast without changing other interfaces'); +foreach (['launchContinue' => 'private function selfConfig', 'passwordContinue' => 'private function targetOrigin'] as $method => $next) { + $section = substr($controller, strpos($controller, 'public function ' . $method), strpos($controller, $next) - strpos($controller, 'public function ' . $method)); + requireContract(str_contains($section, 'IamV2Flow::form(') && str_contains($section, "'Referrer-Policy' => 'strict-origin'"), 'cross-origin source form must retain non-null Origin: ' . $method); +} +requireContract(str_contains(file_get_contents($root . '/app/adminapi/service/iam/v2/IamV2Target.php'), "if (\$origin !== \$expectedOrigin)"), 'target Origin matching stays strict'); echo "PASS IamV2RouteContractTest: fixed POST callbacks, auto-route method gate, origin/service authentication, live proof authorization, target scope, dedicated TLS client, default-off, personal OIDC proof\n";