diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0107c26b..29f36850 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -146,6 +146,21 @@ jobs: - name: Build Linux .deb (Debian/Ubuntu) run: ./scripts/linux/build_deb.sh + - name: Build Linux .rpm (Fedora/RHEL) + run: | + sudo apt-get install -y rpm + ./scripts/linux/build_rpm.sh + + - name: Install Flatpak toolchain + run: | + sudo apt-get install -y flatpak flatpak-builder + flatpak remote-add --if-not-exists --system flathub \ + https://flathub.org/repo/flathub.flatpakrepo + flatpak install -y --system flathub org.gnome.Platform//48 org.gnome.Sdk//48 + + - name: Build Linux Flatpak (installable) + run: ./scripts/linux/build_flatpak.sh + - uses: actions/upload-artifact@v4 with: name: bundle-linux @@ -153,6 +168,8 @@ jobs: Querya-Desktop-${{ steps.version.outputs.version }}-linux.zip Querya-Desktop-${{ steps.version.outputs.version }}-linux.AppImage Querya-Desktop-${{ steps.version.outputs.version }}-linux.deb + Querya-Desktop-${{ steps.version.outputs.version }}-linux.rpm + Querya-Desktop-${{ steps.version.outputs.version }}-linux.flatpak if-no-files-found: error build-macos: @@ -266,11 +283,13 @@ jobs: run: | set -euo pipefail mkdir -p dist - find artifacts \( -name '*.zip' -o -name '*.AppImage' -o -name '*.deb' -o -name '*-windows-setup.exe' \) \ + find artifacts \( -name '*.zip' -o -name '*.AppImage' -o -name '*.deb' -o -name '*.rpm' -o -name '*.flatpak' -o -name '*-windows-setup.exe' \) \ -exec mv -t dist/ {} + test "$(find dist -name '*.zip' | wc -l)" -eq 3 test "$(find dist -name '*.AppImage' | wc -l)" -eq 1 test "$(find dist -name '*.deb' | wc -l)" -eq 1 + test "$(find dist -name '*.rpm' | wc -l)" -eq 1 + test "$(find dist -name '*.flatpak' | wc -l)" -eq 1 test "$(find dist -name '*-windows-setup.exe' | wc -l)" -eq 1 cd dist sha256sum *.* | tee SHA256SUMS.txt @@ -296,9 +315,11 @@ jobs: echo "#### Installable" echo "- **Linux AppImage**: \`Querya-Desktop-${VERSION}-linux.AppImage\` (\`chmod +x\` then run)" echo "- **Linux .deb** (Debian/Ubuntu): \`Querya-Desktop-${VERSION}-linux.deb\` — \`sudo apt install ./Querya-Desktop-${VERSION}-linux.deb\`" + echo "- **Linux .rpm** (Fedora/RHEL): \`Querya-Desktop-${VERSION}-linux.rpm\` — \`sudo dnf install ./Querya-Desktop-${VERSION}-linux.rpm\`" + echo "- **Linux Flatpak**: \`Querya-Desktop-${VERSION}-linux.flatpak\` — \`flatpak install --user ./Querya-Desktop-${VERSION}-linux.flatpak\`" echo "- **Windows setup**: \`Querya-Desktop-${VERSION}-windows-setup.exe\` (Inno Setup)" echo "" - echo "RPM / Flatpak / AUR — follow-ups on #386." + echo "**Arch (AUR):** PKGBUILD in \`packaging/linux/aur/\` (community-maintained)." echo "" echo "Verify checksums: \`SHA256SUMS.txt\`" echo "" @@ -326,6 +347,8 @@ jobs: files: | dist/*.zip dist/*.deb + dist/*.rpm + dist/*.flatpak dist/*.AppImage dist/*-windows-setup.exe dist/SHA256SUMS.txt diff --git a/.gitignore b/.gitignore index 025ddbec..9668440b 100644 --- a/.gitignore +++ b/.gitignore @@ -45,5 +45,7 @@ docker/sqlite/data/ # Local packaging outputs Querya-Desktop-*.deb +Querya-Desktop-*.rpm +Querya-Desktop-*.flatpak Querya-Desktop-*.AppImage Querya-Desktop-*.zip diff --git a/CHANGELOG.md b/CHANGELOG.md index d210b917..b358b175 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.11-a] - 2026-07-27 + +Post-0.4.11 patch: security review hardening (#395–#402) and remaining Linux distro packages (#386). + +### Added + +- **Linux distro packaging (#386)** — `.rpm`, Flatpak (`.flatpak` bundle + manifest), and AUR PKGBUILD; Release CI publishes rpm + Flatpak alongside existing `.deb` / AppImage — see [packaging.md](docs/packaging.md). + +### Security + +- **Theme remote install localhost (#399)** — `ThemeRemoteInstallService` defaults `allowLocalhostInDebug` to `kDebugMode`. +- **Archive path guard (#401)** — zip extraction uses `p.isWithin()` bounds checks (`archive_path_guard.dart`). +- **Marketplace SHA256 (#396)** — `HttpMarketplaceRepository` requires manifest checksum before install. +- **Marketplace download URLs (#397)** — HTTPS allowlist / SSRF policy (`MarketplaceDownloadPolicy`). +- **Safe zip extraction (#398)** — shared zip-bomb limits via `SafeZipExtractor` (extensions, updater, themes). +- **Remote theme SHA256 (#400)** — remote theme install requires checksum when provided by metadata. +- **Sandbox OS consent (#395)** — fail-closed unsandboxed driver launch without OS wrapper (bubblewrap / consent dialog). +- **Sideload integrity UX (#402)** — local `.zip`/`.qext` install dialog with security notice and optional SHA256. + ## [0.4.11] - 2026-07-27 Universal UI standard for drivers/extensions, shell UX hardening, Fluid QueryaMotion morphing, virtual grid/pool reliability, performance follow-ups, and dual-channel packaging (portable + installable). diff --git a/docs/packaging.md b/docs/packaging.md index 3f041d99..0c0fb260 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -6,7 +6,7 @@ Querya Desktop ships (and will ship) two download channels. See epic | Channel | Typical artifact | Profile data | |---------|------------------|--------------| | **Portable** | `Querya-Desktop-{ver}-{os}.zip` (Flutter bundle) | OS app-support by default; optional sidecar — see below | -| **Installable** | AppImage, Windows setup, deb/rpm/Flatpak (planned) | Normal OS locations | +| **Installable** | AppImage, Windows setup, `.deb`, `.rpm`, Flatpak | Normal OS locations | ## Portable profile (`QueryaData`) @@ -54,3 +54,38 @@ Legacy `com.example.*` support directories are migrated once into the new paths - `lib/core/storage/app_data_root.dart` — portable detection, support-dir redirect, id migration - Updater packaging context: `lib/core/updater/installers/update_install_context.dart` - Release workflow: `.github/workflows/release.yml` + +## Linux distro packages + +All installable Linux formats reuse the same Flutter `build/linux/x64/release/bundle` +payload as the portable zip and AppImage. + +| Format | Build script | Install | +|--------|--------------|---------| +| `.deb` | [`scripts/linux/build_deb.sh`](../scripts/linux/build_deb.sh) | `sudo apt install ./Querya-Desktop-{ver}-linux.deb` | +| `.rpm` | [`scripts/linux/build_rpm.sh`](../scripts/linux/build_rpm.sh) | `sudo dnf install ./Querya-Desktop-{ver}-linux.rpm` | +| Flatpak | [`scripts/linux/build_flatpak.sh`](../scripts/linux/build_flatpak.sh) | `flatpak install --user ./Querya-Desktop-{ver}-linux.flatpak` | +| AUR | [`packaging/linux/aur/`](../packaging/linux/aur/) | Community PKGBUILD (Release zip under `/opt`) | + +**Runtime dependencies (deb/rpm):** GTK 3, libsecret, GLib; app indicator recommended for tray. + +### Updater policy + +| Install type | In-app updater | +|--------------|----------------| +| Portable zip / AppImage | Downloads matching release asset (zip or AppImage) | +| `.deb` / `.rpm` | Use **apt** / **dnf** (or distro equivalent); in-app install is not offered | +| Flatpak | Use **`flatpak update`** (`FLATPAK_ID` / managed runtime — see `update_install_context.dart`) | +| Snap | Use **`snap refresh`** when/if a Snap build ships | + +### Flatpak sandbox notes + +The bundled Flatpak grants network, home, and D-Bus access to the freedesktop +secrets service (libsecret). Some database setups (Unix sockets outside `$HOME`, +custom TLS stores) may need extra permissions, e.g.: + +```bash +flatpak override --user com.queryahub.querya_desktop --filesystem=/var/run/postgresql:ro +``` + +Flathub submission can reuse [`packaging/linux/flatpak/com.queryahub.querya_desktop.yml`](../packaging/linux/flatpak/com.queryahub.querya_desktop.yml). diff --git a/docs/release-checklist.md b/docs/release-checklist.md index a5491cef..4e38e2b9 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -70,7 +70,7 @@ Verify the 0.4.4 motion tokens, smooth animations, and high refresh rate support - [x] After merge to `main`, confirm any **Auto Version Bump** still yields a **0.4.11+…** product version (do not ship as 0.4.12). - [x] **Tag** `0.4.11` is placed on the **commit that includes all fixes** you want in binaries (a tag does not auto-include later commits; see [CONTRIBUTING.md](../CONTRIBUTING.md)). - [x] Run the **Release** workflow from GitHub Actions (see [tags-and-releases.md](tags-and-releases.md)). -- [ ] Verify **portable** zips (`*-linux.zip`, `*-windows.zip`, `*-macos.zip`), **installable** artifacts (`*.AppImage`, `*.deb`, `*-windows-setup.exe`), and `SHA256SUMS.txt` on the GitHub Release. +- [ ] Verify **portable** zips (`*-linux.zip`, `*-windows.zip`, `*-macos.zip`), **installable** artifacts (`*.AppImage`, `*.deb`, `*.rpm`, `*.flatpak`, `*-windows-setup.exe`), and `SHA256SUMS.txt` on the GitHub Release. ## Docs diff --git a/docs/roadmap.md b/docs/roadmap.md index 84938bb0..e1d4c610 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -3,6 +3,7 @@ Living document for planned work. Not a commitment order; adjust as priorities change. **GitHub Latest Release:** [0.4.11](https://github.com/QueryaHub/Querya-Desktop/releases/tag/0.4.11) (2026-07-27). +**Next patch:** **0.4.11-a** — security review (#395–#402) + Linux rpm/Flatpak/AUR (#386); tag pending. **Next product release:** **0.5.0** — live Marketplace download and install — see below. ## Theme system @@ -24,7 +25,8 @@ Living document for planned work. Not a commitment order; adjust as priorities c - **Shipped in 0.4.8:** Extension Manager UI (+ mock Marketplace) — [planned-0.4.8.md](planned-0.4.8.md). - **Shipped in 0.4.9:** PostgreSQL SSL & connection reliability — see [CHANGELOG.md](../CHANGELOG.md). - **Shipped in 0.4.10:** Sandboxed extension runtime (Block E), Plugin RPC bridge (Block C), SDUI form/tree builders, local `.zip`/`.qext` install, Registration/Activation for external database drivers (e.g. ClickHouse), in-app updater — see [CHANGELOG.md](../CHANGELOG.md). -- **Shipped in 0.4.11 (Latest):** Universal UI / SDUI RPC expand, ExtensionTableView, universal export, MySQL/SQLite parity, shell UX (#339), Fluid QueryaMotion (#342) + perf follow-ups (#356), grid/pool/timeout fixes, dual-channel packaging (portable zip + AppImage / `.deb` / Windows setup) — [CHANGELOG.md](../CHANGELOG.md) `[0.4.11]`, [packaging.md](packaging.md), epic [#379](https://github.com/QueryaHub/Querya-Desktop/issues/379). +- **Shipped in 0.4.11:** Universal UI / SDUI RPC expand, ExtensionTableView, universal export, MySQL/SQLite parity, shell UX (#339), Fluid QueryaMotion (#342) + perf follow-ups (#356), grid/pool/timeout fixes, dual-channel packaging (portable zip + AppImage / `.deb` / Windows setup) — [CHANGELOG.md](../CHANGELOG.md) `[0.4.11]`, [packaging.md](packaging.md), epic [#379](https://github.com/QueryaHub/Querya-Desktop/issues/379). +- **Pending 0.4.11-a:** security hardening (#395–#402), Linux `.rpm` / Flatpak / AUR (#386) — [CHANGELOG.md](../CHANGELOG.md) `[0.4.11-a]`. - **Planned 0.5.0:** Marketplace Launch — live download, `sha256` validation, install themes (and later DB drivers) from the network. ## Query history and favorites diff --git a/docs/security.md b/docs/security.md index 9b9d44a7..8480bf26 100644 --- a/docs/security.md +++ b/docs/security.md @@ -21,3 +21,36 @@ On upgrade from older databases, existing plaintext secrets in SQLite are **migr ## Tests Automated tests use an **in-memory** secrets backend (see `test/flutter_test_config.dart`) so CI does not require a desktop keyring. + + +## Archive install limits (extensions and updates) + +Marketplace downloads, local extension sideload (`.zip` / `.qext`), and in-app updater extraction use `SafeZipExtractor` (`lib/core/security/safe_zip_extractor.dart`) with shared default limits: + +| Limit | Default | +|-------|---------| +| Max compressed archive size | 100 MiB | +| Max total uncompressed size | 500 MiB | +| Max entries | 10 000 | +| Max single entry uncompressed size | 100 MiB | +| Max compression ratio (uncompressed ÷ compressed) | 100:1 | + +Archives exceeding these bounds fail closed before files are written to disk. Path traversal checks remain in `archive_path_guard.dart`. + +## Extension driver OS sandbox + +Process-sandbox database drivers launch inside OS-level isolation when available: + +| Platform | Wrapper | When unavailable | +|----------|---------|------------------| +| Linux | `bwrap` (bubblewrap) | User must confirm **Run without OS sandbox** | +| macOS | `sandbox-exec` (Seatbelt) | N/A — always wrapped | +| Windows | AppContainer (planned) | User must confirm until native helper ships | + +Querya refuses **silent** unsandboxed launch. `SandboxProcessRunner` throws `SandboxOsIsolationUnavailableException` until the user approves via the consent dialog registered from the main window. + +**Linux:** install `bubblewrap` and ensure unprivileged user namespaces are enabled if you want OS sandbox without manual confirmation. + +## Local extension sideload (`.zip` / `.qext`) + +Installing from a local file does **not** verify integrity unless you paste an optional **SHA-256 checksum** in the install dialog. Marketplace installs always require a manifest checksum (#396). Sideload is intended for trusted local packages and development builds. diff --git a/docs/tags-and-releases.md b/docs/tags-and-releases.md index 1907b485..2d9b0621 100644 --- a/docs/tags-and-releases.md +++ b/docs/tags-and-releases.md @@ -43,13 +43,17 @@ |----------|--------| | `Querya-Desktop-X.Y.Z-linux.AppImage` | [`scripts/linux/build_appimage.sh`](../scripts/linux/build_appimage.sh) (`chmod +x` then run) | | `Querya-Desktop-X.Y.Z-linux.deb` | Debian/Ubuntu — [`scripts/linux/build_deb.sh`](../scripts/linux/build_deb.sh) | +| `Querya-Desktop-X.Y.Z-linux.rpm` | Fedora/RHEL — [`scripts/linux/build_rpm.sh`](../scripts/linux/build_rpm.sh) | +| `Querya-Desktop-X.Y.Z-linux.flatpak` | Distro-agnostic — [`scripts/linux/build_flatpak.sh`](../scripts/linux/build_flatpak.sh) | | `Querya-Desktop-X.Y.Z-windows-setup.exe` | Inno Setup (`packaging/windows/querya.iss`) | ```bash sudo apt install ./Querya-Desktop-X.Y.Z-linux.deb +sudo dnf install ./Querya-Desktop-X.Y.Z-linux.rpm +flatpak install --user ./Querya-Desktop-X.Y.Z-linux.flatpak ``` -RPM / Flatpak / AUR: [#386](https://github.com/QueryaHub/Querya-Desktop/issues/386) / epic [#379](https://github.com/QueryaHub/Querya-Desktop/issues/379). +**Arch (AUR):** community PKGBUILD — [`packaging/linux/aur/`](../packaging/linux/aur/) (installs the Release portable zip under `/opt`). ## Changelog в GitHub Release diff --git a/lib/core/extensions/extension_driver_session.dart b/lib/core/extensions/extension_driver_session.dart index 31f46f13..ee8a3d7a 100644 --- a/lib/core/extensions/extension_driver_session.dart +++ b/lib/core/extensions/extension_driver_session.dart @@ -11,6 +11,8 @@ import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; import 'package:querya_desktop/core/extensions/models/extension_object_metadata.dart'; import 'package:querya_desktop/core/extensions/models/extension_server_stats.dart'; import 'package:querya_desktop/core/extensions/rpc/plugin_rpc_bridge.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_os_isolation.dart'; +import 'package:querya_desktop/core/extensions/sandbox/unsandboxed_launch_consent_gate.dart'; import 'package:querya_desktop/core/sdui/sdui_tree_schema.dart'; import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; @@ -124,18 +126,52 @@ class ExtensionDriverSession { } final bridge = bridgeFactory?.call() ?? PluginRpcBridge(); - await bridge.start( + await _startBridgeWithConsent( + bridge: bridge, manifest: manifest, pluginExecutable: executable, extensionRoot: root, - handshakeParams: { - 'queryaVersion': '2.0.0', - 'pluginId': manifest.id, - }, ); return bridge; } + Future _startBridgeWithConsent({ + required PluginRpcBridge bridge, + required ExtensionManifest manifest, + required String pluginExecutable, + required String extensionRoot, + }) async { + const handshakeParams = { + 'queryaVersion': '2.0.0', + }; + + try { + await bridge.start( + manifest: manifest, + pluginExecutable: pluginExecutable, + extensionRoot: extensionRoot, + handshakeParams: { + ...handshakeParams, + 'pluginId': manifest.id, + }, + ); + } on SandboxOsIsolationUnavailableException catch (error) { + final approved = + await UnsandboxedLaunchConsentGate.instance.request(error); + if (!approved) rethrow; + await bridge.start( + manifest: manifest, + pluginExecutable: pluginExecutable, + extensionRoot: extensionRoot, + allowUnsandboxedLaunch: true, + handshakeParams: { + ...handshakeParams, + 'pluginId': manifest.id, + }, + ); + } + } + Future _injectAndConnect( PluginRpcBridge bridge, { required int connectionId, diff --git a/lib/core/extensions/local_extension_installer.dart b/lib/core/extensions/local_extension_installer.dart index 557ede90..3296763f 100644 --- a/lib/core/extensions/local_extension_installer.dart +++ b/lib/core/extensions/local_extension_installer.dart @@ -10,6 +10,8 @@ import 'package:querya_desktop/core/extensions/local_extension_registry.dart'; import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; import 'package:querya_desktop/core/extensions/sandbox/sandbox_policy.dart'; import 'package:querya_desktop/core/market/marketplace_repository.dart'; +import 'package:querya_desktop/core/security/archive_path_guard.dart'; +import 'package:querya_desktop/core/security/safe_zip_extractor.dart'; /// Installs an extension package from a local `.zip` / `.qext` archive (issue #316). /// @@ -42,7 +44,12 @@ class LocalExtensionInstaller { } onProgress?.call(0.1); - final bytes = await archiveFile.readAsBytes(); + late final List bytes; + try { + bytes = await SafeZipExtractor.readBoundedBytes(archiveFile); + } on SafeZipException catch (error) { + throw MarketplaceException(error.message); + } if (expectedSha256 != null && expectedSha256.trim().isNotEmpty) { final actual = sha256.convert(bytes).toString().toLowerCase(); @@ -56,7 +63,12 @@ class LocalExtensionInstaller { } onProgress?.call(0.25); - final archive = ZipDecoder().decodeBytes(bytes); + late final Archive archive; + try { + archive = SafeZipExtractor.decodeBytes(bytes); + } on SafeZipException catch (error) { + throw MarketplaceException(error.message); + } if (archive.isEmpty) { throw MarketplaceException('Extension archive is empty.'); } @@ -216,9 +228,7 @@ class LocalExtensionInstaller { } if (filename.isEmpty || filename == '/') continue; - if (filename.contains('..') || - filename.startsWith('/') || - filename.startsWith('\\')) { + if (!isArchiveEntryNameSafe(filename)) { throw MarketplaceException( 'Security violation: Path traversal detected in archive entry ' '"${file.name}"', @@ -226,7 +236,7 @@ class LocalExtensionInstaller { } final targetPath = p.normalize(p.join(destPath, filename)); - if (!targetPath.startsWith(destPath)) { + if (!isArchiveExtractPathWithinRoot(destPath, targetPath)) { throw MarketplaceException( 'Security violation: Extraction path out of bounds "${file.name}"', ); diff --git a/lib/core/extensions/rpc/plugin_rpc_bridge.dart b/lib/core/extensions/rpc/plugin_rpc_bridge.dart index 89153785..9e528bb3 100644 --- a/lib/core/extensions/rpc/plugin_rpc_bridge.dart +++ b/lib/core/extensions/rpc/plugin_rpc_bridge.dart @@ -62,6 +62,7 @@ class PluginRpcBridge { String? extensionRoot, Map? environment, Map? handshakeParams, + bool allowUnsandboxedLaunch = false, }) async { if (_started) { throw StateError('PluginRpcBridge already started'); @@ -77,6 +78,7 @@ class PluginRpcBridge { extensionRoot: extensionRoot ?? manifest.installPath, capabilities: capabilities, environment: environment, + allowUnsandboxedLaunch: allowUnsandboxedLaunch, ); _handle = handle; diff --git a/lib/core/extensions/sandbox/sandbox_os_isolation.dart b/lib/core/extensions/sandbox/sandbox_os_isolation.dart new file mode 100644 index 00000000..53d381ee --- /dev/null +++ b/lib/core/extensions/sandbox/sandbox_os_isolation.dart @@ -0,0 +1,58 @@ +import 'sandbox_launch_command.dart'; + +/// Thrown when a process-sandbox driver would launch without OS-level isolation. +class SandboxOsIsolationUnavailableException implements Exception { + const SandboxOsIsolationUnavailableException({ + required this.platform, + required this.message, + this.installHint, + }); + + final String platform; + final String message; + final String? installHint; + + @override + String toString() => 'SandboxOsIsolationUnavailableException: $message'; +} + +/// Describes why OS sandboxing is unavailable for a launch command. +abstract final class SandboxOsIsolation { + static SandboxOsIsolationUnavailableException? exceptionForLaunchCommand( + SandboxLaunchCommand command, + ) { + if (command.usesOsSandbox) return null; + + switch (command.platform) { + case 'linux': + return const SandboxOsIsolationUnavailableException( + platform: 'linux', + message: + 'OS sandbox (bubblewrap) is not available on this Linux system.', + installHint: + 'Install bubblewrap (bwrap) from your distribution and ensure ' + 'unprivileged user namespaces are enabled, or confirm below to ' + 'run the driver without OS sandbox.', + ); + case 'windows': + return const SandboxOsIsolationUnavailableException( + platform: 'windows', + message: + 'Native OS sandbox is not yet available for extension drivers ' + 'on Windows.', + installHint: + 'Drivers run with soft isolation only until AppContainer support ' + 'lands. Confirm below only if you trust this extension.', + ); + default: + return SandboxOsIsolationUnavailableException( + platform: command.platform, + message: + 'OS sandbox is not available for extension drivers on ' + '${command.platform}.', + installHint: + 'Confirm below only if you trust this extension package.', + ); + } + } +} diff --git a/lib/core/extensions/sandbox/sandbox_process_runner.dart b/lib/core/extensions/sandbox/sandbox_process_runner.dart index 09d86146..e793eac1 100644 --- a/lib/core/extensions/sandbox/sandbox_process_runner.dart +++ b/lib/core/extensions/sandbox/sandbox_process_runner.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:querya_desktop/core/extensions/models/sandbox_capabilities.dart'; import 'package:querya_desktop/core/extensions/sandbox/sandbox_launch_command.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_os_isolation.dart'; import 'package:querya_desktop/core/extensions/sandbox/sandbox_scratch_directory.dart'; import 'package:querya_desktop/core/extensions/sandbox/sandbox_secret_guard.dart'; @@ -100,6 +101,9 @@ class SandboxProcessRunner { /// Spawns [pluginExecutable] inside the OS sandbox for [pluginId]. /// + /// When OS sandboxing is unavailable, launch fails unless + /// [allowUnsandboxedLaunch] is true (requires explicit user consent in UI). + /// /// Credentials must never be passed via [pluginArguments] or [environment]; /// use [SandboxCredentialsInjector] over Stdio JSON-RPC instead. Future start({ @@ -109,6 +113,7 @@ class SandboxProcessRunner { String? extensionRoot, SandboxCapabilities? capabilities, Map? environment, + bool allowUnsandboxedLaunch = false, }) async { SandboxSecretGuard.assertNoSecrets( arguments: pluginArguments, @@ -125,7 +130,8 @@ class SandboxProcessRunner { if (bwrapAvailable == null && !usesBwrap) { debugPrint( 'SandboxProcessRunner: bubblewrap unavailable or cannot set up user ' - 'namespaces on this system; launching $pluginId without OS sandbox.', + 'namespaces on this system; $pluginId requires consent to launch ' + 'without OS sandbox.', ); } final command = SandboxLaunchCommand.build( @@ -138,6 +144,20 @@ class SandboxProcessRunner { bwrapAvailable: usesBwrap, ); + final isolationIssue = + SandboxOsIsolation.exceptionForLaunchCommand(command); + if (isolationIssue != null && !allowUnsandboxedLaunch) { + await scratch.delete(); + throw isolationIssue; + } + + if (isolationIssue != null) { + debugPrint( + 'SandboxProcessRunner: launching $pluginId without OS sandbox after ' + 'explicit consent (${command.platform}).', + ); + } + // Never forward parent secrets via environment. Only pass an explicit map // (credentials go through Stdio JSON-RPC — Block E §5). final sanitizedEnv = { diff --git a/lib/core/extensions/sandbox/unsandboxed_launch_consent_gate.dart b/lib/core/extensions/sandbox/unsandboxed_launch_consent_gate.dart new file mode 100644 index 00000000..4bd2df05 --- /dev/null +++ b/lib/core/extensions/sandbox/unsandboxed_launch_consent_gate.dart @@ -0,0 +1,21 @@ +import 'sandbox_os_isolation.dart'; + +typedef UnsandboxedLaunchConsentHandler = Future Function( + SandboxOsIsolationUnavailableException details, +); + +/// App-level hook for explicit user consent before unsandboxed driver launch. +class UnsandboxedLaunchConsentGate { + UnsandboxedLaunchConsentGate._(); + + static final UnsandboxedLaunchConsentGate instance = + UnsandboxedLaunchConsentGate._(); + + UnsandboxedLaunchConsentHandler? handler; + + Future request(SandboxOsIsolationUnavailableException details) async { + final callback = handler; + if (callback == null) return false; + return callback(details); + } +} diff --git a/lib/core/market/http_marketplace_repository.dart b/lib/core/market/http_marketplace_repository.dart index 1d1a5caf..11578fb7 100644 --- a/lib/core/market/http_marketplace_repository.dart +++ b/lib/core/market/http_marketplace_repository.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:archive/archive.dart'; import 'package:crypto/crypto.dart'; +import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:path/path.dart' as p; import 'package:querya_desktop/core/extensions/extension_support.dart'; @@ -11,6 +12,9 @@ import 'package:querya_desktop/core/extensions/sandbox/sandbox_policy.dart'; import 'package:querya_desktop/core/extensions/local_extension_registry.dart'; import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; import 'package:querya_desktop/core/extensions/models/extension_type.dart'; +import 'package:querya_desktop/core/security/archive_path_guard.dart'; +import 'package:querya_desktop/core/security/safe_zip_extractor.dart'; +import 'marketplace_download_policy.dart'; import 'marketplace_repository.dart'; /// HTTP implementation of [MarketplaceRepository] connecting to MarketApi backend. @@ -21,13 +25,46 @@ class HttpMarketplaceRepository implements MarketplaceRepository { HttpMarketplaceRepository({ this.baseUrl = 'http://localhost:8000/api/v1', http.Client? client, - }) : _client = client ?? http.Client(); + Iterable extraTrustedDownloadHosts = const [], + bool allowLocalhostInDebug = kDebugMode, + }) : _client = client ?? http.Client(), + _allowLocalhostInDebug = allowLocalhostInDebug, + _trustedDownloadHosts = MarketplaceDownloadPolicy.trustedHostsFor( + apiBaseUrl: baseUrl, + extraTrustedHosts: extraTrustedDownloadHosts, + ) { + _validateApiBaseUrl(); + } final String baseUrl; final http.Client _client; + final bool _allowLocalhostInDebug; + final Set _trustedDownloadHosts; + + void _validateApiBaseUrl() { + if (!MarketplaceDownloadPolicy.isAllowedApiBaseUrl( + baseUrl, + allowLocalhostInDebug: _allowLocalhostInDebug, + )) { + throw MarketplaceException( + 'Marketplace API base URL is not allowed: $baseUrl', + ); + } + } + + void _validateDownloadUrl(Uri uri) { + if (!MarketplaceDownloadPolicy.isAllowedDownloadUrl( + uri, + trustedHosts: _trustedDownloadHosts, + allowLocalhostInDebug: _allowLocalhostInDebug, + )) { + throw MarketplaceException('Download URL is not allowed: $uri'); + } + } @override Future> getTrending({ExtensionType? type}) async { + _validateApiBaseUrl(); final uri = Uri.parse('$baseUrl/extensions/trending').replace( queryParameters: type != null ? {'type': type.value} : null, ); @@ -41,6 +78,7 @@ class HttpMarketplaceRepository implements MarketplaceRepository { @override Future> search(String query, {ExtensionType? type}) async { + _validateApiBaseUrl(); final uri = Uri.parse('$baseUrl/extensions/search').replace( queryParameters: { 'q': query.trim(), @@ -61,6 +99,7 @@ class HttpMarketplaceRepository implements MarketplaceRepository { if (uri == null) { throw MarketplaceException('Invalid download URL: $url'); } + _validateDownloadUrl(uri); final request = http.Request('GET', uri); final response = await _client.send(request).timeout(const Duration(seconds: 30)); @@ -124,22 +163,37 @@ class HttpMarketplaceRepository implements MarketplaceRepository { try { // Step 2: SHA-256 Integrity Verification (Critical Security Check) - if (manifest.sha256Checksum != null && manifest.sha256Checksum!.trim().isNotEmpty) { - final bytes = await archiveFile.readAsBytes(); - final actualSha256 = sha256.convert(bytes).toString().toLowerCase(); - final expectedSha256 = manifest.sha256Checksum!.trim().toLowerCase(); - if (actualSha256 != expectedSha256) { - throw MarketplaceException( - 'SHA256 checksum mismatch for "${manifest.id}". Expected: $expectedSha256, Actual: $actualSha256. Installation aborted.', - ); - } + final expectedSha256 = manifest.sha256Checksum?.trim().toLowerCase(); + if (expectedSha256 == null || expectedSha256.isEmpty) { + throw MarketplaceException( + 'Extension manifest is missing SHA256 checksum for "${manifest.id}". ' + 'Installation aborted.', + ); + } + + late final List bytes; + try { + bytes = await SafeZipExtractor.readBoundedBytes(archiveFile); + } on SafeZipException catch (error) { + throw MarketplaceException(error.message); + } + final actualSha256 = sha256.convert(bytes).toString().toLowerCase(); + if (actualSha256 != expectedSha256) { + throw MarketplaceException( + 'SHA256 checksum mismatch for "${manifest.id}". ' + 'Expected: $expectedSha256, Actual: $actualSha256. Installation aborted.', + ); } onProgress?.call(0.85); - // Step 3: Safe Archive Extraction (Preventing Path Traversal / Zip Bomb - Issue #242) - final bytes = await archiveFile.readAsBytes(); - final archive = ZipDecoder().decodeBytes(bytes); + // Step 3: Safe Archive Extraction (path traversal + zip bomb limits) + final Archive archive; + try { + archive = SafeZipExtractor.decodeBytes(bytes); + } on SafeZipException catch (error) { + throw MarketplaceException(error.message); + } final dir = await ExtensionPaths.extensionsDirectory(); final extDir = Directory(p.join(dir.path, manifest.id)); @@ -152,12 +206,12 @@ class HttpMarketplaceRepository implements MarketplaceRepository { for (final file in archive) { final filename = file.name; // Check for Path Traversal attempts - if (filename.contains('..') || filename.startsWith('/') || filename.startsWith('\\')) { + if (!isArchiveEntryNameSafe(filename)) { throw MarketplaceException('Security violation: Path traversal detected in archive entry "$filename"'); } final targetPath = p.normalize(p.join(extDirPath, filename)); - if (!targetPath.startsWith(extDirPath)) { + if (!isArchiveExtractPathWithinRoot(extDirPath, targetPath)) { throw MarketplaceException('Security violation: Extraction path out of bounds "$filename"'); } diff --git a/lib/core/market/marketplace_download_policy.dart b/lib/core/market/marketplace_download_policy.dart new file mode 100644 index 00000000..be5edb04 --- /dev/null +++ b/lib/core/market/marketplace_download_policy.dart @@ -0,0 +1,86 @@ +import 'package:flutter/foundation.dart'; + +import '../theme/theme_remote_install_policy.dart'; + +/// HTTPS and host allowlist rules for marketplace API and artifact downloads. +abstract final class MarketplaceDownloadPolicy { + /// Hosts permitted for extension archive downloads (API host + extras). + static Set trustedHostsFor({ + required String apiBaseUrl, + Iterable extraTrustedHosts = const [], + }) { + final hosts = {}; + final apiHost = Uri.tryParse(apiBaseUrl.trim())?.host.toLowerCase(); + if (apiHost != null && apiHost.isNotEmpty) { + hosts.add(apiHost); + } + for (final host in extraTrustedHosts) { + final normalized = host.trim().toLowerCase(); + if (normalized.isNotEmpty) { + hosts.add(normalized); + } + } + return hosts; + } + + /// Whether [baseUrl] may be used for MarketApi REST calls. + static bool isAllowedApiBaseUrl( + String baseUrl, { + bool allowLocalhostInDebug = kDebugMode, + }) { + final uri = Uri.tryParse(baseUrl.trim()); + if (uri == null || !uri.hasAuthority || uri.host.isEmpty) { + return false; + } + + if (uri.scheme == 'https') { + return ThemeRemoteInstallPolicy.isAllowedUrl( + uri, + allowLocalhostInDebug: allowLocalhostInDebug, + ); + } + + if (allowLocalhostInDebug && uri.scheme == 'http') { + return _isDebugLocalHttpHost(uri.host); + } + + return false; + } + + /// Whether [uri] may be used to download an extension archive. + static bool isAllowedDownloadUrl( + Uri uri, { + required Set trustedHosts, + bool allowLocalhostInDebug = kDebugMode, + }) { + if (!uri.hasAuthority || uri.host.isEmpty) { + return false; + } + + final host = uri.host.toLowerCase(); + if (!trustedHosts.contains(host)) { + return false; + } + + if (uri.scheme == 'https') { + return ThemeRemoteInstallPolicy.isAllowedUrl( + uri, + allowLocalhostInDebug: allowLocalhostInDebug, + ); + } + + if (allowLocalhostInDebug && uri.scheme == 'http') { + return _isDebugLocalHttpHost(host); + } + + return false; + } + + static bool _isDebugLocalHttpHost(String host) { + final probe = Uri.parse('https://$host/'); + return ThemeRemoteInstallPolicy.isAllowedUrl( + probe, + allowLocalhostInDebug: true, + ); + } +} diff --git a/lib/core/security/archive_path_guard.dart b/lib/core/security/archive_path_guard.dart new file mode 100644 index 00000000..97006c62 --- /dev/null +++ b/lib/core/security/archive_path_guard.dart @@ -0,0 +1,18 @@ +import 'package:path/path.dart' as p; + +/// Returns true when [targetPath] equals [rootPath] or lies inside it. +/// +/// Prefer over [String.startsWith] so sibling prefixes (e.g. `/tmp/abc` vs +/// `/tmp/abcd`) cannot bypass extraction bounds. +bool isArchiveExtractPathWithinRoot(String rootPath, String targetPath) { + final root = p.normalize(rootPath); + final target = p.normalize(targetPath); + return p.equals(root, target) || p.isWithin(root, target); +} + +/// Rejects archive entry names that attempt absolute paths or traversal. +bool isArchiveEntryNameSafe(String entryName) { + if (entryName.contains('..')) return false; + if (entryName.startsWith('/') || entryName.startsWith('\\')) return false; + return true; +} diff --git a/lib/core/security/safe_zip_extractor.dart b/lib/core/security/safe_zip_extractor.dart new file mode 100644 index 00000000..0ff52fad --- /dev/null +++ b/lib/core/security/safe_zip_extractor.dart @@ -0,0 +1,134 @@ +import 'dart:io'; + +import 'package:archive/archive.dart'; + +/// Bounds for zip decode/extract to mitigate zip bombs and memory exhaustion. +class ZipDecodeLimits { + const ZipDecodeLimits({ + required this.maxCompressedBytes, + required this.maxTotalUncompressedBytes, + required this.maxEntryCount, + required this.maxEntryUncompressedBytes, + required this.maxCompressionRatio, + }); + + final int maxCompressedBytes; + final int maxTotalUncompressedBytes; + final int maxEntryCount; + final int maxEntryUncompressedBytes; + final double maxCompressionRatio; + + /// Default limits for marketplace, sideload, and updater archives. + static const ZipDecodeLimits standard = ZipDecodeLimits( + maxCompressedBytes: 100 * 1024 * 1024, + maxTotalUncompressedBytes: 500 * 1024 * 1024, + maxEntryCount: 10000, + maxEntryUncompressedBytes: 100 * 1024 * 1024, + maxCompressionRatio: 100, + ); +} + +/// Thrown when an archive exceeds [ZipDecodeLimits]. +class SafeZipException implements Exception { + SafeZipException(this.message); + + final String message; + + @override + String toString() => 'SafeZipException: $message'; +} + +/// Bounded zip decode used by marketplace, sideload, and updater paths. +abstract final class SafeZipExtractor { + static Future> readBoundedBytes( + File file, { + ZipDecodeLimits limits = ZipDecodeLimits.standard, + }) async { + final length = await file.length(); + if (length > limits.maxCompressedBytes) { + throw SafeZipException( + 'Archive exceeds maximum compressed size ' + '(${limits.maxCompressedBytes} bytes).', + ); + } + return file.readAsBytes(); + } + + static Archive decodeBytes( + List bytes, { + ZipDecodeLimits limits = ZipDecodeLimits.standard, + }) { + if (bytes.length > limits.maxCompressedBytes) { + throw SafeZipException( + 'Archive exceeds maximum compressed size ' + '(${limits.maxCompressedBytes} bytes).', + ); + } + + final Archive archive; + try { + archive = ZipDecoder().decodeBytes(bytes); + } on Object catch (error) { + throw SafeZipException('Failed to decode zip archive: $error'); + } + + _validateArchive( + archive, + compressedBytes: bytes.length, + limits: limits, + ); + return archive; + } + + static Future readAndDecodeFile( + File file, { + ZipDecodeLimits limits = ZipDecodeLimits.standard, + }) async { + final bytes = await readBoundedBytes(file, limits: limits); + return decodeBytes(bytes, limits: limits); + } + + static void _validateArchive( + Archive archive, { + required int compressedBytes, + required ZipDecodeLimits limits, + }) { + if (archive.length > limits.maxEntryCount) { + throw SafeZipException( + 'Archive contains too many entries (${archive.length}; ' + 'max ${limits.maxEntryCount}).', + ); + } + + var totalUncompressed = 0; + for (final entry in archive) { + if (!entry.isFile) continue; + + final size = entry.size; + if (size > limits.maxEntryUncompressedBytes) { + throw SafeZipException( + 'Archive entry "${entry.name}" exceeds maximum uncompressed size ' + '($size bytes; max ${limits.maxEntryUncompressedBytes}).', + ); + } + + totalUncompressed += size; + if (totalUncompressed > limits.maxTotalUncompressedBytes) { + throw SafeZipException( + 'Archive exceeds maximum total uncompressed size ' + '(max ${limits.maxTotalUncompressedBytes} bytes).', + ); + } + } + + if (compressedBytes > 0 && totalUncompressed > 0) { + final ratio = totalUncompressed / compressedBytes; + if (ratio > limits.maxCompressionRatio) { + throw SafeZipException( + 'Archive compression ratio is too high ' + '(${ratio.toStringAsFixed(1)}:1; max ${limits.maxCompressionRatio}:1).', + ); + } + } + } +} diff --git a/lib/core/theme/theme_remote_install_service.dart b/lib/core/theme/theme_remote_install_service.dart index 061c27c3..7b50bde6 100644 --- a/lib/core/theme/theme_remote_install_service.dart +++ b/lib/core/theme/theme_remote_install_service.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:crypto/crypto.dart'; +import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:path/path.dart' as p; @@ -27,7 +28,7 @@ class ThemeRemoteInstallService { this._registry, { Future Function(Uri uri)? httpGet, Duration timeout = const Duration(seconds: 30), - bool allowLocalhostInDebug = true, + bool allowLocalhostInDebug = kDebugMode, }) : _httpGet = httpGet ?? _defaultHttpGet, _timeout = timeout, _allowLocalhostInDebug = allowLocalhostInDebug; @@ -74,6 +75,12 @@ class ThemeRemoteInstallService { final expectedChecksum = _normalizeSha256( sha256Checksum ?? uri.queryParameters['sha256'], ); + if (expectedChecksum == null) { + return const ThemeDefinitionImportFailure( + 'SHA256 checksum is required for remote theme install. ' + 'Add ?sha256= to the URL or pass sha256Checksum.', + ); + } File? tempFile; try { @@ -91,7 +98,7 @@ class ThemeRemoteInstallService { } final actualChecksum = sha256.convert(utf8.encode(body)).toString(); - if (expectedChecksum != null && expectedChecksum != actualChecksum) { + if (expectedChecksum != actualChecksum) { return const ThemeDefinitionImportFailure( 'Checksum mismatch. Theme was not installed.', ); diff --git a/lib/core/updater/installers/update_install_utils.dart b/lib/core/updater/installers/update_install_utils.dart index 7ef528ce..6755191e 100644 --- a/lib/core/updater/installers/update_install_utils.dart +++ b/lib/core/updater/installers/update_install_utils.dart @@ -3,6 +3,8 @@ import 'dart:io'; import 'package:archive/archive.dart'; import 'package:path/path.dart' as p; +import '../../security/archive_path_guard.dart'; +import '../../security/safe_zip_extractor.dart'; import '../app_updater_service.dart'; /// Safely extracts a zip archive into [destinationDir]. @@ -15,20 +17,24 @@ Future extractZipSecurely({ } await destinationDir.create(recursive: true); - final bytes = await zipFile.readAsBytes(); - final archive = ZipDecoder().decodeBytes(bytes); + final Archive archive; + try { + archive = await SafeZipExtractor.readAndDecodeFile(zipFile); + } on SafeZipException catch (error) { + throw AppUpdaterException(error.message); + } final root = p.normalize(destinationDir.path); for (final entry in archive) { final name = entry.name; - if (name.contains('..') || name.startsWith('/') || name.startsWith('\\')) { + if (!isArchiveEntryNameSafe(name)) { throw AppUpdaterException( 'Security violation: path traversal in archive entry "$name"', ); } final targetPath = p.normalize(p.join(root, name)); - if (!targetPath.startsWith(root)) { + if (!isArchiveExtractPathWithinRoot(root, targetPath)) { throw AppUpdaterException( 'Security violation: extraction path out of bounds "$name"', ); diff --git a/lib/features/extensions/presentation/pages/extension_manager_dialog.dart b/lib/features/extensions/presentation/pages/extension_manager_dialog.dart index 89bcc724..2fce9aca 100644 --- a/lib/features/extensions/presentation/pages/extension_manager_dialog.dart +++ b/lib/features/extensions/presentation/pages/extension_manager_dialog.dart @@ -7,6 +7,7 @@ import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/core/market/marketplace_repository.dart'; import 'package:querya_desktop/features/extensions/presentation/widgets/extension_card.dart'; +import 'package:querya_desktop/features/extensions/presentation/widgets/extension_sideload_dialog.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; void showExtensionManagerDialog(material.BuildContext context) { @@ -124,7 +125,18 @@ class _ExtensionManagerContentState ], ); if (file == null) return; - await LocalExtensionInstaller().installFromPath(file.path); + + if (!mounted) return; + final request = await showExtensionSideloadDialog( + context, + archivePath: file.path, + ); + if (request == null) return; + + await LocalExtensionInstaller().installFromPath( + request.archivePath, + expectedSha256: request.sha256Checksum, + ); await LocalExtensionRegistry.instance.reload(); if (!mounted) return; setState(() { @@ -269,7 +281,8 @@ class _ExtensionManagerContentState const material.SizedBox(width: 12), material.Expanded( child: const Text( - 'Install a local .zip or .qext package without the Marketplace.', + 'Install a local .zip or .qext package. Integrity verification ' + 'is optional — paste SHA-256 if the publisher provides one.', ).muted().small(), ), ], diff --git a/lib/features/extensions/presentation/widgets/extension_sideload_dialog.dart b/lib/features/extensions/presentation/widgets/extension_sideload_dialog.dart new file mode 100644 index 00000000..dfb84ff7 --- /dev/null +++ b/lib/features/extensions/presentation/widgets/extension_sideload_dialog.dart @@ -0,0 +1,128 @@ +import 'package:flutter/material.dart' as material; +import 'package:path/path.dart' as p; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +class ExtensionSideloadRequest { + const ExtensionSideloadRequest({ + required this.archivePath, + this.sha256Checksum, + }); + + final String archivePath; + final String? sha256Checksum; +} + +Future showExtensionSideloadDialog( + material.BuildContext context, { + required String archivePath, +}) async { + return showAppDialog( + context: context, + builder: (dialogContext) => _ExtensionSideloadDialog( + archivePath: archivePath, + ), + ); +} + +class _ExtensionSideloadDialog extends material.StatefulWidget { + const _ExtensionSideloadDialog({required this.archivePath}); + + final String archivePath; + + @override + material.State<_ExtensionSideloadDialog> createState() => + _ExtensionSideloadDialogState(); +} + +class _ExtensionSideloadDialogState + extends material.State<_ExtensionSideloadDialog> { + final _checksumController = material.TextEditingController(); + + @override + void dispose() { + _checksumController.dispose(); + super.dispose(); + } + + void _submit() { + final checksum = _checksumController.text.trim(); + material.Navigator.pop( + context, + ExtensionSideloadRequest( + archivePath: widget.archivePath, + sha256Checksum: checksum.isEmpty ? null : checksum, + ), + ); + } + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + final fileName = p.basename(widget.archivePath); + + return material.AlertDialog( + title: const material.Text('Install local extension'), + content: material.SizedBox( + width: 440, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Text('File: $fileName'), + const material.SizedBox(height: 12), + material.Container( + width: double.infinity, + padding: const material.EdgeInsets.all(12), + decoration: material.BoxDecoration( + color: cs.muted.withValues(alpha: 0.35), + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all(color: cs.border), + ), + child: material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Icon( + material.Icons.warning_amber_rounded, + size: 18, + color: cs.mutedForeground, + ), + const material.SizedBox(width: 10), + material.Expanded( + child: material.Text( + 'Local packages are not verified unless you provide a ' + 'SHA-256 checksum. Only install archives from sources you ' + 'trust.', + style: material.TextStyle( + fontSize: 13, + color: cs.mutedForeground, + ), + ), + ), + ], + ), + ), + const material.SizedBox(height: 12), + material.TextField( + controller: _checksumController, + decoration: const material.InputDecoration( + labelText: 'SHA-256 checksum (optional)', + hintText: '64-character hex digest from publisher', + ), + autocorrect: false, + ), + ], + ), + ), + actions: [ + OutlineButton( + onPressed: () => material.Navigator.pop(context), + child: const material.Text('Cancel'), + ), + PrimaryButton( + onPressed: _submit, + child: const material.Text('Install'), + ), + ], + ); + } +} diff --git a/lib/features/extensions/presentation/widgets/unsandboxed_driver_consent_dialog.dart b/lib/features/extensions/presentation/widgets/unsandboxed_driver_consent_dialog.dart new file mode 100644 index 00000000..dbba764d --- /dev/null +++ b/lib/features/extensions/presentation/widgets/unsandboxed_driver_consent_dialog.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_os_isolation.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +Future showUnsandboxedDriverConsentDialog( + material.BuildContext context, + SandboxOsIsolationUnavailableException details, +) async { + final approved = await showAppDialog( + context: context, + builder: (dialogContext) => material.AlertDialog( + title: const material.Text('Run driver without OS sandbox?'), + content: material.SizedBox( + width: 440, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Text(details.message), + if (details.installHint != null) ...[ + const material.SizedBox(height: 12), + material.Text( + details.installHint!, + style: material.TextStyle( + fontSize: 13, + color: Theme.of(dialogContext).colorScheme.mutedForeground, + ), + ), + ], + const material.SizedBox(height: 12), + const material.Text( + 'The driver process may access your user session (files, network) ' + 'beyond the extension manifest policy. Only continue if you trust ' + 'this extension package.', + ), + ], + ), + ), + actions: [ + OutlineButton( + onPressed: () => material.Navigator.pop(dialogContext, false), + child: const material.Text('Cancel'), + ), + PrimaryButton( + onPressed: () => material.Navigator.pop(dialogContext, true), + child: const material.Text('Run without OS sandbox'), + ), + ], + ), + ); + return approved == true; +} diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index 307fd937..f24f201e 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -13,6 +13,8 @@ import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:querya_desktop/core/extensions/sandbox/unsandboxed_launch_consent_gate.dart'; +import 'package:querya_desktop/features/extensions/presentation/widgets/unsandboxed_driver_consent_dialog.dart'; import 'package:querya_desktop/features/connections/connection_creation_flow.dart'; import 'package:querya_desktop/features/connections/new_connection_url_dialog.dart'; import 'package:querya_desktop/features/connections/connections_panel.dart'; @@ -39,8 +41,18 @@ class _MainScreenState extends State { final ValueNotifier _workspace = ValueNotifier(MainScreenWorkspaceState.empty); + @override + void initState() { + super.initState(); + UnsandboxedLaunchConsentGate.instance.handler = (details) { + if (!mounted) return Future.value(false); + return showUnsandboxedDriverConsentDialog(context, details); + }; + } + @override void dispose() { + UnsandboxedLaunchConsentGate.instance.handler = null; _workspace.dispose(); super.dispose(); } diff --git a/packaging/linux/aur/PKGBUILD b/packaging/linux/aur/PKGBUILD new file mode 100644 index 00000000..f789bcdd --- /dev/null +++ b/packaging/linux/aur/PKGBUILD @@ -0,0 +1,39 @@ +# Maintainer: QueryaHub +# AUR package — installs the official Release portable Linux zip under /opt. +# Bump pkgver/pkgrel when a new GitHub Release is published. +pkgname=querya-desktop +pkgver=0.4.11-a +pkgrel=1 +pkgdesc="Multi-database desktop client (PostgreSQL, MySQL, Redis, MongoDB, SQLite)" +arch=('x86_64') +url="https://github.com/QueryaHub/Querya-Desktop" +license=('MIT') +depends=('gtk3' 'libsecret' 'glib2') +optdepends=( + 'libappindicator-gtk3: system tray / app indicator' +) +source=( + "Querya-Desktop-${pkgver}-linux.zip::https://github.com/QueryaHub/Querya-Desktop/releases/download/${pkgver}/Querya-Desktop-${pkgver}-linux.zip" + "querya_desktop.desktop" +) +sha256sums=('SKIP' 'SKIP') + +prepare() { + bsdtar -xf "$srcdir/Querya-Desktop-${pkgver}-linux.zip" -C "$srcdir" +} + +package() { + install -dm755 "$pkgdir/opt/querya-desktop" + cp -a "$srcdir"/{querya_desktop,lib,data} "$pkgdir/opt/querya-desktop/" + + install -dm755 "$pkgdir/usr/bin" + ln -s /opt/querya-desktop/querya_desktop "$pkgdir/usr/bin/querya_desktop" + + install -dm755 "$pkgdir/usr/share/applications" + install -Dm644 "$srcdir/querya_desktop.desktop" \ + "$pkgdir/usr/share/applications/querya_desktop.desktop" + + install -dm755 "$pkgdir/usr/share/icons/hicolor/512x512/apps" + install -Dm644 "$srcdir/querya_desktop.png" \ + "$pkgdir/usr/share/icons/hicolor/512x512/apps/querya_desktop.png" +} diff --git a/packaging/linux/aur/README.md b/packaging/linux/aur/README.md new file mode 100644 index 00000000..fdcfdd8b --- /dev/null +++ b/packaging/linux/aur/README.md @@ -0,0 +1,21 @@ +# Arch Linux (AUR) + +Community packaging for Arch-based distros. The PKGBUILD installs the official +**portable Linux zip** from GitHub Releases under `/opt/querya-desktop`. + +## Before publishing to AUR + +1. Copy `PKGBUILD`, `querya_desktop.desktop`, and `querya_desktop.png` into a clean build directory. +2. Bump `pkgver` / `pkgrel` to match the GitHub Release tag and AUR revision. +3. Run `makepkg -si` locally and smoke-launch `querya_desktop`. +4. Generate `.SRCINFO`: `makepkg --printsrcinfo > .SRCINFO` +5. Push to your AUR repo (e.g. `querya-desktop`). + +`querya_desktop.desktop` and the 512×512 icon are the same assets used by +`.deb` / `.rpm` packaging (`packaging/linux/querya_desktop.desktop` and +`macos/Runner/Assets.xcassets/.../app_icon_512.png`). + +## Updates + +Prefer **`pacman -Syu`** / AUR helper updates over the in-app zip/AppImage +updater when running this package build. diff --git a/packaging/linux/aur/querya_desktop.desktop b/packaging/linux/aur/querya_desktop.desktop new file mode 100644 index 00000000..fc46a01b --- /dev/null +++ b/packaging/linux/aur/querya_desktop.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Type=Application +Name=Querya Desktop +Comment=Multi-database desktop client +Exec=querya_desktop +Icon=querya_desktop +Categories=Development;Database; +Terminal=false +StartupWMClass=querya_desktop +X-Querya-AppId=com.queryahub.querya_desktop diff --git a/packaging/linux/aur/querya_desktop.png b/packaging/linux/aur/querya_desktop.png new file mode 100644 index 00000000..326c0e72 Binary files /dev/null and b/packaging/linux/aur/querya_desktop.png differ diff --git a/packaging/linux/flatpak/com.queryahub.querya_desktop.desktop b/packaging/linux/flatpak/com.queryahub.querya_desktop.desktop new file mode 100644 index 00000000..da264a91 --- /dev/null +++ b/packaging/linux/flatpak/com.queryahub.querya_desktop.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Type=Application +Name=Querya Desktop +Comment=Multi-database desktop client +Exec=querya_desktop +Icon=com.queryahub.querya_desktop +Categories=Development;Database; +Terminal=false +StartupWMClass=querya_desktop diff --git a/packaging/linux/flatpak/com.queryahub.querya_desktop.yml b/packaging/linux/flatpak/com.queryahub.querya_desktop.yml new file mode 100644 index 00000000..50cdaa9d --- /dev/null +++ b/packaging/linux/flatpak/com.queryahub.querya_desktop.yml @@ -0,0 +1,29 @@ +# Flatpak manifest for Querya Desktop (Flutter GTK bundle). +# Built by scripts/linux/build_flatpak.sh — bundle/ is copied next to this file at build time. +app-id: com.queryahub.querya_desktop +runtime: org.gnome.Platform +runtime-version: '48' +sdk: org.gnome.Sdk +command: querya_desktop + +finish-args: + - --share=network + - --share=ipc + - --socket=fallback-x11 + - --socket=wayland + - --device=dri + - --filesystem=home + - --talk-name=org.freedesktop.secrets + +modules: + - name: querya-desktop + buildsystem: simple + build-commands: + - install -Dm755 querya_desktop /app/bin/querya_desktop + - cp -a lib /app/ + - cp -a data /app/ + - install -Dm644 com.queryahub.querya_desktop.desktop /app/share/applications/com.queryahub.querya_desktop.desktop + - install -Dm644 com.queryahub.querya_desktop.png /app/share/icons/hicolor/512x512/apps/com.queryahub.querya_desktop.png + sources: + - type: dir + path: bundle diff --git a/packaging/linux/querya-desktop.spec b/packaging/linux/querya-desktop.spec new file mode 100644 index 00000000..fa7d1be9 --- /dev/null +++ b/packaging/linux/querya-desktop.spec @@ -0,0 +1,44 @@ +# RPM spec for Querya Desktop (built via scripts/linux/build_rpm.sh). +# Macros querya_bundle, querya_icon, querya_desktop_file, and version are passed on the command line. + +Name: querya-desktop +Version: %{version} +Release: 1%{?dist} +Summary: Querya Desktop database client +License: MIT +URL: https://github.com/QueryaHub/Querya-Desktop +BuildArch: x86_64 +Requires: gtk3 >= 3.22 +Requires: libsecret >= 0.18 +Requires: glib2 >= 2.56 +Recommends: libappindicator-gtk3 + +%description +Multi-database desktop client for PostgreSQL, MySQL, Redis, MongoDB, +SQLite, and sandboxed drivers. + +%prep +# Pre-built Flutter bundle; no source compile in this packaging path. + +%build +# no-op + +%install +rm -rf %{buildroot} +mkdir -p %{buildroot}/opt/querya-desktop +cp -a %{querya_bundle}/. %{buildroot}/opt/querya-desktop/ +chmod +x %{buildroot}/opt/querya-desktop/querya_desktop +mkdir -p %{buildroot}/usr/bin +ln -s /opt/querya-desktop/querya_desktop %{buildroot}/usr/bin/querya_desktop +mkdir -p %{buildroot}/usr/share/applications +mkdir -p %{buildroot}/usr/share/icons/hicolor/512x512/apps +install -m 644 %{querya_desktop_file} %{buildroot}/usr/share/applications/querya_desktop.desktop +install -m 644 %{querya_icon} %{buildroot}/usr/share/icons/hicolor/512x512/apps/querya_desktop.png + +%files +/opt/querya-desktop +/usr/bin/querya_desktop +/usr/share/applications/querya_desktop.desktop +/usr/share/icons/hicolor/512x512/apps/querya_desktop.png + +%changelog diff --git a/packaging/linux/querya_desktop.desktop b/packaging/linux/querya_desktop.desktop new file mode 100644 index 00000000..fc46a01b --- /dev/null +++ b/packaging/linux/querya_desktop.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Type=Application +Name=Querya Desktop +Comment=Multi-database desktop client +Exec=querya_desktop +Icon=querya_desktop +Categories=Development;Database; +Terminal=false +StartupWMClass=querya_desktop +X-Querya-AppId=com.queryahub.querya_desktop diff --git a/pubspec.yaml b/pubspec.yaml index 76bdd6b6..20394a8d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: querya_desktop description: Lightweight desktop SQL/NoSQL client. Flutter (Dart). -version: 0.4.11+2 +version: 0.4.11-a diff --git a/scripts/linux/build_deb.sh b/scripts/linux/build_deb.sh index ce39bb2e..07299dc2 100755 --- a/scripts/linux/build_deb.sh +++ b/scripts/linux/build_deb.sh @@ -8,8 +8,6 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" BUNDLE="${1:-$ROOT/build/linux/x64/release/bundle}" BINARY_NAME="querya_desktop" -# Keep in sync with linux/CMakeLists.txt APPLICATION_ID (and #385 when merged). -APP_ID="${QUERYA_LINUX_APP_ID:-com.queryahub.querya_desktop}" ICON_SRC="$ROOT/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png" if [[ ! -d "$BUNDLE" ]]; then @@ -51,18 +49,8 @@ if [[ -f "$ICON_SRC" ]]; then cp "$ICON_SRC" "$PKG/usr/share/icons/hicolor/512x512/apps/${BINARY_NAME}.png" fi -cat > "$PKG/usr/share/applications/${BINARY_NAME}.desktop" <&2 + exit 1 +fi +if [[ ! -x "$BUNDLE/$BINARY_NAME" ]]; then + echo "error: missing executable: $BUNDLE/$BINARY_NAME" >&2 + exit 1 +fi +if ! command -v flatpak-builder >/dev/null 2>&1; then + echo "error: flatpak-builder is required" >&2 + exit 1 +fi + +VERSION="$(grep '^version:' "$ROOT/pubspec.yaml" | sed 's/^version: //; s/+.*//')" +OUT="${2:-$ROOT/Querya-Desktop-${VERSION}-linux.flatpak}" + +WORKDIR="$(mktemp -d "${TMPDIR:-/tmp}/querya-flatpak.XXXXXX")" +cleanup() { rm -rf "$WORKDIR"; } +trap cleanup EXIT + +STAGING="$WORKDIR/staging" +BUILD_DIR="$WORKDIR/build" +REPO="$WORKDIR/repo" +STATE="$WORKDIR/state" + +mkdir -p "$STAGING/bundle" +cp -a "$BUNDLE"/. "$STAGING/bundle/" +cp "$MANIFEST_SRC" "$STAGING/${APP_ID}.yml" +cp "$DESKTOP_SRC" "$STAGING/bundle/${APP_ID}.desktop" +cp "$ICON_SRC" "$STAGING/bundle/${APP_ID}.png" + +flatpak-builder \ + --force-clean \ + --repo="$REPO" \ + --state-dir="$STATE" \ + "$BUILD_DIR" \ + "$STAGING/${APP_ID}.yml" + +flatpak build-bundle "$REPO" "$OUT" "$APP_ID" +echo "Wrote $OUT" diff --git a/scripts/linux/build_rpm.sh b/scripts/linux/build_rpm.sh new file mode 100755 index 00000000..3457c72d --- /dev/null +++ b/scripts/linux/build_rpm.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Build Querya-Desktop-*.rpm from a Flutter linux release bundle (Fedora/RHEL/openSUSE). +# +# Usage: +# ./scripts/linux/build_rpm.sh [bundle_dir] [output_rpm] +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +BUNDLE="${1:-$ROOT/build/linux/x64/release/bundle}" +BINARY_NAME="querya_desktop" +ICON_SRC="$ROOT/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png" +DESKTOP_FILE="$ROOT/packaging/linux/querya_desktop.desktop" +SPEC="$ROOT/packaging/linux/querya-desktop.spec" + +if [[ ! -d "$BUNDLE" ]]; then + echo "error: Flutter linux bundle not found: $BUNDLE" >&2 + exit 1 +fi +if [[ ! -x "$BUNDLE/$BINARY_NAME" ]]; then + echo "error: missing executable: $BUNDLE/$BINARY_NAME" >&2 + exit 1 +fi +if [[ ! -f "$DESKTOP_FILE" ]]; then + echo "error: desktop file not found: $DESKTOP_FILE" >&2 + exit 1 +fi +if ! command -v rpmbuild >/dev/null 2>&1; then + echo "error: rpmbuild is required (dnf install rpm-build / apt install rpm)" >&2 + exit 1 +fi + +VERSION="$(grep '^version:' "$ROOT/pubspec.yaml" | sed 's/^version: //; s/+.*//')" +OUT="${2:-$ROOT/Querya-Desktop-${VERSION}-linux.rpm}" + +WORKDIR="$(mktemp -d "${TMPDIR:-/tmp}/querya-rpm.XXXXXX")" +cleanup() { rm -rf "$WORKDIR"; } +trap cleanup EXIT + +RPM_TOP="$WORKDIR/rpm" +mkdir -p "$RPM_TOP"/{BUILD,RPMS,SOURCES,SPECS,SRPMS,BUILDROOT} + +rpmbuild -bb "$SPEC" \ + --define "_topdir $RPM_TOP" \ + --define "version $VERSION" \ + --define "querya_bundle $BUNDLE" \ + --define "querya_icon $ICON_SRC" \ + --define "querya_desktop_file $DESKTOP_FILE" + +BUILT="$(find "$RPM_TOP/RPMS" -name 'querya-desktop-*.rpm' | head -n 1)" +if [[ -z "$BUILT" || ! -f "$BUILT" ]]; then + echo "error: rpmbuild did not produce an RPM under $RPM_TOP/RPMS" >&2 + exit 1 +fi + +cp "$BUILT" "$OUT" +echo "Wrote $OUT" diff --git a/test/core/extensions/rpc/plugin_rpc_bridge_test.dart b/test/core/extensions/rpc/plugin_rpc_bridge_test.dart index 6d5f73e9..c7bee5e8 100644 --- a/test/core/extensions/rpc/plugin_rpc_bridge_test.dart +++ b/test/core/extensions/rpc/plugin_rpc_bridge_test.dart @@ -150,6 +150,7 @@ void main() { final handshake = await bridge.start( manifest: testManifest, pluginExecutable: '/opt/driver', + allowUnsandboxedLaunch: true, ); expect(handshake, isA()); expect((handshake as Map)['protocolVersion'], '1.0'); @@ -190,7 +191,11 @@ void main() { ); await expectLater( - bridge.start(manifest: testManifest, pluginExecutable: '/opt/driver'), + bridge.start( + manifest: testManifest, + pluginExecutable: '/opt/driver', + allowUnsandboxedLaunch: true, + ), throwsA(isA()), ); expect(bridge.isStarted, isFalse); @@ -234,7 +239,11 @@ void main() { requestTimeout: const Duration(seconds: 5), ); - await bridge.start(manifest: testManifest, pluginExecutable: '/opt/driver'); + await bridge.start( + manifest: testManifest, + pluginExecutable: '/opt/driver', + allowUnsandboxedLaunch: true, + ); final pending = bridge.connect({'host': 'x'}); await Future.delayed(const Duration(milliseconds: 20)); process.completeExit(1); @@ -296,7 +305,11 @@ void main() { enableStderrPipe: false, ); - await bridge.start(manifest: testManifest, pluginExecutable: '/opt/driver'); + await bridge.start( + manifest: testManifest, + pluginExecutable: '/opt/driver', + allowUnsandboxedLaunch: true, + ); await bridge.shutdown(); expect(shutdownReceived, isTrue); diff --git a/test/core/extensions/sandbox/sandbox_os_isolation_test.dart b/test/core/extensions/sandbox/sandbox_os_isolation_test.dart new file mode 100644 index 00000000..b0c7f401 --- /dev/null +++ b/test/core/extensions/sandbox/sandbox_os_isolation_test.dart @@ -0,0 +1,48 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_launch_command.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_os_isolation.dart'; + +void main() { + group('SandboxOsIsolation', () { + test('returns null when launch command uses OS sandbox', () { + final command = SandboxLaunchCommand.build( + pluginExecutable: '/bin/driver', + scratchPath: '/tmp/s', + platformOverride: 'linux', + bwrapAvailable: true, + ); + + expect( + SandboxOsIsolation.exceptionForLaunchCommand(command), + isNull, + ); + }); + + test('returns linux message when bwrap unavailable', () { + final command = SandboxLaunchCommand.build( + pluginExecutable: '/bin/driver', + scratchPath: '/tmp/s', + platformOverride: 'linux', + bwrapAvailable: false, + ); + + final error = SandboxOsIsolation.exceptionForLaunchCommand(command); + expect(error, isNotNull); + expect(error!.platform, 'linux'); + expect(error.message, contains('bubblewrap')); + }); + + test('returns windows message for soft isolation path', () { + final command = SandboxLaunchCommand.build( + pluginExecutable: r'C:\driver.exe', + scratchPath: r'C:\tmp\s', + platformOverride: 'windows', + ); + + final error = SandboxOsIsolation.exceptionForLaunchCommand(command); + expect(error, isNotNull); + expect(error!.platform, 'windows'); + expect(error.message, contains('Windows')); + }); + }); +} diff --git a/test/core/extensions/sandbox/sandbox_process_runner_test.dart b/test/core/extensions/sandbox/sandbox_process_runner_test.dart index b2211b71..3b94223c 100644 --- a/test/core/extensions/sandbox/sandbox_process_runner_test.dart +++ b/test/core/extensions/sandbox/sandbox_process_runner_test.dart @@ -6,6 +6,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; import 'package:querya_desktop/core/extensions/models/sandbox_capabilities.dart'; import 'package:querya_desktop/core/extensions/sandbox/sandbox_launch_command.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_os_isolation.dart'; import 'package:querya_desktop/core/extensions/sandbox/sandbox_process_runner.dart'; import 'package:querya_desktop/core/extensions/sandbox/sandbox_scratch_directory.dart'; @@ -259,6 +260,58 @@ void main() { await handle.dispose(); }); + test('refuses unsandboxed launch without explicit consent', () async { + final runner = SandboxProcessRunner( + platformOverride: 'linux', + bwrapAvailable: false, + scratchBaseDirectory: tempBase, + processStarter: ( + exe, + args, { + String? workingDirectory, + Map? environment, + bool includeParentEnvironment = true, + bool runInShell = false, + ProcessStartMode mode = ProcessStartMode.normal, + }) async => + _FakeProcess(), + ); + + await expectLater( + () => runner.start( + pluginId: 'test.driver', + pluginExecutable: '/bin/driver', + ), + throwsA(isA()), + ); + }); + + test('allows unsandboxed launch when consent flag is set', () async { + final runner = SandboxProcessRunner( + platformOverride: 'windows', + scratchBaseDirectory: tempBase, + processStarter: ( + exe, + args, { + String? workingDirectory, + Map? environment, + bool includeParentEnvironment = true, + bool runInShell = false, + ProcessStartMode mode = ProcessStartMode.normal, + }) async => + _FakeProcess(), + ); + + final handle = await runner.start( + pluginId: 'win.driver', + pluginExecutable: 'driver.exe', + allowUnsandboxedLaunch: true, + ); + + expect(handle.launchCommand.usesOsSandbox, isFalse); + await handle.dispose(); + }); + test('start deletes scratch when process spawn fails', () async { Directory? observedScratch; final runner = SandboxProcessRunner( @@ -282,6 +335,7 @@ void main() { () => runner.start( pluginId: 'fail.driver', pluginExecutable: 'driver.exe', + allowUnsandboxedLaunch: true, ), throwsA(isA()), ); diff --git a/test/core/market/marketplace_download_policy_test.dart b/test/core/market/marketplace_download_policy_test.dart new file mode 100644 index 00000000..547e7490 --- /dev/null +++ b/test/core/market/marketplace_download_policy_test.dart @@ -0,0 +1,90 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/market/marketplace_download_policy.dart'; + +void main() { + group('MarketplaceDownloadPolicy', () { + test('trustedHostsFor includes API host and extras', () { + expect( + MarketplaceDownloadPolicy.trustedHostsFor( + apiBaseUrl: 'https://api.example.com/api/v1', + extraTrustedHosts: ['cdn.example.com'], + ), + {'api.example.com', 'cdn.example.com'}, + ); + }); + + test('isAllowedApiBaseUrl allows public https API', () { + expect( + MarketplaceDownloadPolicy.isAllowedApiBaseUrl( + 'https://api.example.com/api/v1', + allowLocalhostInDebug: false, + ), + isTrue, + ); + }); + + test('isAllowedApiBaseUrl rejects cleartext in release mode', () { + expect( + MarketplaceDownloadPolicy.isAllowedApiBaseUrl( + 'http://localhost:8000/api/v1', + allowLocalhostInDebug: false, + ), + isFalse, + ); + }); + + test('isAllowedApiBaseUrl allows localhost http in debug mode', () { + expect( + MarketplaceDownloadPolicy.isAllowedApiBaseUrl( + 'http://localhost:8000/api/v1', + allowLocalhostInDebug: true, + ), + isTrue, + ); + }); + + test('isAllowedDownloadUrl rejects untrusted host', () { + expect( + MarketplaceDownloadPolicy.isAllowedDownloadUrl( + Uri.parse('https://evil.example.com/pkg.zip'), + trustedHosts: {'api.example.com'}, + allowLocalhostInDebug: false, + ), + isFalse, + ); + }); + + test('isAllowedDownloadUrl rejects private IPs in release mode', () { + expect( + MarketplaceDownloadPolicy.isAllowedDownloadUrl( + Uri.parse('https://192.168.1.10/pkg.zip'), + trustedHosts: {'192.168.1.10'}, + allowLocalhostInDebug: false, + ), + isFalse, + ); + }); + + test('isAllowedDownloadUrl rejects file scheme', () { + expect( + MarketplaceDownloadPolicy.isAllowedDownloadUrl( + Uri.parse('file:///etc/passwd'), + trustedHosts: {'localhost'}, + allowLocalhostInDebug: true, + ), + isFalse, + ); + }); + + test('isAllowedDownloadUrl allows trusted public https host', () { + expect( + MarketplaceDownloadPolicy.isAllowedDownloadUrl( + Uri.parse('https://cdn.example.com/pkg.zip'), + trustedHosts: {'cdn.example.com'}, + allowLocalhostInDebug: false, + ), + isTrue, + ); + }); + }); +} diff --git a/test/core/market/marketplace_repository_test.dart b/test/core/market/marketplace_repository_test.dart index db9e14d2..4e23cfd5 100644 --- a/test/core/market/marketplace_repository_test.dart +++ b/test/core/market/marketplace_repository_test.dart @@ -231,6 +231,67 @@ void main() { ); }); + test('install aborts when SHA256 checksum is missing', () async { + final archive = Archive(); + archive.addFile(ArchiveFile('test.txt', 4, utf8.encode('good'))); + final zipBytes = ZipEncoder().encode(archive); + + final mockClient = MockClient((request) async { + return http.Response.bytes(zipBytes, 200); + }); + + final repo = HttpMarketplaceRepository(client: mockClient); + const manifest = ExtensionManifest( + id: 'test.no-sha256', + name: 'No SHA256', + version: '1.0.0', + publisher: 'Test', + type: ExtensionType.theme, + engines: {'querya_desktop': '*'}, + downloadUrl: 'http://localhost:8000/test.zip', + ); + + expect( + () => repo.install(manifest), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('missing SHA256 checksum'), + )), + ); + }); + + test('install aborts when SHA256 checksum is empty', () async { + final archive = Archive(); + archive.addFile(ArchiveFile('test.txt', 4, utf8.encode('good'))); + final zipBytes = ZipEncoder().encode(archive); + + final mockClient = MockClient((request) async { + return http.Response.bytes(zipBytes, 200); + }); + + final repo = HttpMarketplaceRepository(client: mockClient); + const manifest = ExtensionManifest( + id: 'test.empty-sha256', + name: 'Empty SHA256', + version: '1.0.0', + publisher: 'Test', + type: ExtensionType.theme, + engines: {'querya_desktop': '*'}, + downloadUrl: 'http://localhost:8000/test.zip', + sha256Checksum: ' ', + ); + + expect( + () => repo.install(manifest), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('missing SHA256 checksum'), + )), + ); + }); + test('install prevents Path Traversal during archive unpacking (Issue #242)', () async { final archive = Archive(); archive.addFile(ArchiveFile('../evil.txt', 4, utf8.encode('evil'))); @@ -294,6 +355,35 @@ void main() { )), ); }); + + test('download rejects disallowed URLs when release policy is enforced', + () async { + final repo = HttpMarketplaceRepository( + baseUrl: 'https://cdn.example.com/api/v1', + extraTrustedDownloadHosts: ['cdn.example.com'], + allowLocalhostInDebug: false, + client: MockClient((request) async => http.Response('', 200)), + ); + + expect( + () => repo.download('http://cdn.example.com/test.zip'), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Download URL is not allowed'), + )), + ); + + expect( + () => repo.download('https://127.0.0.1/test.zip'), + throwsA(isA()), + ); + + expect( + () => repo.download('file:///tmp/test.zip'), + throwsA(isA()), + ); + }); }); } diff --git a/test/core/security/archive_path_guard_test.dart b/test/core/security/archive_path_guard_test.dart new file mode 100644 index 00000000..01f64c7f --- /dev/null +++ b/test/core/security/archive_path_guard_test.dart @@ -0,0 +1,47 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/security/archive_path_guard.dart'; + +void main() { + group('isArchiveExtractPathWithinRoot', () { + test('allows paths inside root', () { + expect( + isArchiveExtractPathWithinRoot('/tmp/ext', '/tmp/ext/file.txt'), + isTrue, + ); + }); + + test('allows root path itself', () { + expect( + isArchiveExtractPathWithinRoot('/tmp/ext', '/tmp/ext'), + isTrue, + ); + }); + + test('rejects sibling prefix paths (startsWith false positive)', () { + expect( + isArchiveExtractPathWithinRoot('/tmp/abc', '/tmp/abcd/evil.txt'), + isFalse, + ); + }); + + test('rejects paths outside root', () { + expect( + isArchiveExtractPathWithinRoot('/tmp/ext', '/tmp/other/file.txt'), + isFalse, + ); + }); + }); + + group('isArchiveEntryNameSafe', () { + test('rejects traversal and absolute names', () { + expect(isArchiveEntryNameSafe('../evil.txt'), isFalse); + expect(isArchiveEntryNameSafe('/etc/passwd'), isFalse); + expect(isArchiveEntryNameSafe(r'\windows\system32'), isFalse); + }); + + test('allows relative safe names', () { + expect(isArchiveEntryNameSafe('manifest.json'), isTrue); + expect(isArchiveEntryNameSafe('bin/driver'), isTrue); + }); + }); +} diff --git a/test/core/security/safe_zip_extractor_test.dart b/test/core/security/safe_zip_extractor_test.dart new file mode 100644 index 00000000..283a4f63 --- /dev/null +++ b/test/core/security/safe_zip_extractor_test.dart @@ -0,0 +1,136 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:archive/archive.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/security/safe_zip_extractor.dart'; + +const _tightLimits = ZipDecodeLimits( + maxCompressedBytes: 4096, + maxTotalUncompressedBytes: 8192, + maxEntryCount: 5, + maxEntryUncompressedBytes: 4096, + maxCompressionRatio: 10, +); + +Archive _singleFileArchive(String name, List content) { + return Archive()..addFile(ArchiveFile(name, content.length, content)); +} + +Future _writeZip(Directory dir, Archive archive, String name) async { + final bytes = ZipEncoder().encode(archive); + final file = File(p.join(dir.path, name)); + await file.writeAsBytes(bytes); + return file; +} + +void main() { + group('SafeZipExtractor', () { + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('querya_safe_zip_'); + }); + + tearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + test('decodes a small valid archive', () { + final archive = _singleFileArchive('hello.txt', utf8.encode('hello')); + final zipBytes = ZipEncoder().encode(archive); + + final decoded = SafeZipExtractor.decodeBytes(zipBytes, limits: _tightLimits); + expect(decoded.length, 1); + expect(decoded.first.name, 'hello.txt'); + }); + + test('rejects archives exceeding max compressed bytes', () async { + final file = File(p.join(tempDir.path, 'oversize.zip')); + await file.writeAsBytes(List.filled(5000, 1)); + + expect( + () => SafeZipExtractor.readBoundedBytes(file, limits: _tightLimits), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('maximum compressed size'), + )), + ); + }); + + test('rejects archives with too many entries', () { + final archive = Archive(); + for (var i = 0; i < 6; i++) { + archive.addFile(ArchiveFile('file$i.txt', 1, [i])); + } + final zipBytes = ZipEncoder().encode(archive); + + expect( + () => SafeZipExtractor.decodeBytes(zipBytes, limits: _tightLimits), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('too many entries'), + )), + ); + }); + + test('rejects archives exceeding total uncompressed size', () { + const limits = ZipDecodeLimits( + maxCompressedBytes: 4096, + maxTotalUncompressedBytes: 6000, + maxEntryCount: 5, + maxEntryUncompressedBytes: 5000, + maxCompressionRatio: 100, + ); + final archive = Archive() + ..addFile(ArchiveFile('a.bin', 4000, List.filled(4000, 1))) + ..addFile(ArchiveFile('b.bin', 4000, List.filled(4000, 2))); + final zipBytes = ZipEncoder().encode(archive); + + expect( + () => SafeZipExtractor.decodeBytes(zipBytes, limits: limits), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('total uncompressed size'), + )), + ); + }); + + test('rejects high compression ratio zip bombs', () { + const limits = ZipDecodeLimits( + maxCompressedBytes: 4096, + maxTotalUncompressedBytes: 8192, + maxEntryCount: 5, + maxEntryUncompressedBytes: 10000, + maxCompressionRatio: 10, + ); + final payload = List.filled(5000, 0); + final archive = _singleFileArchive('bomb.bin', payload); + final zipBytes = ZipEncoder().encode(archive); + + expect( + () => SafeZipExtractor.decodeBytes(zipBytes, limits: limits), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('compression ratio'), + )), + ); + }); + + test('readAndDecodeFile reads bounded archives from disk', () async { + final archive = _singleFileArchive('ok.txt', utf8.encode('ok')); + final zipFile = await _writeZip(tempDir, archive, 'ok.zip'); + + final decoded = + await SafeZipExtractor.readAndDecodeFile(zipFile, limits: _tightLimits); + expect(decoded.first.name, 'ok.txt'); + }); + }); +} diff --git a/test/core/theme/theme_remote_install_service_test.dart b/test/core/theme/theme_remote_install_service_test.dart index 04adc8b6..8f10ac5a 100644 --- a/test/core/theme/theme_remote_install_service_test.dart +++ b/test/core/theme/theme_remote_install_service_test.dart @@ -1,5 +1,7 @@ +import 'dart:convert'; import 'dart:io'; +import 'package:crypto/crypto.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; @@ -26,6 +28,8 @@ Future _fixtureAssetLoader(String assetPath) async { return File(p.join('test/fixtures/themes', fileName)).readAsString(); } +String _sha256Hex(String body) => sha256.convert(utf8.encode(body)).toString(); + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -100,6 +104,7 @@ void main() { final result = await service.installFromUrl( 'https://cdn.example.com/themes/querya_custom_dark.json', + sha256Checksum: _sha256Hex(raw), ); expect(result, isA()); @@ -136,18 +141,37 @@ void main() { expect(await ExtensionPaths.mockExtensionsDirectory!.list().length, 0); }); + test('rejects install when SHA256 checksum is missing', () async { + final service = ThemeRemoteInstallService( + registry, + allowLocalhostInDebug: false, + ); + + final result = await service.installFromUrl( + 'https://cdn.example.com/themes/querya_custom_dark.json', + ); + + expect(result, isA()); + expect( + (result as ThemeDefinitionImportFailure).message, + contains('SHA256 checksum is required'), + ); + }); + test('rejects invalid JSON without writing to themes folder', () async { + const brokenBody = '{ not valid json'; final service = ThemeRemoteInstallService( registry, allowLocalhostInDebug: false, httpGet: (_) async => const RemoteThemeHttpResponse( statusCode: 200, - body: '{ not valid json', + body: brokenBody, ), ); final result = await service.installFromUrl( 'https://cdn.example.com/themes/broken.json', + sha256Checksum: _sha256Hex(brokenBody), ); expect(result, isA()); @@ -164,6 +188,32 @@ void main() { expect(result, isA()); }); + test('rejects localhost URLs when release policy is enforced', () async { + final service = ThemeRemoteInstallService( + registry, + allowLocalhostInDebug: false, + ); + final result = await service.installFromUrl( + 'https://127.0.0.1/theme.json', + ); + expect(result, isA()); + expect( + (result as ThemeDefinitionImportFailure).message, + contains('Only public HTTPS theme URLs are allowed'), + ); + }); + + test('rejects private IPv4 URLs when release policy is enforced', () async { + final service = ThemeRemoteInstallService( + registry, + allowLocalhostInDebug: false, + ); + final result = await service.installFromUrl( + 'https://192.168.1.10/theme.json', + ); + expect(result, isA()); + }); + test('reuses existing file when remote content hash matches', () async { final raw = await File('test/fixtures/themes/querya_custom_dark.json') .readAsString(); @@ -193,6 +243,7 @@ void main() { final result = await service.installFromUrl( 'https://cdn.example.com/themes/querya_custom_dark.json', + sha256Checksum: _sha256Hex(raw), ); expect(result, isA()); @@ -209,6 +260,7 @@ void main() { final result = await controller.importRegistryThemeFromUrl( 'https://cdn.example.com/themes/querya_custom_dark.json', + sha256Checksum: _sha256Hex(raw), remoteInstallService: ThemeRemoteInstallService( registry, allowLocalhostInDebug: false,