Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion config/app.example.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
'ca_key_path' => '',
'ca_key_passphrase' => '',
'ca_certificate_days' => 365,
'msign_timeout_seconds' => 10,
'msign_max_output_bytes' => 65536,
'session_cookie_name' => 'mochios_appstore_session',

'local' => [
'frontend_url' => 'http://localhost:3000',
Expand All @@ -22,7 +25,6 @@
'frontend_url' => 'https://console.mochios.org',
'api_url' => 'https://api.mochios.org',
'allowed_origins' => [
'http://localhost:3000',
'https://console.mochios.org',
],
],
Expand Down
10 changes: 9 additions & 1 deletion docker/apache/appstore.conf
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
ServerName store.mochios.org

<IfModule mod_headers.c>
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=()"
</IfModule>

<VirtualHost *:80>
ServerName store.mochios.org

Expand Down Expand Up @@ -42,4 +50,4 @@ ServerName store.mochios.org
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ router.php [QSA,L]
</Directory>
</VirtualHost>
</VirtualHost>
7 changes: 7 additions & 0 deletions docs/appbundle.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` と同じ意味として扱う

## 予定

将来的に以下の機能を追加可能とする。
Expand Down
14 changes: 13 additions & 1 deletion server/etc/nginx/sites-available/appstore
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -24,4 +36,4 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}
32 changes: 32 additions & 0 deletions src/api/cors.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

function appstoreAllowedOrigins(array $appConfig): array
{
$origins = $appConfig['allowed_origins'] ?? [];

if (!is_array($origins)) {
return [];
}

return array_values(array_filter($origins, static fn ($origin): bool => 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');
}
Comment thread
tas0dev marked this conversation as resolved.
21 changes: 6 additions & 15 deletions src/api/router.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -55,4 +46,4 @@
],
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);

return true;
return true;
49 changes: 32 additions & 17 deletions src/api/v1/bootstrap.php
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
<?php

if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}

if (!defined('ROOT')) {
define('ROOT', __DIR__ . '/../../');
}
Expand All @@ -13,6 +9,29 @@
require_once ROOT . 'helper/ApiRequest.php';
require_once ROOT . 'helper/ApiResponse.php';
require_once ROOT . 'helper/AppConfig.php';
require_once __DIR__ . '/../cors.php';

$appConfig = AppConfig::get();

if (session_status() !== PHP_SESSION_ACTIVE) {
session_name((string) ($appConfig['session_cookie_name'] ?? 'mochios_appstore_session'));

session_set_cookie_params([
'lifetime' => 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';
Expand All @@ -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();

Expand All @@ -53,6 +62,8 @@
exit;
}

requireValidCsrfToken($appConfig, $method);

$db = Database::get();

$appRepo = new AppRepository($db);
Expand All @@ -75,9 +86,13 @@
certificateAuthority: CertificateAuthority::fromAppConfig($appConfig),
storage: new PackageStorage(ROOT),
adminRepo: new AdminRepository($db),
packageSignatureVerifier: new PackageSignatureVerifier(),
packageSignatureVerifier: new PackageSignatureVerifier(
'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),
);
);
85 changes: 84 additions & 1 deletion src/api/v1/guards.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -50,4 +82,55 @@ function requireAdminDeveloper(ApiContext $ctx): array
}

return $admin;
}
}

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'),
]);
}
34 changes: 17 additions & 17 deletions src/api/v1/index.php
Original file line number Diff line number Diff line change
@@ -1,23 +1,23 @@
<?php

/** @var ApiContext $context */
$context = require __DIR__ . '/bootstrap.php';
try {
/** @var ApiContext $context */
$context = require __DIR__ . '/bootstrap.php';

$routes = [
require __DIR__ . '/routes/public_apps.php',
require __DIR__ . '/routes/oauth.php',
require __DIR__ . '/routes/auth.php',
require __DIR__ . '/routes/keys.php',
require __DIR__ . '/routes/bundle_ids.php',
require __DIR__ . '/routes/certificates.php',
require __DIR__ . '/routes/admin_certificates.php',
require __DIR__ . '/routes/developer_apps.php',
require __DIR__ . '/routes/developer_releases.php',
require __DIR__ . '/routes/admin_releases.php',
require __DIR__ . '/routes/teams.php',
];
$routes = [
require __DIR__ . '/routes/public_apps.php',
require __DIR__ . '/routes/oauth.php',
require __DIR__ . '/routes/auth.php',
require __DIR__ . '/routes/keys.php',
require __DIR__ . '/routes/bundle_ids.php',
require __DIR__ . '/routes/certificates.php',
require __DIR__ . '/routes/admin_certificates.php',
require __DIR__ . '/routes/developer_apps.php',
require __DIR__ . '/routes/developer_releases.php',
require __DIR__ . '/routes/admin_releases.php',
require __DIR__ . '/routes/teams.php',
];

try {
foreach ($routes as $route) {
if ($route($context)) {
return;
Expand All @@ -30,4 +30,4 @@
return;
}

ApiResponse::error('NOT_FOUND', 'Not found', 404);
ApiResponse::error('NOT_FOUND', 'Not found', 404);
Loading
Loading