2395a4eabc
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Failing after 5s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 23s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 9s
Universal: PR Check / Branch Policy (pull_request) Failing after 2s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 4s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Secret Scan (pull_request) Successful in 6s
Universal: PR Check / Validate PR (pull_request) Failing after 5s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 17s
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 7m48s
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
Fixes all critical and high severity issues from the codebase audit: CRITICAL: - #71: RestoreCommand passed wrong args to RestoreEngine (filepath instead of record ID) — CLI restore was completely broken - #72: JpaUnarchiver path traversal — added traversal rejection and realpath boundary check to prevent writes outside staging dir - #77: RestoreEngine staging path sanitized — $record->tag stripped of non-alphanumeric characters HIGH: - #75: (noted, AkeebaImporter unserialize needs separate refactor) - #76: BackupTable now deletes DB row before file — prevents data loss if DB delete fails - #78: API profiles endpoint now masks sensitive fields (passwords, keys, tokens) with '***' - #79: Webcron handler adds return after sendJsonResponse — prevents execution falling through on non-terminal close() - #80: BackupModel/ProfileModel loadFormData() now casts array to object — prevents TypeError on PHP 8.x form state restore PREFLIGHT HARDENING: - PreflightCheck::run() wrapped in try-catch for DB exceptions - mkdir() failure now includes actual error reason - Unresolved placeholders generate a warning instead of silent return Closes #71, closes #76, closes #77, closes #78, closes #79, closes #80 Ref #72, ref #81
291 lines
7.1 KiB
PHP
291 lines
7.1 KiB
PHP
<?php
|
|
|
|
/**
|
|
* @package MokoSuiteBackup
|
|
* @subpackage com_mokosuitebackup
|
|
* @author Moko Consulting <hello@mokoconsulting.tech>
|
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
* @license GNU General Public License version 3 or later; see LICENSE
|
|
*
|
|
* JPA (Joomla Pack Archive) unarchiver for importing Akeeba Backup files.
|
|
*
|
|
* JPA Format Structure:
|
|
* - Header: signature (3 bytes "JPA"), header length, major/minor version
|
|
* - Entity headers: signature (3 bytes), header length, path length, path,
|
|
* compression type (0=none, 1=gzip), compressed size, uncompressed size,
|
|
* permissions, then compressed data
|
|
*
|
|
* Read-only: extracts JPA archives to a staging directory.
|
|
* The RestoreEngine can then restore from the extracted files.
|
|
*/
|
|
|
|
namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
|
|
|
|
defined('_JEXEC') or die;
|
|
|
|
class JpaUnarchiver
|
|
{
|
|
private const JPA_SIGNATURE = "\x4a\x50\x41"; // "JPA"
|
|
private const ENTITY_SIGNATURE = "\x4a\x50\x46"; // "JPF" — file entity
|
|
|
|
private string $archivePath;
|
|
private string $outputDir;
|
|
private int $filesExtracted = 0;
|
|
|
|
public function __construct(string $archivePath, string $outputDir)
|
|
{
|
|
$this->archivePath = $archivePath;
|
|
$this->outputDir = rtrim($outputDir, '/\\');
|
|
}
|
|
|
|
/**
|
|
* Extract a JPA archive to the output directory.
|
|
*
|
|
* @return int Number of files extracted
|
|
*
|
|
* @throws \RuntimeException On format errors or extraction failure
|
|
*/
|
|
public function extract(): int
|
|
{
|
|
if (!is_file($this->archivePath) || !is_readable($this->archivePath)) {
|
|
throw new \RuntimeException('JPA file not readable: ' . $this->archivePath);
|
|
}
|
|
|
|
$handle = fopen($this->archivePath, 'rb');
|
|
|
|
if ($handle === false) {
|
|
throw new \RuntimeException('Cannot open JPA file: ' . $this->archivePath);
|
|
}
|
|
|
|
try {
|
|
// Read and validate archive header
|
|
$this->readArchiveHeader($handle);
|
|
|
|
// Read entities until EOF
|
|
while (!feof($handle)) {
|
|
$pos = ftell($handle);
|
|
|
|
// Try to read entity signature
|
|
$sig = fread($handle, 3);
|
|
|
|
if ($sig === false || strlen($sig) < 3) {
|
|
break; // End of archive
|
|
}
|
|
|
|
if ($sig === self::ENTITY_SIGNATURE) {
|
|
$this->readFileEntity($handle);
|
|
} else {
|
|
// Unknown entity — try to skip by reading header length
|
|
fseek($handle, $pos + 3);
|
|
$headerLenData = fread($handle, 2);
|
|
|
|
if ($headerLenData === false || strlen($headerLenData) < 2) {
|
|
break;
|
|
}
|
|
|
|
$headerLen = unpack('v', $headerLenData)[1];
|
|
// Skip remaining header + data
|
|
fseek($handle, $pos + 3 + $headerLen);
|
|
}
|
|
}
|
|
|
|
return $this->filesExtracted;
|
|
} finally {
|
|
fclose($handle);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Read and validate the JPA archive header.
|
|
*/
|
|
private function readArchiveHeader($handle): void
|
|
{
|
|
$signature = fread($handle, 3);
|
|
|
|
if ($signature !== self::JPA_SIGNATURE) {
|
|
throw new \RuntimeException('Not a valid JPA archive — invalid signature');
|
|
}
|
|
|
|
// Header length (2 bytes, little-endian)
|
|
$headerLenData = fread($handle, 2);
|
|
|
|
if ($headerLenData === false || strlen($headerLenData) < 2) {
|
|
throw new \RuntimeException('Truncated JPA header');
|
|
}
|
|
|
|
$headerLen = unpack('v', $headerLenData)[1];
|
|
|
|
// Version: major (1 byte), minor (1 byte)
|
|
$versionData = fread($handle, 2);
|
|
|
|
if ($versionData === false || strlen($versionData) < 2) {
|
|
throw new \RuntimeException('Cannot read JPA version');
|
|
}
|
|
|
|
// File count (4 bytes, little-endian)
|
|
$countData = fread($handle, 4);
|
|
|
|
if ($countData === false || strlen($countData) < 4) {
|
|
throw new \RuntimeException('Cannot read file count');
|
|
}
|
|
|
|
// Skip any remaining header bytes
|
|
$bytesRead = 3 + 2 + 2 + 4; // sig + headerLen + version + count
|
|
|
|
if ($headerLen > ($bytesRead - 3)) {
|
|
$remaining = $headerLen - ($bytesRead - 3);
|
|
|
|
if ($remaining > 0) {
|
|
fseek($handle, ftell($handle) + $remaining);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Read a single file entity and extract it.
|
|
*/
|
|
private function readFileEntity($handle): void
|
|
{
|
|
// Entity header length (2 bytes)
|
|
$headerLenData = fread($handle, 2);
|
|
|
|
if ($headerLenData === false || strlen($headerLenData) < 2) {
|
|
return;
|
|
}
|
|
|
|
$headerLen = unpack('v', $headerLenData)[1];
|
|
|
|
// Path length (2 bytes)
|
|
$pathLenData = fread($handle, 2);
|
|
|
|
if ($pathLenData === false || strlen($pathLenData) < 2) {
|
|
return;
|
|
}
|
|
|
|
$pathLen = unpack('v', $pathLenData)[1];
|
|
|
|
// Path (variable)
|
|
$path = fread($handle, $pathLen);
|
|
|
|
if ($path === false || strlen($path) < $pathLen) {
|
|
return;
|
|
}
|
|
|
|
// Compression type (1 byte): 0 = none, 1 = gzip
|
|
$compTypeData = fread($handle, 1);
|
|
$compType = ord($compTypeData);
|
|
|
|
// Compressed size (4 bytes)
|
|
$compSizeData = fread($handle, 4);
|
|
$compSize = unpack('V', $compSizeData)[1];
|
|
|
|
// Uncompressed size (4 bytes)
|
|
$uncompSizeData = fread($handle, 4);
|
|
$uncompSize = unpack('V', $uncompSizeData)[1];
|
|
|
|
// Permissions (4 bytes)
|
|
$permsData = fread($handle, 4);
|
|
$perms = unpack('V', $permsData)[1];
|
|
|
|
// Skip any remaining header bytes
|
|
$entityHeaderRead = 2 + 2 + $pathLen + 1 + 4 + 4 + 4;
|
|
$entityHeaderTotal = $headerLen;
|
|
|
|
if ($entityHeaderTotal > $entityHeaderRead) {
|
|
fseek($handle, ftell($handle) + ($entityHeaderTotal - $entityHeaderRead));
|
|
}
|
|
|
|
// Read compressed data
|
|
$data = '';
|
|
|
|
if ($compSize > 0) {
|
|
$data = fread($handle, $compSize);
|
|
|
|
if ($data === false || strlen($data) < $compSize) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Path traversal protection: reject absolute paths and directory traversal
|
|
if (str_starts_with($path, '/') || str_starts_with($path, '\\') || str_contains($path, '..')) {
|
|
return; // skip malicious entry
|
|
}
|
|
|
|
// Is this a directory?
|
|
if (substr($path, -1) === '/' || $uncompSize === 0 && $compSize === 0) {
|
|
$dirPath = $this->outputDir . '/' . $path;
|
|
|
|
if (!is_dir($dirPath)) {
|
|
mkdir($dirPath, 0755, true);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
// Decompress if needed
|
|
if ($compType === 1 && !empty($data)) {
|
|
$data = @gzinflate($data);
|
|
|
|
if ($data === false) {
|
|
throw new \RuntimeException('Failed to decompress file: ' . $path);
|
|
}
|
|
}
|
|
|
|
// Write file
|
|
$fullPath = $this->outputDir . '/' . $path;
|
|
|
|
// Verify resolved path stays within output directory
|
|
$realOutput = realpath($this->outputDir);
|
|
|
|
if ($realOutput !== false) {
|
|
$parentDir = dirname($fullPath);
|
|
|
|
if (!is_dir($parentDir)) {
|
|
mkdir($parentDir, 0755, true);
|
|
}
|
|
|
|
$realDest = realpath($parentDir);
|
|
|
|
if ($realDest === false || !str_starts_with($realDest, $realOutput)) {
|
|
return; // path escapes staging directory
|
|
}
|
|
}
|
|
|
|
$parentDir = dirname($fullPath);
|
|
|
|
if (!is_dir($parentDir)) {
|
|
mkdir($parentDir, 0755, true);
|
|
}
|
|
|
|
file_put_contents($fullPath, $data);
|
|
|
|
// Set permissions (only if reasonable)
|
|
if ($perms > 0 && $perms <= 0777) {
|
|
@chmod($fullPath, $perms);
|
|
}
|
|
|
|
$this->filesExtracted++;
|
|
}
|
|
|
|
/**
|
|
* Check if a file appears to be a JPA archive.
|
|
*/
|
|
public static function isJpaFile(string $path): bool
|
|
{
|
|
if (!is_file($path) || !is_readable($path)) {
|
|
return false;
|
|
}
|
|
|
|
$handle = fopen($path, 'rb');
|
|
|
|
if ($handle === false) {
|
|
return false;
|
|
}
|
|
|
|
$sig = fread($handle, 3);
|
|
fclose($handle);
|
|
|
|
return $sig === self::JPA_SIGNATURE;
|
|
}
|
|
}
|