From 06564fae77281d0ebdfd4233f2618559e825eb10 Mon Sep 17 00:00:00 2001 From: dev minto <205936182+minto-dane@users.noreply.github.com> Date: Wed, 10 Jun 2026 23:23:20 -0400 Subject: [PATCH 1/6] Harden package signing and archive validation --- config/app.example.php | 5 +- docs/appbundle.md | 7 + src/api/v1/routes/developer_releases.php | 217 +++++++++++++--------- src/api/v1/routes/keys.php | 11 +- src/helper/DeveloperReleaseRepository.php | 22 ++- src/helper/PackageInspectService.php | 177 +++++++++++++++++- src/helper/PackageSignatureVerifier.php | 158 +++++++++++++++- src/helper/PackageUploadService.php | 29 +-- src/helper/PublicKeyRepository.php | 52 +++++- 9 files changed, 558 insertions(+), 120 deletions(-) diff --git a/config/app.example.php b/config/app.example.php index a7b869e..2df9987 100644 --- a/config/app.example.php +++ b/config/app.example.php @@ -8,6 +8,10 @@ 'ca_key_path' => '', 'ca_key_passphrase' => '', 'ca_certificate_days' => 365, + 'msign_path' => '/usr/local/bin/msign', + 'msign_timeout_seconds' => 10, + 'msign_max_output_bytes' => 65536, + 'session_cookie_name' => 'mochios_appstore_session', 'local' => [ 'frontend_url' => 'http://localhost:3000', @@ -22,7 +26,6 @@ 'frontend_url' => 'https://console.mochios.org', 'api_url' => 'https://api.mochios.org', 'allowed_origins' => [ - 'http://localhost:3000', 'https://console.mochios.org', ], ], diff --git a/docs/appbundle.md b/docs/appbundle.md index 63f8ba3..2c3e83a 100644 --- a/docs/appbundle.md +++ b/docs/appbundle.md @@ -179,6 +179,13 @@ mochiOS はインストール時に以下を検証する想定である。 ストアは release metadata に `package_hash`、`signature`、`certificate_id` などの検証材料を含める。 +hash名は以下の意味で扱う。 + +- `package_sha256`: アップロードされた `.pkg` raw bytes の SHA-256 +- `content_hash`: `META/signature.toml` を除外した canonical content hash +- `manifest_hash`: `manifest.toml` raw bytes の SHA-256 +- `package_hash`: 後方互換のため残る既存名。現状は `content_hash` と同じ意味として扱う + ## 予定 将来的に以下の機能を追加可能とする。 diff --git a/src/api/v1/routes/developer_releases.php b/src/api/v1/routes/developer_releases.php index 6154948..5e9d01a 100644 --- a/src/api/v1/routes/developer_releases.php +++ b/src/api/v1/routes/developer_releases.php @@ -1,108 +1,133 @@ packageSignatureVerifier->verify($packagePath); - } catch (RuntimeException $e) { - ApiResponse::error( - 'SIGNATURE_INVALID', - $e->getMessage(), - 422 - ); - return null; - } + if ($declaredKeyId === null) { + ApiResponse::error( + 'SIGNATURE_KEY_ID_REQUIRED', + 'Package signature does not contain key_id', + 422 + ); + return null; + } + + $signatureValue = packageSignatureStringField($signature, [ + 'signature', + 'sig', + ]); - $verifiedKeyId = $verified['key_id']; + if ($signatureValue === null) { + ApiResponse::error( + 'SIGNATURE_VALUE_REQUIRED', + 'Package signature does not contain signature value', + 422 + ); + return null; + } - if (!hash_equals($declaredKeyId, $verifiedKeyId)) { - ApiResponse::error( - 'SIGNATURE_KEY_ID_MISMATCH', - 'Package signature key_id does not match msign verify result', - 422 + $publicKey = $ctx->publicKeyRepo->findActiveOwnedByKeyId( + $declaredKeyId, + $developerId ); - return null; - } - $publicKey = $ctx->publicKeyRepo->findActiveOwnedByKeyId( - $verifiedKeyId, - $developerId - ); + if ($publicKey === null) { + ApiResponse::error( + 'PUBLIC_KEY_NOT_REGISTERED', + 'Package is signed, but the public key is not registered by this developer', + 403 + ); + return null; + } + + $declaredPublicKey = packageSignatureStringField($signature, [ + 'public_key', + 'public-key', + 'publicKey', + ]); - if ($publicKey === null) { - ApiResponse::error( - 'PUBLIC_KEY_NOT_REGISTERED', - 'Package is signed, but the public key is not registered by this developer', - 403 - ); - return null; - } + if ( + $declaredPublicKey !== null + && !PublicKeyRepository::publicKeyMaterialEquals($publicKey['public_key'], $declaredPublicKey) + ) { + ApiResponse::error( + 'SIGNATURE_PUBLIC_KEY_MISMATCH', + 'Package signature public_key does not match the registered public key', + 422 + ); + return null; + } - return [ - 'key_id' => $verifiedKeyId, - 'signature' => $signatureValue, - 'public_key' => $publicKey, - 'msign_output' => $verified['output'], - ]; + try { + $verified = $ctx->packageSignatureVerifier->verifyWithPublicKey( + $packagePath, + $publicKey['public_key'] + ); + } catch (RuntimeException $e) { + ApiResponse::error( + 'SIGNATURE_INVALID', + $e->getMessage(), + 422 + ); + return null; + } + + $verifiedKeyId = $verified['key_id']; + + if (!hash_equals($declaredKeyId, $verifiedKeyId)) { + ApiResponse::error( + 'SIGNATURE_KEY_ID_MISMATCH', + 'Package signature key_id does not match msign verify result', + 422 + ); + return null; + } + + return [ + 'key_id' => $verifiedKeyId, + 'signature' => $signatureValue, + 'public_key' => $publicKey, + 'msign_output' => $verified['output'], + ]; + } } return function (ApiContext $ctx): bool { @@ -146,6 +171,7 @@ function validateUploadedPackageSignature( } try { + $ctx->packageUploadService->validateUploadedPackage($_FILES['package']); $inspection = $ctx->packageInspectService->inspect($_FILES['package']['tmp_name']); $about = $inspection['about']; @@ -184,6 +210,7 @@ function validateUploadedPackageSignature( ); $signature = $signatureCheck['signature']; + // TODO: Replace this key_id placeholder when Developer CA certificate binding is integrated. $certificateId = $signatureCheck['key_id']; $release = $ctx->developerReleaseRepo->createDraft( @@ -272,6 +299,16 @@ function validateUploadedPackageSignature( return true; } + $keyId = (string) ($current['certificate_id'] ?? ''); + if ($keyId === '' || $ctx->publicKeyRepo->findActiveOwnedByKeyId($keyId, $developerId) === null) { + ApiResponse::error( + 'SIGNING_KEY_NOT_ACTIVE', + 'Release signing key is not active', + 409 + ); + return true; + } + $release = $ctx->developerReleaseRepo->submitOwned($releaseId, $developerId); ApiResponse::json([ @@ -281,4 +318,4 @@ function validateUploadedPackageSignature( } return false; -}; \ No newline at end of file +}; diff --git a/src/api/v1/routes/keys.php b/src/api/v1/routes/keys.php index 33dbec2..3d59233 100644 --- a/src/api/v1/routes/keys.php +++ b/src/api/v1/routes/keys.php @@ -36,6 +36,15 @@ return true; } + if (!PublicKeyRepository::isValidEd25519PublicKey($publicKey)) { + ApiResponse::error( + 'VALIDATION_ERROR', + 'public_key must be a base64-encoded 32-byte Ed25519 public key', + 422 + ); + return true; + } + try { $key = $ctx->publicKeyRepo->create($developerId, $keyId, $publicKey); } catch (PDOException $e) { @@ -74,4 +83,4 @@ } return false; -}; \ No newline at end of file +}; diff --git a/src/helper/DeveloperReleaseRepository.php b/src/helper/DeveloperReleaseRepository.php index 8357946..262e766 100644 --- a/src/helper/DeveloperReleaseRepository.php +++ b/src/helper/DeveloperReleaseRepository.php @@ -79,6 +79,26 @@ public function findById(string $releaseId): ?array return $release === false ? null : $release; } + public function findDeveloperIdByReleaseId(string $releaseId): ?string + { + $stmt = $this->db->prepare( + 'SELECT bi.developer_id + FROM developer_releases dr + INNER JOIN bundle_ids bi + ON bi.bundle_id = dr.bundle_id + WHERE dr.release_id = :release_id + LIMIT 1' + ); + + $stmt->execute([ + ':release_id' => $releaseId, + ]); + + $developerId = $stmt->fetchColumn(); + + return is_string($developerId) && $developerId !== '' ? $developerId : null; + } + public function findOwnedById(string $releaseId, string $developerId): ?array { $stmt = $this->db->prepare( @@ -421,4 +441,4 @@ private function bundleIsOwnedByDeveloper(string $bundleId, string $developerId) return $stmt->fetch(PDO::FETCH_ASSOC) !== false; } -} \ No newline at end of file +} diff --git a/src/helper/PackageInspectService.php b/src/helper/PackageInspectService.php index e5d33cb..3c7629c 100644 --- a/src/helper/PackageInspectService.php +++ b/src/helper/PackageInspectService.php @@ -2,14 +2,25 @@ class PackageInspectService { + private const MAX_COMPRESSED_BYTES = 128 * 1024 * 1024; + private const MAX_ENTRIES = 4096; + private const MAX_TOTAL_UNPACKED_BYTES = 256 * 1024 * 1024; + private const MAX_FILE_BYTES = 64 * 1024 * 1024; + public function inspect(string $packagePath): array { if (!is_file($packagePath)) { throw new RuntimeException('Package file not found'); } - $phar = $this->openPackage($packagePath); - $files = $this->listFiles($phar); + [$phar, $tmpTarGz] = $this->openPackage($packagePath); + + try { + $files = $this->listFiles($phar); + } finally { + unset($phar); + @unlink($tmpTarGz); + } if (!isset($files['about.toml'])) { throw new RuntimeException('about.toml is missing'); @@ -136,13 +147,19 @@ public function inspect(string $packagePath): array public function calculateContentHash(string $packagePath): string { - $phar = $this->openPackage($packagePath); - $files = $this->listFiles($phar); + [$phar, $tmpTarGz] = $this->openPackage($packagePath); + + try { + $files = $this->listFiles($phar); + } finally { + unset($phar); + @unlink($tmpTarGz); + } return $this->calculateContentHashFromFiles($files); } - private function openPackage(string $packagePath): PharData + private function openPackage(string $packagePath): array { $tmp = tempnam(sys_get_temp_dir(), 'mochi_pkg_'); @@ -150,24 +167,136 @@ private function openPackage(string $packagePath): PharData throw new RuntimeException('Failed to create temporary package path'); } + $compressedSize = filesize($packagePath); + if ($compressedSize === false || $compressedSize <= 0) { + @unlink($tmp); + throw new RuntimeException('Package is empty'); + } + + if ($compressedSize > self::MAX_COMPRESSED_BYTES) { + @unlink($tmp); + throw new RuntimeException('Package is too large'); + } + $tmpTarGz = $tmp . '.tar.gz'; unlink($tmp); if (!copy($packagePath, $tmpTarGz)) { + @unlink($tmpTarGz); throw new RuntimeException('Failed to copy package for inspection'); } try { - return new PharData($tmpTarGz); + $this->scanTarGzArchive($tmpTarGz); + return [new PharData($tmpTarGz), $tmpTarGz]; } catch (Throwable $e) { @unlink($tmpTarGz); + + if ($e instanceof RuntimeException) { + throw $e; + } + throw new RuntimeException('Package is not a valid tar.gz archive'); } } + private function scanTarGzArchive(string $path): void + { + $handle = gzopen($path, 'rb'); + + if ($handle === false) { + throw new RuntimeException('Package is not a valid tar.gz archive'); + } + + $seen = []; + $entryCount = 0; + $totalBytes = 0; + + try { + while (!gzeof($handle)) { + $header = gzread($handle, 512); + + if ($header === false || $header === '') { + break; + } + + if (strlen($header) < 512) { + throw new RuntimeException('Package tar header is truncated'); + } + + if (trim($header, "\0") === '') { + break; + } + + $name = rtrim(substr($header, 0, 100), "\0"); + $prefix = rtrim(substr($header, 345, 155), "\0"); + $pathName = $prefix === '' ? $name : $prefix . '/' . $name; + $type = $header[156] ?? "\0"; + $sizeText = trim(rtrim(substr($header, 124, 12), "\0")); + $size = $sizeText === '' ? 0 : octdec($sizeText); + + if (str_starts_with($pathName, './')) { + $pathName = substr($pathName, 2); + } + + if ($type === '5') { + $pathName = rtrim($pathName, '/'); + } + + if ($pathName === '') { + throw new RuntimeException('Unsafe package path: '); + } + + $this->assertSafeArchivePath($pathName); + + if (!in_array($type, ["\0", '0', '5'], true)) { + throw new RuntimeException('Only regular files are allowed in package'); + } + + if (array_key_exists($pathName, $seen)) { + throw new RuntimeException('Duplicate package path is not allowed: ' . $pathName); + } + + $seen[$pathName] = true; + $entryCount++; + + if ($entryCount > self::MAX_ENTRIES) { + throw new RuntimeException('Package contains too many entries'); + } + + if ($type !== '5') { + if ($size > self::MAX_FILE_BYTES) { + throw new RuntimeException('Package file is too large: ' . $pathName); + } + + $totalBytes += $size; + if ($totalBytes > self::MAX_TOTAL_UNPACKED_BYTES) { + throw new RuntimeException('Package unpacked content is too large'); + } + } + + $padding = (512 - ($size % 512)) % 512; + $toSkip = $size + $padding; + + while ($toSkip > 0) { + $chunk = gzread($handle, min(8192, $toSkip)); + if ($chunk === false || $chunk === '') { + throw new RuntimeException('Package tar entry is truncated'); + } + + $toSkip -= strlen($chunk); + } + } + } finally { + gzclose($handle); + } + } + private function listFiles(PharData $phar): array { $files = []; + $entryCount = 0; + $totalBytes = 0; $iterator = new RecursiveIteratorIterator($phar); @@ -183,7 +312,17 @@ private function listFiles(PharData $phar): array throw new RuntimeException('Links are not allowed in package'); } + if (array_key_exists($path, $files)) { + throw new RuntimeException('Duplicate package path is not allowed: ' . $path); + } + + $entryCount++; + if ($entryCount > self::MAX_ENTRIES) { + throw new RuntimeException('Package contains too many entries'); + } + if ($item->isDir()) { + $files[$path] = null; continue; } @@ -191,14 +330,38 @@ private function listFiles(PharData $phar): array throw new RuntimeException('Only regular files are allowed in package'); } + $fileSize = $item->getSize(); + if ($fileSize > self::MAX_FILE_BYTES) { + throw new RuntimeException('Package file is too large: ' . $path); + } + + $totalBytes += max(0, $fileSize); + if ($totalBytes > self::MAX_TOTAL_UNPACKED_BYTES) { + throw new RuntimeException('Package unpacked content is too large'); + } + $content = file_get_contents($item->getPathname()); if ($content === false) { throw new RuntimeException('Failed to read package file: ' . $path); } + $actualSize = strlen($content); + + if ($actualSize > self::MAX_FILE_BYTES) { + throw new RuntimeException('Package file is too large: ' . $path); + } + + if ($actualSize > $fileSize) { + $totalBytes += $actualSize - max(0, $fileSize); + if ($totalBytes > self::MAX_TOTAL_UNPACKED_BYTES) { + throw new RuntimeException('Package unpacked content is too large'); + } + } + $files[$path] = $content; } + $files = array_filter($files, static fn ($content): bool => $content !== null); ksort($files); return $files; @@ -413,4 +576,4 @@ private function stringValue(mixed $value): ?string return null; } -} \ No newline at end of file +} diff --git a/src/helper/PackageSignatureVerifier.php b/src/helper/PackageSignatureVerifier.php index fa1e280..f26da6b 100644 --- a/src/helper/PackageSignatureVerifier.php +++ b/src/helper/PackageSignatureVerifier.php @@ -2,7 +2,30 @@ class PackageSignatureVerifier { + public function __construct( + private readonly string $msignPath = '/usr/local/bin/msign', + private readonly int $timeoutSeconds = 10, + private readonly int $maxOutputBytes = 65536 + ) { + } + public function verify(string $packagePath): array + { + return $this->verifyInternal($packagePath, null); + } + + public function verifyWithPublicKey(string $packagePath, string $publicKey): array + { + $publicKeyPath = $this->writeTemporaryPublicKey($publicKey); + + try { + return $this->verifyInternal($packagePath, $publicKeyPath); + } finally { + @unlink($publicKeyPath); + } + } + + private function verifyInternal(string $packagePath, ?string $publicKeyPath): array { if (!is_file($packagePath)) { throw new RuntimeException('Package file not found'); @@ -11,7 +34,7 @@ public function verify(string $packagePath): array $verifyPath = $this->copyToTemporaryPkg($packagePath); try { - $result = $this->runMsignVerify($verifyPath); + $result = $this->runMsignVerify($verifyPath, $publicKeyPath); } finally { @unlink($verifyPath); } @@ -34,6 +57,22 @@ public function verify(string $packagePath): array ]; } + private function writeTemporaryPublicKey(string $publicKey): string + { + $tmp = tempnam(sys_get_temp_dir(), 'msign_pubkey_'); + + if ($tmp === false) { + throw new RuntimeException('Failed to create temporary public key file'); + } + + if (file_put_contents($tmp, trim($publicKey) . "\n") === false) { + @unlink($tmp); + throw new RuntimeException('Failed to write temporary public key file'); + } + + return $tmp; + } + private function copyToTemporaryPkg(string $packagePath): string { $tmp = tempnam(sys_get_temp_dir(), 'msign_verify_'); @@ -46,22 +85,32 @@ private function copyToTemporaryPkg(string $packagePath): string @unlink($tmp); if (!copy($packagePath, $tmpPkg)) { + @unlink($tmpPkg); throw new RuntimeException('Failed to prepare package for verification'); } return $tmpPkg; } - private function runMsignVerify(string $packagePath): array + private function runMsignVerify(string $packagePath, ?string $publicKeyPath): array { + $this->assertMsignExecutable(); + $descriptors = [ 0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w'], ]; + $command = [$this->msignPath, 'verify', $packagePath]; + + if ($publicKeyPath !== null) { + $command[] = '--pubkey'; + $command[] = $publicKeyPath; + } + $process = proc_open( - ['msign', 'verify', $packagePath], + $command, $descriptors, $pipes ); @@ -72,13 +121,79 @@ private function runMsignVerify(string $packagePath): array fclose($pipes[0]); - $stdout = stream_get_contents($pipes[1]); - $stderr = stream_get_contents($pipes[2]); + stream_set_blocking($pipes[1], false); + stream_set_blocking($pipes[2], false); + + $stdout = ''; + $stderr = ''; + $startedAt = time(); + $timedOut = false; + $outputLimitExceeded = false; + $observedExitCode = null; + + while (true) { + $stdout .= $this->readLimited($pipes[1], $this->remainingOutputBytes($stdout, $stderr)); + $stderr .= $this->readLimited($pipes[2], $this->remainingOutputBytes($stdout, $stderr)); + + $status = proc_get_status($process); + if (!$status['running']) { + $observedExitCode = is_int($status['exitcode'] ?? null) ? $status['exitcode'] : null; + break; + } + + if ($this->remainingOutputBytes($stdout, $stderr) === 0) { + $outputLimitExceeded = true; + proc_terminate($process); + usleep(100000); + + $status = proc_get_status($process); + if ($status['running']) { + proc_terminate($process, 9); + } + + break; + } + + if (time() - $startedAt >= max(1, $this->timeoutSeconds)) { + $timedOut = true; + proc_terminate($process); + usleep(100000); + + $status = proc_get_status($process); + if ($status['running']) { + proc_terminate($process, 9); + } + + break; + } + + usleep(20000); + } + + $stdout .= $this->readLimited($pipes[1], $this->remainingOutputBytes($stdout, $stderr)); + $stderr .= $this->readLimited($pipes[2], $this->remainingOutputBytes($stdout, $stderr)); fclose($pipes[1]); fclose($pipes[2]); $exitCode = proc_close($process); + if ($exitCode === -1 && $observedExitCode !== null) { + $exitCode = $observedExitCode; + } + + if ($timedOut) { + return [ + 'exit_code' => 124, + 'output' => 'msign verify timed out', + ]; + } + + if ($outputLimitExceeded) { + return [ + 'exit_code' => 125, + 'output' => 'msign verify output exceeded limit', + ]; + } return [ 'exit_code' => $exitCode, @@ -86,6 +201,37 @@ private function runMsignVerify(string $packagePath): array ]; } + private function assertMsignExecutable(): void + { + if (str_contains($this->msignPath, '/')) { + if (!is_file($this->msignPath) || !is_executable($this->msignPath)) { + throw new RuntimeException('msign executable was not found or is not executable'); + } + + return; + } + + if ($this->msignPath === '') { + throw new RuntimeException('msign executable path is empty'); + } + } + + private function remainingOutputBytes(string $stdout, string $stderr): int + { + return max(0, $this->maxOutputBytes - strlen($stdout) - strlen($stderr)); + } + + private function readLimited(mixed $pipe, int $remaining): string + { + if ($remaining === 0) { + return ''; + } + + $chunk = stream_get_contents($pipe, min(8192, $remaining)); + + return $chunk === false ? '' : $chunk; + } + private function parseKeyId(string $output): ?string { foreach (preg_split('/\R/', $output) as $line) { @@ -102,4 +248,4 @@ private function parseKeyId(string $output): ?string return null; } -} \ No newline at end of file +} diff --git a/src/helper/PackageUploadService.php b/src/helper/PackageUploadService.php index 3a859f1..5567b60 100644 --- a/src/helper/PackageUploadService.php +++ b/src/helper/PackageUploadService.php @@ -4,14 +4,8 @@ class PackageUploadService { private const MAX_PACKAGE_SIZE = 128 * 1024 * 1024; - public function storeUploadedPackage( - array $file, - string $bundleId, - string $version - ): array { - $this->assertValidPathSegment($bundleId, 'bundle_id'); - $this->assertValidVersion($version); - + public function validateUploadedPackage(array $file): void + { if (!isset($file['error'], $file['tmp_name'], $file['size'])) { throw new RuntimeException('Invalid upload payload'); } @@ -30,11 +24,22 @@ public function storeUploadedPackage( throw new RuntimeException('Package is too large'); } - $tmpPath = (string) $file['tmp_name']; - - if (!is_file($tmpPath)) { + if (!is_file((string) $file['tmp_name'])) { throw new RuntimeException('Uploaded package file is missing'); } + } + + public function storeUploadedPackage( + array $file, + string $bundleId, + string $version + ): array { + $this->assertValidPathSegment($bundleId, 'bundle_id'); + $this->assertValidVersion($version); + $this->validateUploadedPackage($file); + + $tmpPath = (string) $file['tmp_name']; + $size = (int) $file['size']; $relativePath = 'data/packages/' . $bundleId . '/' . $version . '.pkg'; $absolutePath = Paths::dataDir() . '/packages/' . $bundleId . '/' . $version . '.pkg'; @@ -91,4 +96,4 @@ private function assertValidVersion(string $version): void throw new RuntimeException('version is invalid'); } } -} \ No newline at end of file +} diff --git a/src/helper/PublicKeyRepository.php b/src/helper/PublicKeyRepository.php index 4f06d1b..2a52582 100644 --- a/src/helper/PublicKeyRepository.php +++ b/src/helper/PublicKeyRepository.php @@ -43,11 +43,19 @@ public function findByKeyId(string $keyId): ?array public function create(string $developerId, string $keyId, string $publicKey): array { + $decodedPublicKey = self::decodeEd25519PublicKey($publicKey); + + if ($decodedPublicKey === null) { + throw new InvalidArgumentException('public_key must be a base64-encoded 32-byte Ed25519 public key'); + } + + $publicKey = base64_encode($decodedPublicKey); + $key = [ 'key_id' => $keyId, 'developer_id' => $developerId, 'public_key' => $publicKey, - 'fingerprint' => hash('sha256', $publicKey), + 'fingerprint' => hash('sha256', $decodedPublicKey), 'created_at' => date('c'), 'revoked_at' => null, ]; @@ -82,6 +90,46 @@ public function create(string $developerId, string $keyId, string $publicKey): a return $key; } + public static function normalizePublicKey(string $publicKey): string + { + return trim($publicKey); + } + + public static function isValidEd25519PublicKey(string $publicKey): bool + { + return self::decodeEd25519PublicKey($publicKey) !== null; + } + + public static function publicKeyMaterialEquals(string $left, string $right): bool + { + $leftDecoded = self::decodeEd25519PublicKey($left); + $rightDecoded = self::decodeEd25519PublicKey($right); + + if ($leftDecoded !== null && $rightDecoded !== null) { + return hash_equals($leftDecoded, $rightDecoded); + } + + return hash_equals(self::normalizePublicKey($left), self::normalizePublicKey($right)); + } + + private static function decodeEd25519PublicKey(string $publicKey): ?string + { + $publicKey = preg_replace('/\s+/', '', self::normalizePublicKey($publicKey)) ?? ''; + + if ($publicKey === '') { + return null; + } + + $padding = strlen($publicKey) % 4; + if ($padding !== 0) { + $publicKey .= str_repeat('=', 4 - $padding); + } + + $decoded = base64_decode($publicKey, true); + + return $decoded !== false && strlen($decoded) === 32 ? $decoded : null; + } + public function revokeForDeveloper(string $keyId, string $developerId): ?array { $key = $this->findByKeyId($keyId); @@ -131,4 +179,4 @@ public function findActiveOwnedByKeyId(string $keyId, string $developerId): ?arr return $key === false ? null : $key; } -} \ No newline at end of file +} From 58f411ddf78a2ae4a6070d863ae6cb24f3b6ed95 Mon Sep 17 00:00:00 2001 From: dev minto <205936182+minto-dane@users.noreply.github.com> Date: Wed, 10 Jun 2026 23:23:20 -0400 Subject: [PATCH 2/6] Add CSRF CORS and session protections --- src/api/cors.php | 32 ++++++++++++++ src/api/router.php | 21 +++------ src/api/v1/bootstrap.php | 49 +++++++++++++-------- src/api/v1/guards.php | 85 +++++++++++++++++++++++++++++++++++- src/api/v1/index.php | 34 +++++++-------- src/api/v1/routes/auth.php | 24 +++++++++- src/api/v1/routes/oauth.php | 52 ++++++++++++++++++---- src/helper/AppConfig.php | 40 +++++++++++++++-- src/public/console/common.js | 45 ++++++++++++++++++- 9 files changed, 317 insertions(+), 65 deletions(-) create mode 100644 src/api/cors.php diff --git a/src/api/cors.php b/src/api/cors.php new file mode 100644 index 0000000..2301158 --- /dev/null +++ b/src/api/cors.php @@ -0,0 +1,32 @@ + is_string($origin) && $origin !== '')); +} + +function appstoreIsAllowedOrigin(array $appConfig, string $origin): bool +{ + return $origin !== '' && in_array($origin, appstoreAllowedOrigins($appConfig), true); +} + +function appstoreApplyCors(array $appConfig): void +{ + $origin = $_SERVER['HTTP_ORIGIN'] ?? ''; + + if (!appstoreIsAllowedOrigin($appConfig, $origin)) { + return; + } + + header('Access-Control-Allow-Origin: ' . $origin); + header('Vary: Origin'); + header('Access-Control-Allow-Credentials: true'); + header('Access-Control-Allow-Headers: Content-Type, Authorization, X-CSRF-Token'); + header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS'); +} diff --git a/src/api/router.php b/src/api/router.php index e83aa42..4aec460 100644 --- a/src/api/router.php +++ b/src/api/router.php @@ -2,20 +2,11 @@ header('X-AppStore-Router: hit'); -$origin = $_SERVER['HTTP_ORIGIN'] ?? ''; -$allowedOrigins = [ - 'http://localhost:3000', - 'https://console.mochios.org', - 'https://store.mochios.org', -]; - -if (in_array($origin, $allowedOrigins, true)) { - header('Access-Control-Allow-Origin: ' . $origin); - header('Vary: Origin'); - header('Access-Control-Allow-Credentials: true'); - header('Access-Control-Allow-Headers: Content-Type, Authorization'); - header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS'); -} +require_once __DIR__ . '/../helper/AppConfig.php'; +require_once __DIR__ . '/cors.php'; + +$appConfig = AppConfig::get(); +appstoreApplyCors($appConfig); if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'OPTIONS') { http_response_code(204); @@ -55,4 +46,4 @@ ], ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); -return true; \ No newline at end of file +return true; diff --git a/src/api/v1/bootstrap.php b/src/api/v1/bootstrap.php index d2a8f36..6e050a8 100644 --- a/src/api/v1/bootstrap.php +++ b/src/api/v1/bootstrap.php @@ -1,9 +1,5 @@ 0, + 'path' => '/', + 'domain' => '', + 'secure' => ($appConfig['env'] ?? 'local') === 'production', + 'httponly' => true, + 'samesite' => 'Lax', + ]); + + session_start(); +} + +if (empty($_SESSION['csrf_token']) || !is_string($_SESSION['csrf_token'])) { + $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); +} + require_once ROOT . 'helper/AppRepository.php'; require_once ROOT . 'helper/ReleaseRepository.php'; require_once ROOT . 'helper/AppCatalog.php'; @@ -34,17 +53,7 @@ require_once __DIR__ . '/ApiContext.php'; require_once __DIR__ . '/guards.php'; -$appConfig = AppConfig::get(); - -$origin = $_SERVER['HTTP_ORIGIN'] ?? ''; - -if (in_array($origin, $appConfig['allowed_origins'], true)) { - header('Access-Control-Allow-Origin: ' . $origin); - header('Vary: Origin'); - header('Access-Control-Allow-Credentials: true'); - header('Access-Control-Allow-Headers: Content-Type'); - header('Access-Control-Allow-Methods: GET, POST, OPTIONS'); -} +appstoreApplyCors($appConfig); $method = ApiRequest::method(); @@ -53,6 +62,8 @@ exit; } +requireValidCsrfToken($appConfig, $method); + $db = Database::get(); $appRepo = new AppRepository($db); @@ -75,9 +86,13 @@ certificateAuthority: CertificateAuthority::fromAppConfig($appConfig), storage: new PackageStorage(ROOT), adminRepo: new AdminRepository($db), - packageSignatureVerifier: new PackageSignatureVerifier(), + packageSignatureVerifier: new PackageSignatureVerifier( + (string) ($appConfig['msign_path'] ?? '/usr/local/bin/msign'), + (int) ($appConfig['msign_timeout_seconds'] ?? 10), + (int) ($appConfig['msign_max_output_bytes'] ?? 65536), + ), teamRepo: new TeamRepository($db), appConfig: $appConfig, limit: ApiRequest::queryInt('limit', 50, 0), offset: ApiRequest::queryInt('offset', 0, 0), -); \ No newline at end of file +); diff --git a/src/api/v1/guards.php b/src/api/v1/guards.php index da0a4c7..22cc774 100644 --- a/src/api/v1/guards.php +++ b/src/api/v1/guards.php @@ -38,6 +38,38 @@ function readJsonBody(): array return $payload; } +function isStateChangingMethod(string $method): bool +{ + return in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true); +} + +function requireValidCsrfToken(array $appConfig, string $method): void +{ + if (!isStateChangingMethod($method)) { + return; + } + + $origin = $_SERVER['HTTP_ORIGIN'] ?? ''; + if ($origin !== '' && !appstoreIsAllowedOrigin($appConfig, $origin)) { + ApiResponse::error('CSRF_ORIGIN_INVALID', 'Request origin is not allowed', 403); + throw new ApiAbortException('Invalid CSRF origin'); + } + + $fetchSite = strtolower((string) ($_SERVER['HTTP_SEC_FETCH_SITE'] ?? '')); + if ($fetchSite === 'cross-site' && ($origin === '' || !appstoreIsAllowedOrigin($appConfig, $origin))) { + ApiResponse::error('CSRF_ORIGIN_INVALID', 'Cross-site requests are not allowed', 403); + throw new ApiAbortException('Invalid fetch site'); + } + + $expected = $_SESSION['csrf_token'] ?? ''; + $actual = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; + + if (!is_string($expected) || $expected === '' || !is_string($actual) || !hash_equals($expected, $actual)) { + ApiResponse::error('CSRF_TOKEN_INVALID', 'CSRF token is missing or invalid', 403); + throw new ApiAbortException('Invalid CSRF token'); + } +} + function requireAdminDeveloper(ApiContext $ctx): array { $developerId = requireDeveloperId(); @@ -50,4 +82,55 @@ function requireAdminDeveloper(ApiContext $ctx): array } return $admin; -} \ No newline at end of file +} + +function requireOwnerDeveloper(ApiContext $ctx): array +{ + $admin = requireAdminDeveloper($ctx); + + if (($admin['role'] ?? '') !== 'owner') { + ApiResponse::error('FORBIDDEN', 'Owner permission is required', 403); + throw new ApiAbortException('Owner permission required'); + } + + return $admin; +} + +function writeAuditLog( + ApiContext $ctx, + ?string $actorDeveloperId, + string $action, + string $targetType, + string $targetId, + array $metadata = [] +): void { + $stmt = $ctx->db->prepare( + 'INSERT INTO audit_logs ( + audit_id, + actor_developer_id, + action, + target_type, + target_id, + metadata_json, + created_at + ) VALUES ( + :audit_id, + :actor_developer_id, + :action, + :target_type, + :target_id, + :metadata_json, + :created_at + )' + ); + + $stmt->execute([ + ':audit_id' => 'audit_' . bin2hex(random_bytes(16)), + ':actor_developer_id' => $actorDeveloperId, + ':action' => $action, + ':target_type' => $targetType, + ':target_id' => $targetId, + ':metadata_json' => $metadata === [] ? null : json_encode($metadata, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), + ':created_at' => date('c'), + ]); +} diff --git a/src/api/v1/index.php b/src/api/v1/index.php index 2c32e74..426381b 100644 --- a/src/api/v1/index.php +++ b/src/api/v1/index.php @@ -1,23 +1,23 @@ path === '/auth/csrf') { + if ($ctx->method !== 'GET') { + ApiResponse::error('METHOD_NOT_ALLOWED', 'Method not allowed', 405); + return true; + } + + ApiResponse::json([ + 'csrf_token' => $_SESSION['csrf_token'], + ]); + return true; + } + if ($ctx->path === '/auth/me') { if ($ctx->method !== 'GET') { ApiResponse::error('METHOD_NOT_ALLOWED', 'Method not allowed', 405); @@ -35,6 +47,7 @@ 'authenticated' => true, 'developer_id' => $developerId, 'user' => $user, + 'csrf_token' => $_SESSION['csrf_token'], ]); return true; } @@ -68,6 +81,15 @@ $_SESSION = []; if (session_status() === PHP_SESSION_ACTIVE) { + $params = session_get_cookie_params(); + setcookie(session_name(), '', [ + 'expires' => time() - 42000, + 'path' => $params['path'] ?? '/', + 'domain' => $params['domain'] ?? '', + 'secure' => (bool) ($params['secure'] ?? false), + 'httponly' => (bool) ($params['httponly'] ?? true), + 'samesite' => $params['samesite'] ?? 'Lax', + ]); session_destroy(); } @@ -78,4 +100,4 @@ } return false; -}; \ No newline at end of file +}; diff --git a/src/api/v1/routes/oauth.php b/src/api/v1/routes/oauth.php index 523acd2..e82b172 100644 --- a/src/api/v1/routes/oauth.php +++ b/src/api/v1/routes/oauth.php @@ -88,23 +88,50 @@ function oauthGet(string $url, string $accessToken): array return true; } + $origin = $_SERVER['HTTP_ORIGIN'] ?? ''; + $fetchSite = strtolower((string) ($_SERVER['HTTP_SEC_FETCH_SITE'] ?? '')); + if ( + ($origin !== '' && !appstoreIsAllowedOrigin($ctx->appConfig, $origin)) + || $fetchSite === 'cross-site' + ) { + ApiResponse::error('CSRF_ORIGIN_INVALID', 'Cross-site OAuth initiation is not allowed', 403); + return true; + } + $provider = ApiRequest::queryString('provider', 'github'); + $pendingProvider = $_SESSION['oauth_provider'] ?? ''; + $pendingState = $_SESSION['oauth_state'] ?? ''; + if ( + is_string($pendingProvider) + && is_string($pendingState) + && $pendingProvider !== '' + && $pendingState !== '' + && $pendingProvider !== $provider + ) { + ApiResponse::error('OAUTH_IN_PROGRESS', 'OAuth login is already in progress', 409); + return true; + } + $config = Provider::get($provider); if ($config === null) { ApiResponse::error('OAUTH_PROVIDER_NOT_CONFIGURED', 'OAuth provider is not configured', 500); return true; } - try { - $state = bin2hex(random_bytes(32)); - } catch (Throwable) { - ApiResponse::error('OAUTH_STATE_FAILED', 'Failed to create OAuth state', 500); - return true; - } + if (is_string($pendingProvider) && is_string($pendingState) && $pendingProvider === $provider && $pendingState !== '') { + $state = $pendingState; + } else { + try { + $state = bin2hex(random_bytes(32)); + } catch (Throwable) { + ApiResponse::error('OAUTH_STATE_FAILED', 'Failed to create OAuth state', 500); + return true; + } - $_SESSION['oauth_state'] = $state; - $_SESSION['oauth_provider'] = $provider; + $_SESSION['oauth_state'] = $state; + $_SESSION['oauth_provider'] = $provider; + } $redirectUri = rtrim($ctx->appConfig['api_url'], '/') . '/v1/oauth/callback'; @@ -212,12 +239,19 @@ function oauthGet(string $url, string $accessToken): array return true; } + if (session_regenerate_id(true) !== true) { + http_response_code(500); + echo 'Failed to create secure session'; + return true; + } + unset($_SESSION['user_id']); $_SESSION['developer_id'] = $developer['developer_id']; + $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); header('Location: ' . rtrim($ctx->appConfig['frontend_url'], '/') . '/'); return true; } return false; -}; \ No newline at end of file +}; diff --git a/src/helper/AppConfig.php b/src/helper/AppConfig.php index d603d47..32e8596 100644 --- a/src/helper/AppConfig.php +++ b/src/helper/AppConfig.php @@ -4,9 +4,15 @@ class AppConfig { public static function get(): array { - $config = require __DIR__ . '/../../config/app.php'; + $configPath = __DIR__ . '/../../config/app.php'; - $env = $config['env'] ?? 'local'; + if (!is_file($configPath)) { + $configPath = __DIR__ . '/../../config/app.example.php'; + } + + $config = require $configPath; + + $env = getenv('APPSTORE_ENV') ?: ($config['env'] ?? 'local'); if (!isset($config[$env])) { throw new RuntimeException('Invalid app environment.'); @@ -22,8 +28,36 @@ public static function get(): array $shared[$key] = $value; } - return $config[$env] + $shared + [ + $resolved = $config[$env] + $shared + [ 'env' => $env, ]; + + $envOverrides = [ + 'APPSTORE_FRONTEND_URL' => 'frontend_url', + 'APPSTORE_API_URL' => 'api_url', + 'APPSTORE_MSIGN_PATH' => 'msign_path', + 'APPSTORE_MSIGN_TIMEOUT_SECONDS' => 'msign_timeout_seconds', + 'APPSTORE_MSIGN_MAX_OUTPUT_BYTES' => 'msign_max_output_bytes', + 'APPSTORE_SESSION_COOKIE_NAME' => 'session_cookie_name', + ]; + + foreach ($envOverrides as $envName => $configKey) { + $value = getenv($envName); + + if ($value !== false && $value !== '') { + $resolved[$configKey] = $value; + } + } + + if (($resolved['env'] ?? '') === 'production' && isset($resolved['allowed_origins']) && is_array($resolved['allowed_origins'])) { + $resolved['allowed_origins'] = array_values(array_filter( + $resolved['allowed_origins'], + static fn ($origin): bool => is_string($origin) + && !str_starts_with($origin, 'http://localhost') + && !str_starts_with($origin, 'http://127.0.0.1') + )); + } + + return $resolved; } } diff --git a/src/public/console/common.js b/src/public/console/common.js index 4a442c8..49ce965 100644 --- a/src/public/console/common.js +++ b/src/public/console/common.js @@ -5,16 +5,55 @@ window.APP_CONFIG = { : "https://api.mochios.org" }; +let csrfToken = null; + function login(provider = "github") { location.href = `${window.APP_CONFIG.API_URL}/v1/oauth?provider=${encodeURIComponent(provider)}`; } +function isStateChangingMethod(method) { + return ["POST", "PUT", "PATCH", "DELETE"].includes(method.toUpperCase()); +} + +async function getCsrfToken() { + if (csrfToken) { + return csrfToken; + } + + const response = await fetch(`${window.APP_CONFIG.API_URL}/v1/auth/csrf`, { + credentials: "include", + cache: "no-store" + }); + + if (!response.ok) { + return null; + } + + const data = await response.json(); + csrfToken = data.csrf_token || null; + + return csrfToken; +} + async function apiFetch(path, options = {}) { + const method = (options.method || "GET").toUpperCase(); + const headers = new Headers(options.headers || {}); + + if (isStateChangingMethod(method)) { + const token = await getCsrfToken(); + + if (token) { + headers.set("X-CSRF-Token", token); + } + } + const response = await fetch(`${window.APP_CONFIG.API_URL}${path}`, { credentials: "include", cache: "no-store", - ...options + ...options, + method, + headers }); const text = await response.text(); @@ -44,6 +83,8 @@ async function showMe() { return null; } + csrfToken = result.data.csrf_token || csrfToken; + return result.data; } @@ -245,4 +286,4 @@ async function updateLoginState() { }; } -updateLoginState().then(() => {}); \ No newline at end of file +updateLoginState().then(() => {}); From 94ec6cc1550e210c6b4f5d050df1ad874bf2bc8b Mon Sep 17 00:00:00 2001 From: dev minto <205936182+minto-dane@users.noreply.github.com> Date: Wed, 10 Jun 2026 23:23:20 -0400 Subject: [PATCH 3/6] Harden admin actions and HTTP surfaces --- docker/apache/appstore.conf | 10 ++++++- server/etc/nginx/sites-available/appstore | 14 ++++++++- src/api/v1/routes/admin_certificates.php | 35 +++++++++++++++++------ src/api/v1/routes/admin_releases.php | 27 ++++++++++++++++- src/api/v1/routes/teams.php | 26 ++++++++++------- src/migrations/008_audit_logs.sql | 15 ++++++++++ src/public/script.js | 18 +++++++----- 7 files changed, 115 insertions(+), 30 deletions(-) create mode 100644 src/migrations/008_audit_logs.sql diff --git a/docker/apache/appstore.conf b/docker/apache/appstore.conf index 037246a..db7ee4e 100644 --- a/docker/apache/appstore.conf +++ b/docker/apache/appstore.conf @@ -1,5 +1,13 @@ ServerName store.mochios.org + + Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains" + Header always set X-Content-Type-Options "nosniff" + Header always set Referrer-Policy "strict-origin-when-cross-origin" + Header always set Content-Security-Policy "frame-ancestors 'none'; base-uri 'self'; object-src 'none'" + Header always set Permissions-Policy "camera=(), microphone=(), geolocation=()" + + ServerName store.mochios.org @@ -42,4 +50,4 @@ ServerName store.mochios.org RewriteCond %{REQUEST_FILENAME} !-f RewriteRule ^ router.php [QSA,L] - \ No newline at end of file + diff --git a/server/etc/nginx/sites-available/appstore b/server/etc/nginx/sites-available/appstore index 7793713..0173180 100644 --- a/server/etc/nginx/sites-available/appstore +++ b/server/etc/nginx/sites-available/appstore @@ -2,6 +2,12 @@ server { listen 80; server_name store.mochios.org; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Content-Security-Policy "frame-ancestors 'none'; base-uri 'self'; object-src 'none'" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + location / { proxy_pass http://127.0.0.1:3001; proxy_http_version 1.1; @@ -16,6 +22,12 @@ server { listen 80; server_name console.mochios.org; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Content-Security-Policy "frame-ancestors 'none'; base-uri 'self'; object-src 'none'" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + location / { proxy_pass http://127.0.0.1:3001; proxy_http_version 1.1; @@ -24,4 +36,4 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } -} \ No newline at end of file +} diff --git a/src/api/v1/routes/admin_certificates.php b/src/api/v1/routes/admin_certificates.php index e4baefb..7402bce 100644 --- a/src/api/v1/routes/admin_certificates.php +++ b/src/api/v1/routes/admin_certificates.php @@ -30,13 +30,19 @@ return true; } + $verification = $ctx->certificateRepo->updateVerification( + $developerId, + $status, + $note, + $adminId + ); + + writeAuditLog($ctx, $adminId, 'developer.verification.update', 'developer', $developerId, [ + 'verification_status' => $status, + ]); + ApiResponse::json([ - 'verification' => $ctx->certificateRepo->updateVerification( - $developerId, - $status, - $note, - $adminId - ), + 'verification' => $verification, ]); return true; } @@ -47,7 +53,7 @@ return true; } - $admin = requireAdminDeveloper($ctx); + $admin = requireOwnerDeveloper($ctx); $adminId = $admin['developer_id']; if (!$ctx->certificateAuthority->isConfigured()) { @@ -74,6 +80,11 @@ $issued ); + writeAuditLog($ctx, $adminId, 'certificate.issue', 'certificate', $certificate['certificate_id'], [ + 'csr_id' => $csr['csr_id'], + 'developer_id' => $csr['developer_id'], + ]); + ApiResponse::json([ 'certificate' => $certificate, ], 201); @@ -102,6 +113,8 @@ return true; } + writeAuditLog($ctx, $adminId, 'certificate_request.reject', 'certificate_request', $matches[1]); + ApiResponse::json([ 'certificate_request' => $csr, ]); @@ -114,7 +127,7 @@ return true; } - requireAdminDeveloper($ctx); + $admin = requireOwnerDeveloper($ctx); $payload = readJsonBody(); $reason = trim((string) ($payload['reason'] ?? '')); @@ -130,6 +143,10 @@ return true; } + writeAuditLog($ctx, $admin['developer_id'], 'certificate.revoke', 'certificate', $matches[1], [ + 'reason' => $reason, + ]); + ApiResponse::json([ 'certificate' => $certificate, ]); @@ -137,4 +154,4 @@ } return false; -}; \ No newline at end of file +}; diff --git a/src/api/v1/routes/admin_releases.php b/src/api/v1/routes/admin_releases.php index 1ca02ef..490368f 100644 --- a/src/api/v1/routes/admin_releases.php +++ b/src/api/v1/routes/admin_releases.php @@ -56,8 +56,28 @@ return true; } + $releaseDeveloperId = $ctx->developerReleaseRepo->findDeveloperIdByReleaseId($releaseId); + $keyId = (string) ($current['certificate_id'] ?? ''); + if ( + $releaseDeveloperId === null + || $keyId === '' + || $ctx->publicKeyRepo->findActiveOwnedByKeyId($keyId, $releaseDeveloperId) === null + ) { + ApiResponse::error( + 'SIGNING_KEY_NOT_ACTIVE', + 'Release signing key is not active', + 409 + ); + return true; + } + $release = $ctx->developerReleaseRepo->approve($releaseId, $adminId); + writeAuditLog($ctx, $adminId, 'release.approve', 'release', $releaseId, [ + 'bundle_id' => $current['bundle_id'], + 'version' => $current['version'], + ]); + ApiResponse::json([ 'release' => $release, ]); @@ -104,6 +124,11 @@ $message ); + writeAuditLog($ctx, $adminId, 'release.reject', 'release', $releaseId, [ + 'bundle_id' => $current['bundle_id'], + 'version' => $current['version'], + ]); + ApiResponse::json([ 'release' => $release, ]); @@ -151,4 +176,4 @@ } return false; -}; \ No newline at end of file +}; diff --git a/src/api/v1/routes/teams.php b/src/api/v1/routes/teams.php index 56a62a4..2c0daca 100644 --- a/src/api/v1/routes/teams.php +++ b/src/api/v1/routes/teams.php @@ -1,17 +1,21 @@ - - 「${val}」に関するアプリが表示されます - - `; + const list = document.getElementById('search-result-list'); + const empty = document.createElement('div'); + + empty.style.textAlign = 'center'; + empty.style.padding = '40px 20px'; + empty.style.color = 'var(--g400)'; + empty.style.fontSize = '13px'; + empty.textContent = `「${val}」に関するアプリが表示されます`; + + list.replaceChildren(empty); } } @@ -47,4 +51,4 @@ document.querySelectorAll('.cat-chip').forEach(chip => { parent.querySelectorAll('.cat-chip').forEach(c => c.classList.remove('active')); this.classList.add('active'); }); -}); \ No newline at end of file +}); From 3f6af37601d3652e64e70a8999b7fe4ec78011cc Mon Sep 17 00:00:00 2001 From: dev minto <205936182+minto-dane@users.noreply.github.com> Date: Wed, 10 Jun 2026 23:23:20 -0400 Subject: [PATCH 4/6] Add security regression coverage --- src/cli/migrate.php | 56 +++++++- src/tests/ApiTest.php | 263 +++++++++++++++++++++++++++++++++- src/tests/DeveloperCaTest.php | 46 ++++-- src/tests/SecurityTest.php | 189 ++++++++++++++++++++++++ src/tests/Support.php | 24 +++- src/tests/run.php | 5 + 6 files changed, 563 insertions(+), 20 deletions(-) create mode 100644 src/tests/SecurityTest.php diff --git a/src/cli/migrate.php b/src/cli/migrate.php index ed04249..5a92971 100644 --- a/src/cli/migrate.php +++ b/src/cli/migrate.php @@ -17,11 +17,63 @@ } $storage = new PackageStorage(Paths::repoRoot()); -$storage->ensurePlaceholderPackage( +$placeholderPath = $storage->ensurePlaceholderPackage( 'data/releases/com.example/0.1.0.pkg', "mochiOS placeholder package for com.example 0.1.0\n" ); -echo "Migration complete\n"; +$db->prepare( + 'INSERT OR IGNORE INTO apps ( + bundle_id, + name, + version, + developer, + description, + icon + ) VALUES ( + :bundle_id, + :name, + :version, + :developer, + :description, + :icon + )' +)->execute([ + ':bundle_id' => 'com.example', + ':name' => 'Example App', + ':version' => '0.1.0', + ':developer' => 'mochiOS', + ':description' => 'Example app for AppStore development.', + ':icon' => null, +]); + +$db->prepare( + 'INSERT OR IGNORE INTO releases ( + bundle_id, + version, + size, + sha256, + changelog, + download_path, + created_at + ) VALUES ( + :bundle_id, + :version, + :size, + :sha256, + :changelog, + :download_path, + :created_at + )' +)->execute([ + ':bundle_id' => 'com.example', + ':version' => '0.1.0', + ':size' => filesize($placeholderPath) ?: 0, + ':sha256' => hash_file('sha256', $placeholderPath), + ':changelog' => 'Initial example release.', + ':download_path' => 'data/releases/com.example/0.1.0.pkg', + ':created_at' => '2026-06-06T00:00:00+00:00', +]); +echo "Migration complete\n"; diff --git a/src/tests/ApiTest.php b/src/tests/ApiTest.php index 4da6180..5835207 100644 --- a/src/tests/ApiTest.php +++ b/src/tests/ApiTest.php @@ -66,9 +66,10 @@ ':status' => 'active', ]); - $session = [ + $session = csrfSession([ 'developer_id' => $developerId, - ]; + ]); + $headers = csrfHeaders(); $profileResponse = apiJsonRequest('/developers/me', [], 'GET', null, $session); $profile = decodeJson($profileResponse['body']); @@ -79,8 +80,12 @@ '/keys', [], 'POST', - ['public_key' => 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestKey mochios@example'], - $session + [ + 'key_id' => 'test-key-1', + 'public_key' => base64_encode(str_repeat('a', 32)), + ], + $session, + $headers ); $keyPayload = decodeJson($keyResponse['body']); assertSame(201, $keyResponse['status']); @@ -96,7 +101,8 @@ [], 'POST', [], - $session + $session, + $headers ); $revoked = decodeJson($revokeResponse['body']); assertSame(200, $revokeResponse['status']); @@ -110,7 +116,8 @@ 'bundle_id' => 'org.mochios.example', 'app_name' => 'Example App', ], - $session + $session, + $headers ); $bundlePayload = decodeJson($bundleResponse['body']); assertSame(201, $bundleResponse['status']); @@ -122,3 +129,247 @@ assertSame('org.mochios.example', $listBundles['bundle_ids'][0]['bundle_id']); }); +it('rejects state-changing requests without csrf token', function (): void { + $pdo = Database::get(); + $developerId = 'dev_csrf_suite'; + + $stmt = $pdo->prepare( + 'INSERT INTO developers (developer_id, created_at, status) + VALUES (:developer_id, :created_at, :status)' + ); + $stmt->execute([ + ':developer_id' => $developerId, + ':created_at' => date('c'), + ':status' => 'active', + ]); + + $response = apiJsonRequest( + '/keys', + [], + 'POST', + [ + 'key_id' => 'csrf-key', + 'public_key' => base64_encode(str_repeat('b', 32)), + ], + ['developer_id' => $developerId, 'csrf_token' => 'expected-token'] + ); + $payload = decodeJson($response['body']); + + assertSame(403, $response['status']); + assertSame('CSRF_TOKEN_INVALID', $payload['error']['code']); + + $submitResponse = apiJsonRequest( + '/developer/releases/rel_missing/submit', + [], + 'POST', + [], + ['developer_id' => $developerId, 'csrf_token' => 'expected-token'] + ); + $submitPayload = decodeJson($submitResponse['body']); + assertSame(403, $submitResponse['status']); + assertSame('CSRF_TOKEN_INVALID', $submitPayload['error']['code']); + + $adminResponse = apiJsonRequest( + '/admin/releases/rel_missing/approve', + [], + 'POST', + [], + ['developer_id' => 'admin-dev', 'csrf_token' => 'expected-token'] + ); + $adminPayload = decodeJson($adminResponse['body']); + assertSame(403, $adminResponse['status']); + assertSame('CSRF_TOKEN_INVALID', $adminPayload['error']['code']); +}); + +it('rejects state-changing requests from invalid origins even with csrf token', function (): void { + $session = csrfSession(['developer_id' => 'dev_origin_suite']); + + $originResponse = apiJsonRequest( + '/keys', + [], + 'POST', + [ + 'key_id' => 'origin-key', + 'public_key' => base64_encode(str_repeat('c', 32)), + ], + $session, + csrfHeaders(['Origin' => 'https://evil.example']) + ); + $originPayload = decodeJson($originResponse['body']); + assertSame(403, $originResponse['status']); + assertSame('CSRF_ORIGIN_INVALID', $originPayload['error']['code']); + + $fetchResponse = apiJsonRequest( + '/keys', + [], + 'POST', + [ + 'key_id' => 'fetch-key', + 'public_key' => base64_encode(str_repeat('d', 32)), + ], + $session, + csrfHeaders(['Sec-Fetch-Site' => 'cross-site']) + ); + $fetchPayload = decodeJson($fetchResponse['body']); + assertSame(403, $fetchResponse['status']); + assertSame('CSRF_ORIGIN_INVALID', $fetchPayload['error']['code']); +}); + +it('rejects cross-site oauth initiation without overwriting state', function (): void { + $session = [ + 'oauth_provider' => 'github', + 'oauth_state' => 'existing-state', + ]; + + $response = apiJsonRequest( + '/oauth', + ['provider' => 'github'], + 'GET', + null, + $session, + ['Sec-Fetch-Site' => 'cross-site'] + ); + $payload = decodeJson($response['body']); + + assertSame(403, $response['status']); + assertSame('CSRF_ORIGIN_INVALID', $payload['error']['code']); +}); + +it('rejects submit and approve when the signing key was revoked', function (): void { + $pdo = Database::get(); + $developerId = 'dev_revoked_key_suite'; + $adminId = 'dev_revoked_key_admin'; + $createdAt = date('c'); + $publicKey = str_repeat('e', 32); + + $stmt = $pdo->prepare( + 'INSERT INTO developers (developer_id, created_at, status) + VALUES (:developer_id, :created_at, :status)' + ); + foreach ([$developerId, $adminId] as $id) { + $stmt->execute([ + ':developer_id' => $id, + ':created_at' => $createdAt, + ':status' => 'active', + ]); + } + + $pdo->prepare( + 'INSERT INTO admin_developers (developer_id, role, created_at) + VALUES (:developer_id, :role, :created_at)' + )->execute([ + ':developer_id' => $adminId, + ':role' => 'owner', + ':created_at' => $createdAt, + ]); + + $pdo->prepare( + 'INSERT INTO public_keys ( + key_id, + developer_id, + public_key, + fingerprint, + created_at, + revoked_at + ) VALUES ( + :key_id, + :developer_id, + :public_key, + :fingerprint, + :created_at, + :revoked_at + )' + )->execute([ + ':key_id' => 'revoked-key-suite', + ':developer_id' => $developerId, + ':public_key' => base64_encode($publicKey), + ':fingerprint' => hash('sha256', $publicKey), + ':created_at' => $createdAt, + ':revoked_at' => $createdAt, + ]); + + $pdo->prepare( + 'INSERT INTO bundle_ids (bundle_id, developer_id, app_name, status, created_at) + VALUES (:bundle_id, :developer_id, :app_name, :status, :created_at)' + )->execute([ + ':bundle_id' => 'org.mochios.revoked', + ':developer_id' => $developerId, + ':app_name' => 'Revoked Key App', + ':status' => 'reserved', + ':created_at' => $createdAt, + ]); + + $releaseInsert = $pdo->prepare( + 'INSERT INTO developer_releases ( + release_id, + bundle_id, + version, + manifest_hash, + package_hash, + signature, + certificate_id, + status, + created_at, + package_path, + package_size, + changelog + ) VALUES ( + :release_id, + :bundle_id, + :version, + :manifest_hash, + :package_hash, + :signature, + :certificate_id, + :status, + :created_at, + :package_path, + :package_size, + :changelog + )' + ); + + foreach ([ + ['rel_revoked_submit', '1.0.0', 'draft'], + ['rel_revoked_approve', '1.0.1', 'submitted'], + ] as [$releaseId, $version, $status]) { + $releaseInsert->execute([ + ':release_id' => $releaseId, + ':bundle_id' => 'org.mochios.revoked', + ':version' => $version, + ':manifest_hash' => null, + ':package_hash' => hash('sha256', $releaseId), + ':signature' => 'signature', + ':certificate_id' => 'revoked-key-suite', + ':status' => $status, + ':created_at' => $createdAt, + ':package_path' => 'data/packages/org.mochios.revoked/' . $version . '.pkg', + ':package_size' => 1, + ':changelog' => null, + ]); + } + + $submit = apiJsonRequest( + '/developer/releases/rel_revoked_submit/submit', + [], + 'POST', + [], + csrfSession(['developer_id' => $developerId]), + csrfHeaders() + ); + $submitPayload = decodeJson($submit['body']); + assertSame(409, $submit['status']); + assertSame('SIGNING_KEY_NOT_ACTIVE', $submitPayload['error']['code']); + + $approve = apiJsonRequest( + '/admin/releases/rel_revoked_approve/approve', + [], + 'POST', + [], + csrfSession(['developer_id' => $adminId]), + csrfHeaders() + ); + $approvePayload = decodeJson($approve['body']); + assertSame(409, $approve['status']); + assertSame('SIGNING_KEY_NOT_ACTIVE', $approvePayload['error']['code']); +}); diff --git a/src/tests/DeveloperCaTest.php b/src/tests/DeveloperCaTest.php index 7904ac8..74ebf6c 100644 --- a/src/tests/DeveloperCaTest.php +++ b/src/tests/DeveloperCaTest.php @@ -26,27 +26,47 @@ $developerSession = [ 'developer_id' => $developerId, ]; + $developerCsrfSession = csrfSession($developerSession); + $headers = csrfHeaders(); $verificationRequest = apiJsonRequest( '/developer-verifications/request', [], 'POST', ['note' => 'please verify me'], - $developerSession + $developerCsrfSession, + $headers ); $verificationPayload = decodeJson($verificationRequest['body']); assertSame(201, $verificationRequest['status']); assertSame('pending', $verificationPayload['verification']['verification_status']); - $_SERVER['HTTP_X_ADMIN_TOKEN'] = 'test-admin-token'; + $adminId = 'dev_ca_admin'; + $stmt->execute([ + ':developer_id' => $adminId, + ':created_at' => date('c'), + ':status' => 'active', + ]); + Database::get() + ->prepare( + 'INSERT INTO admin_developers (developer_id, role, created_at) + VALUES (:developer_id, :role, :created_at)' + ) + ->execute([ + ':developer_id' => $adminId, + ':role' => 'owner', + ':created_at' => date('c'), + ]); + $adminSession = csrfSession(['developer_id' => $adminId]); + $verifyResponse = apiJsonRequest( '/admin/developers/' . $developerId . '/verification', [], 'POST', ['verification_status' => 'verified', 'note' => 'approved'], - [] + $adminSession, + $headers ); - unset($_SERVER['HTTP_X_ADMIN_TOKEN']); $verifiedPayload = decodeJson($verifyResponse['body']); assertSame(200, $verifyResponse['status']); assertSame('verified', $verifiedPayload['verification']['verification_status']); @@ -58,21 +78,21 @@ [], 'POST', ['csr_pem' => $csr['csr_pem']], - $developerSession + $developerCsrfSession, + $headers ); $csrPayload = decodeJson($csrResponse['body']); assertSame(201, $csrResponse['status']); assertSame('pending', $csrPayload['certificate_request']['status']); - $_SERVER['HTTP_X_ADMIN_TOKEN'] = 'test-admin-token'; $issueResponse = apiJsonRequest( '/admin/certificate-requests/' . $csrPayload['certificate_request']['csr_id'] . '/issue', [], 'POST', [], - [] + $adminSession, + $headers ); - unset($_SERVER['HTTP_X_ADMIN_TOKEN']); $certificatePayload = decodeJson($issueResponse['body']); assertSame(201, $issueResponse['status']); assertSame('active', $certificatePayload['certificate']['status']); @@ -83,17 +103,21 @@ assertSame(200, $listResponse['status']); assertSame(1, count($listPayload['certificates'])); - $_SERVER['HTTP_X_ADMIN_TOKEN'] = 'test-admin-token'; $revokeResponse = apiJsonRequest( '/admin/certificates/' . $certificatePayload['certificate']['certificate_id'] . '/revoke', [], 'POST', ['reason' => 'compromised'], - [] + $adminSession, + $headers ); - unset($_SERVER['HTTP_X_ADMIN_TOKEN']); $revokePayload = decodeJson($revokeResponse['body']); assertSame(200, $revokeResponse['status']); assertSame('revoked', $revokePayload['certificate']['status']); assertSame('compromised', $revokePayload['certificate']['revocation_reason']); + + $auditCount = (int) Database::get() + ->query("SELECT COUNT(*) FROM audit_logs WHERE action IN ('certificate.issue', 'certificate.revoke')") + ->fetchColumn(); + assertSame(2, $auditCount); }); diff --git a/src/tests/SecurityTest.php b/src/tests/SecurityTest.php new file mode 100644 index 0000000..c524177 --- /dev/null +++ b/src/tests/SecurityTest.php @@ -0,0 +1,189 @@ + $content) { + $absolute = $workDir . '/' . $path; + $directory = dirname($absolute); + if (!is_dir($directory)) { + mkdir($directory, 0777, true); + } + file_put_contents($absolute, $content); + } + + $packagePath = tempnam(sys_get_temp_dir(), 'appstore-pkg-out-') . '.pkg'; + $names = $tarNames === [] ? array_keys($files) : $tarNames; + $command = ['tar', ...$tarOptions, '--hard-dereference', '-czf', $packagePath, '-C', $workDir, ...$names]; + + $process = proc_open($command, [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ], $pipes); + + if (!is_resource($process)) { + throw new RuntimeException('Failed to execute tar'); + } + + fclose($pipes[0]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + + if (proc_close($process) !== 0) { + throw new RuntimeException('tar failed: ' . $stderr); + } + + return $packagePath; +} + +function createDirectoryHeavyPackage(int $directoryCount): string +{ + $workDir = sys_get_temp_dir() . '/appstore-dir-pkg-' . bin2hex(random_bytes(4)); + mkdir($workDir . '/app', 0777, true); + + file_put_contents($workDir . '/about.toml', "bundle_id = \"org.mochios.secure\"\nversion = \"1.0.0\"\nname = \"Secure\"\nentry = \"app/main.js\"\n"); + file_put_contents($workDir . '/manifest.toml', "[app]\nid = \"org.mochios.secure\"\n"); + file_put_contents($workDir . '/app/main.js', "console.log('ok');\n"); + + $names = []; + for ($i = 0; $i < $directoryCount; $i++) { + $name = 'dirs/d' . $i; + mkdir($workDir . '/' . $name, 0777, true); + $names[] = $name; + } + + $names[] = 'about.toml'; + $names[] = 'manifest.toml'; + $names[] = 'app/main.js'; + + $packagePath = tempnam(sys_get_temp_dir(), 'appstore-dir-pkg-out-') . '.pkg'; + $command = ['tar', '--hard-dereference', '-czf', $packagePath, '-C', $workDir, ...$names]; + + $process = proc_open($command, [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ], $pipes); + + if (!is_resource($process)) { + throw new RuntimeException('Failed to execute tar'); + } + + fclose($pipes[0]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + + if (proc_close($process) !== 0) { + throw new RuntimeException('tar failed: ' . $stderr); + } + + return $packagePath; +} + +function minimalPackageFiles(array $extra = []): array +{ + return [ + 'about.toml' => "bundle_id = \"org.mochios.secure\"\nversion = \"1.0.0\"\nname = \"Secure\"\nentry = \"app/main.js\"\n", + 'manifest.toml' => "[app]\nid = \"org.mochios.secure\"\n", + 'app/main.js' => "console.log('ok');\n", + ] + $extra; +} + +it('validates base64 ed25519 public keys for registration', function (): void { + assertTrue(PublicKeyRepository::isValidEd25519PublicKey(base64_encode(str_repeat('k', 32)))); + assertSame(false, PublicKeyRepository::isValidEd25519PublicKey('')); + assertSame(false, PublicKeyRepository::isValidEd25519PublicKey(base64_encode(str_repeat('k', 31)))); + assertSame(false, PublicKeyRepository::isValidEd25519PublicKey('ssh-ed25519 AAAA comment')); +}); + +it('verifies package signatures with the registered public key file', function (): void { + $dir = sys_get_temp_dir() . '/appstore-msign-' . bin2hex(random_bytes(4)); + mkdir($dir, 0777, true); + + $logPath = $dir . '/args.log'; + $msignPath = $dir . '/msign'; + file_put_contents($msignPath, "#!/bin/sh\nprintf '%s\\n' \"$@\" > " . escapeshellarg($logPath) . "\ncat \"$4\" | grep -q registered-public-key || exit 7\nprintf 'key_id: registered-key\\n'\n"); + chmod($msignPath, 0755); + + $packagePath = tempnam(sys_get_temp_dir(), 'appstore-msign-pkg-'); + file_put_contents($packagePath, 'package'); + + $verifier = new PackageSignatureVerifier($msignPath, 2, 4096); + $result = $verifier->verifyWithPublicKey($packagePath, 'registered-public-key'); + + assertSame('registered-key', $result['key_id']); + assertContains('--pubkey', file_get_contents($logPath)); +}); + +it('keeps localhost out of production cors origins', function (): void { + $originalEnv = getenv('APPSTORE_ENV'); + putenv('APPSTORE_ENV=production'); + + try { + $config = AppConfig::get(); + } finally { + if ($originalEnv === false) { + putenv('APPSTORE_ENV'); + } else { + putenv('APPSTORE_ENV=' . $originalEnv); + } + } + + assertSame(false, in_array('http://localhost:3000', appstoreAllowedOrigins($config), true)); + assertTrue(in_array('https://console.mochios.org', appstoreAllowedOrigins($config), true)); +}); + +it('inspects normal packages and rejects duplicate paths', function (): void { + $service = new PackageInspectService(); + $normal = createPackageArchive(minimalPackageFiles()); + $inspection = $service->inspect($normal); + + assertSame('org.mochios.secure', $inspection['about']['bundle_id']); + assertTrue(isset($inspection['hashes']['package_sha256'])); + assertTrue(isset($inspection['hashes']['content_hash'])); + + $duplicate = createPackageArchive( + minimalPackageFiles(), + ['about.toml', 'about.toml', 'manifest.toml', 'app/main.js'] + ); + + try { + $service->inspect($duplicate); + throw new RuntimeException('duplicate package path was accepted'); + } catch (RuntimeException $e) { + assertContains('Duplicate package path', $e->getMessage()); + } +}); + +it('rejects pax headers and excessive directory entries', function (): void { + $service = new PackageInspectService(); + $longPath = 'long/' . str_repeat('a', 120) . '.txt'; + $pax = createPackageArchive( + minimalPackageFiles([$longPath => 'x']), + [], + ['--format=posix'] + ); + + try { + $service->inspect($pax); + throw new RuntimeException('pax package was accepted'); + } catch (RuntimeException $e) { + assertContains('Only regular files are allowed', $e->getMessage()); + } + + $manyDirectories = createDirectoryHeavyPackage(4105); + + try { + $service->inspect($manyDirectories); + throw new RuntimeException('directory-heavy package was accepted'); + } catch (RuntimeException $e) { + assertContains('too many entries', $e->getMessage()); + } +}); diff --git a/src/tests/Support.php b/src/tests/Support.php index 0cbf90b..8ee7d22 100644 --- a/src/tests/Support.php +++ b/src/tests/Support.php @@ -67,10 +67,22 @@ function apiJsonRequest( array $query = [], string $method = 'GET', ?array $jsonBody = null, - array $session = [] + array $session = [], + array $headers = [] ): array { $_SERVER['REQUEST_METHOD'] = $method; $_SERVER['REQUEST_URI'] = $path . ($query === [] ? '' : '?' . http_build_query($query)); + unset( + $_SERVER['HTTP_ORIGIN'], + $_SERVER['HTTP_SEC_FETCH_SITE'], + $_SERVER['HTTP_X_CSRF_TOKEN'], + $_SERVER['HTTP_X_ADMIN_TOKEN'] + ); + + foreach ($headers as $name => $value) { + $_SERVER['HTTP_' . strtoupper(str_replace('-', '_', $name))] = $value; + } + $_GET = $query; $_POST = []; @@ -96,6 +108,16 @@ function apiJsonRequest( ]; } +function csrfSession(array $session = []): array +{ + return ['csrf_token' => 'test-csrf-token'] + $session; +} + +function csrfHeaders(array $headers = []): array +{ + return ['X-CSRF-Token' => 'test-csrf-token'] + $headers; +} + function generatePemKeyAndCsr(string $commonName): array { $privateKey = openssl_pkey_new([ diff --git a/src/tests/run.php b/src/tests/run.php index 77f95d2..51488d0 100644 --- a/src/tests/run.php +++ b/src/tests/run.php @@ -11,9 +11,13 @@ require_once __DIR__ . '/../helper/ApiResponse.php'; require_once __DIR__ . '/../helper/PackageStorage.php'; require_once __DIR__ . '/../helper/AppConfig.php'; +require_once __DIR__ . '/../api/cors.php'; require_once __DIR__ . '/../helper/DeveloperRepository.php'; require_once __DIR__ . '/../helper/DeveloperCertificateRepository.php'; require_once __DIR__ . '/../helper/CertificateAuthority.php'; +require_once __DIR__ . '/../helper/PublicKeyRepository.php'; +require_once __DIR__ . '/../helper/PackageInspectService.php'; +require_once __DIR__ . '/../helper/PackageSignatureVerifier.php'; require_once __DIR__ . '/Support.php'; @@ -96,6 +100,7 @@ require_once __DIR__ . '/DeveloperRepositoryTest.php'; require_once __DIR__ . '/DeveloperCaTest.php'; require_once __DIR__ . '/ApiTest.php'; +require_once __DIR__ . '/SecurityTest.php'; $failures = 0; $results = []; From a1b180d23d28964768462de172aefba7e5614540 Mon Sep 17 00:00:00 2001 From: dev minto <205936182+minto-dane@users.noreply.github.com> Date: Fri, 12 Jun 2026 20:10:39 -0400 Subject: [PATCH 5/6] Move sample data to tests and document CSRF API --- src/api/v1/list.yaml | 89 ++++++++++++++++++++++++++++++++++++++++++++ src/cli/migrate.php | 61 ------------------------------ src/tests/run.php | 60 +++++++++++++++++++++++++++++ 3 files changed, 149 insertions(+), 61 deletions(-) diff --git a/src/api/v1/list.yaml b/src/api/v1/list.yaml index f058988..39ad3b2 100644 --- a/src/api/v1/list.yaml +++ b/src/api/v1/list.yaml @@ -293,9 +293,33 @@ paths: oneOf: - $ref: "#/components/schemas/OAuthUser" - type: "null" + csrf_token: + type: string + description: CSRF token to send as X-CSRF-Token on state-changing requests. "401": $ref: "#/components/responses/Unauthorized" + /auth/csrf: + get: + tags: [Auth] + summary: Get CSRF token for state-changing requests + security: [] + responses: + "200": + description: CSRF token for this session. + content: + application/json: + schema: + type: object + required: [csrf_token] + additionalProperties: false + properties: + csrf_token: + type: string + description: Send this value as X-CSRF-Token on POST, PUT, PATCH, and DELETE requests. + "405": + $ref: "#/components/responses/MethodNotAllowed" + /developers/me: get: tags: [Auth] @@ -319,6 +343,9 @@ paths: post: tags: [Auth] summary: Logout current session + security: + - sessionCookie: [] + csrfToken: [] responses: "200": description: Logged out. @@ -357,6 +384,9 @@ paths: post: tags: [Developer Apps] summary: Create developer app from reserved Bundle ID + security: + - sessionCookie: [] + csrfToken: [] requestBody: required: true content: @@ -428,6 +458,9 @@ paths: post: tags: [Developer Apps, Teams] summary: Assign app to a team or return it to personal ownership + security: + - sessionCookie: [] + csrfToken: [] parameters: - $ref: "#/components/parameters/BundleId" requestBody: @@ -493,6 +526,9 @@ paths: post: tags: [Developer Releases] summary: Upload signed package and create draft release + security: + - sessionCookie: [] + csrfToken: [] parameters: - $ref: "#/components/parameters/BundleId" requestBody: @@ -561,6 +597,9 @@ paths: post: tags: [Developer Releases] summary: Submit draft or rejected release for review + security: + - sessionCookie: [] + csrfToken: [] parameters: - $ref: "#/components/parameters/ReleaseId" responses: @@ -605,6 +644,9 @@ paths: post: tags: [Public Keys] summary: Register public key + security: + - sessionCookie: [] + csrfToken: [] requestBody: required: true content: @@ -646,6 +688,9 @@ paths: post: tags: [Public Keys] summary: Revoke public key + security: + - sessionCookie: [] + csrfToken: [] parameters: - name: key_id in: path @@ -693,6 +738,9 @@ paths: post: tags: [Bundle IDs] summary: Reserve Bundle ID + security: + - sessionCookie: [] + csrfToken: [] requestBody: required: true content: @@ -750,6 +798,9 @@ paths: post: tags: [Teams] summary: Create team + security: + - sessionCookie: [] + csrfToken: [] requestBody: required: true content: @@ -846,6 +897,9 @@ paths: post: tags: [Teams] summary: Add team member + security: + - sessionCookie: [] + csrfToken: [] parameters: - $ref: "#/components/parameters/TeamId" requestBody: @@ -888,6 +942,9 @@ paths: post: tags: [Teams] summary: Update team member role + security: + - sessionCookie: [] + csrfToken: [] parameters: - $ref: "#/components/parameters/TeamId" - $ref: "#/components/parameters/DeveloperId" @@ -935,6 +992,9 @@ paths: post: tags: [Teams] summary: Remove team member + security: + - sessionCookie: [] + csrfToken: [] parameters: - $ref: "#/components/parameters/TeamId" - $ref: "#/components/parameters/DeveloperId" @@ -982,6 +1042,9 @@ paths: post: tags: [Certificates] summary: Request developer verification + security: + - sessionCookie: [] + csrfToken: [] requestBody: required: false content: @@ -1027,6 +1090,9 @@ paths: post: tags: [Certificates] summary: Create certificate signing request + security: + - sessionCookie: [] + csrfToken: [] requestBody: required: true content: @@ -1178,6 +1244,9 @@ paths: post: tags: [Admin Releases] summary: Approve submitted release and publish it + security: + - sessionCookie: [] + csrfToken: [] parameters: - $ref: "#/components/parameters/ReleaseId" responses: @@ -1205,6 +1274,9 @@ paths: post: tags: [Admin Releases] summary: Reject submitted release + security: + - sessionCookie: [] + csrfToken: [] parameters: - $ref: "#/components/parameters/ReleaseId" requestBody: @@ -1271,6 +1343,9 @@ paths: post: tags: [Admin Certificates] summary: Update developer verification status + security: + - sessionCookie: [] + csrfToken: [] parameters: - $ref: "#/components/parameters/DeveloperId" requestBody: @@ -1313,6 +1388,9 @@ paths: post: tags: [Admin Certificates] summary: Issue certificate from CSR + security: + - sessionCookie: [] + csrfToken: [] parameters: - $ref: "#/components/parameters/CsrId" responses: @@ -1350,6 +1428,9 @@ paths: post: tags: [Admin Certificates] summary: Reject certificate signing request + security: + - sessionCookie: [] + csrfToken: [] parameters: - $ref: "#/components/parameters/CsrId" requestBody: @@ -1386,6 +1467,9 @@ paths: post: tags: [Admin Certificates] summary: Revoke certificate + security: + - sessionCookie: [] + csrfToken: [] parameters: - $ref: "#/components/parameters/CertificateId" requestBody: @@ -1428,6 +1512,11 @@ components: in: cookie name: PHPSESSID description: PHP session cookie. Frontend sends it with credentials include. + csrfToken: + type: apiKey + in: header + name: X-CSRF-Token + description: CSRF token returned by /auth/csrf or /auth/me. Required for POST, PUT, PATCH, and DELETE requests. parameters: Limit: diff --git a/src/cli/migrate.php b/src/cli/migrate.php index 5a92971..c6afda7 100644 --- a/src/cli/migrate.php +++ b/src/cli/migrate.php @@ -2,7 +2,6 @@ require_once __DIR__ . '/../helper/Paths.php'; require_once __DIR__ . '/../helper/Database.php'; -require_once __DIR__ . '/../helper/PackageStorage.php'; $db = Database::get(); @@ -16,64 +15,4 @@ $db->exec($sql); } -$storage = new PackageStorage(Paths::repoRoot()); -$placeholderPath = $storage->ensurePlaceholderPackage( - 'data/releases/com.example/0.1.0.pkg', - "mochiOS placeholder package for com.example 0.1.0\n" -); - -$db->prepare( - 'INSERT OR IGNORE INTO apps ( - bundle_id, - name, - version, - developer, - description, - icon - ) VALUES ( - :bundle_id, - :name, - :version, - :developer, - :description, - :icon - )' -)->execute([ - ':bundle_id' => 'com.example', - ':name' => 'Example App', - ':version' => '0.1.0', - ':developer' => 'mochiOS', - ':description' => 'Example app for AppStore development.', - ':icon' => null, -]); - -$db->prepare( - 'INSERT OR IGNORE INTO releases ( - bundle_id, - version, - size, - sha256, - changelog, - download_path, - created_at - ) VALUES ( - :bundle_id, - :version, - :size, - :sha256, - :changelog, - :download_path, - :created_at - )' -)->execute([ - ':bundle_id' => 'com.example', - ':version' => '0.1.0', - ':size' => filesize($placeholderPath) ?: 0, - ':sha256' => hash_file('sha256', $placeholderPath), - ':changelog' => 'Initial example release.', - ':download_path' => 'data/releases/com.example/0.1.0.pkg', - ':created_at' => '2026-06-06T00:00:00+00:00', -]); - echo "Migration complete\n"; - diff --git a/src/tests/run.php b/src/tests/run.php index 51488d0..a0a0bf6 100644 --- a/src/tests/run.php +++ b/src/tests/run.php @@ -96,6 +96,66 @@ require __DIR__ . '/../cli/migrate.php'; ob_end_clean(); +$db = Database::get(); +$storage = new PackageStorage(Paths::repoRoot()); +$placeholderPath = $storage->ensurePlaceholderPackage( + 'data/releases/com.example/0.1.0.pkg', + "mochiOS placeholder package for com.example 0.1.0\n" +); + +$db->prepare( + 'INSERT OR IGNORE INTO apps ( + bundle_id, + name, + version, + developer, + description, + icon + ) VALUES ( + :bundle_id, + :name, + :version, + :developer, + :description, + :icon + )' +)->execute([ + ':bundle_id' => 'com.example', + ':name' => 'Example App', + ':version' => '0.1.0', + ':developer' => 'mochiOS', + ':description' => 'Example app for AppStore development.', + ':icon' => null, +]); + +$db->prepare( + 'INSERT OR IGNORE INTO releases ( + bundle_id, + version, + size, + sha256, + changelog, + download_path, + created_at + ) VALUES ( + :bundle_id, + :version, + :size, + :sha256, + :changelog, + :download_path, + :created_at + )' +)->execute([ + ':bundle_id' => 'com.example', + ':version' => '0.1.0', + ':size' => filesize($placeholderPath) ?: 0, + ':sha256' => hash_file('sha256', $placeholderPath), + ':changelog' => 'Initial example release.', + ':download_path' => 'data/releases/com.example/0.1.0.pkg', + ':created_at' => '2026-06-06T00:00:00+00:00', +]); + require_once __DIR__ . '/PathsTest.php'; require_once __DIR__ . '/DeveloperRepositoryTest.php'; require_once __DIR__ . '/DeveloperCaTest.php'; From ea28b2c789a14ee1d8cdfda5ca698ca34e780750 Mon Sep 17 00:00:00 2001 From: dev minto <205936182+minto-dane@users.noreply.github.com> Date: Fri, 12 Jun 2026 21:30:22 -0400 Subject: [PATCH 6/6] Remove msign_path configuration and update references to use default 'msign' --- config/app.example.php | 1 - src/api/v1/bootstrap.php | 2 +- src/helper/AppConfig.php | 1 - src/helper/PackageSignatureVerifier.php | 2 +- 4 files changed, 2 insertions(+), 4 deletions(-) diff --git a/config/app.example.php b/config/app.example.php index 2df9987..09a2ac3 100644 --- a/config/app.example.php +++ b/config/app.example.php @@ -8,7 +8,6 @@ 'ca_key_path' => '', 'ca_key_passphrase' => '', 'ca_certificate_days' => 365, - 'msign_path' => '/usr/local/bin/msign', 'msign_timeout_seconds' => 10, 'msign_max_output_bytes' => 65536, 'session_cookie_name' => 'mochios_appstore_session', diff --git a/src/api/v1/bootstrap.php b/src/api/v1/bootstrap.php index 6e050a8..a328819 100644 --- a/src/api/v1/bootstrap.php +++ b/src/api/v1/bootstrap.php @@ -87,7 +87,7 @@ storage: new PackageStorage(ROOT), adminRepo: new AdminRepository($db), packageSignatureVerifier: new PackageSignatureVerifier( - (string) ($appConfig['msign_path'] ?? '/usr/local/bin/msign'), + 'msign', (int) ($appConfig['msign_timeout_seconds'] ?? 10), (int) ($appConfig['msign_max_output_bytes'] ?? 65536), ), diff --git a/src/helper/AppConfig.php b/src/helper/AppConfig.php index 32e8596..618fdc7 100644 --- a/src/helper/AppConfig.php +++ b/src/helper/AppConfig.php @@ -35,7 +35,6 @@ public static function get(): array $envOverrides = [ 'APPSTORE_FRONTEND_URL' => 'frontend_url', 'APPSTORE_API_URL' => 'api_url', - 'APPSTORE_MSIGN_PATH' => 'msign_path', 'APPSTORE_MSIGN_TIMEOUT_SECONDS' => 'msign_timeout_seconds', 'APPSTORE_MSIGN_MAX_OUTPUT_BYTES' => 'msign_max_output_bytes', 'APPSTORE_SESSION_COOKIE_NAME' => 'session_cookie_name', diff --git a/src/helper/PackageSignatureVerifier.php b/src/helper/PackageSignatureVerifier.php index f26da6b..ee7d42b 100644 --- a/src/helper/PackageSignatureVerifier.php +++ b/src/helper/PackageSignatureVerifier.php @@ -3,7 +3,7 @@ class PackageSignatureVerifier { public function __construct( - private readonly string $msignPath = '/usr/local/bin/msign', + private readonly string $msignPath = 'msign', private readonly int $timeoutSeconds = 10, private readonly int $maxOutputBytes = 65536 ) {