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
) {