aboutsummaryrefslogtreecommitdiff
path: root/src/zen/cmds/projectstore_cmd.cpp
diff options
context:
space:
mode:
authorStefan Boberg <[email protected]>2026-04-21 13:48:41 +0200
committerGitHub Enterprise <[email protected]>2026-04-21 13:48:41 +0200
commit245d2e562165d048e5ee2ab97f1260975a8142d3 (patch)
treec01a36119ccacf830c8486634d78d0af137ec47f /src/zen/cmds/projectstore_cmd.cpp
parentasync consul register/deregister (#992) (diff)
downloadarchived-zen-245d2e562165d048e5ee2ab97f1260975a8142d3.tar.xz
archived-zen-245d2e562165d048e5ee2ab97f1260975a8142d3.zip
zen CLI security review fixes (#974)
Security review follow-ups to the `zen` CLI. Each fix stands on its own commit. Grouped by category below. ## Credentials and secrets - **Per-install random auth encryption key instead of a hardcoded literal.** The default AES key and IV used to encrypt persisted OIDC refresh tokens / OAuth client secrets were ASCII literals compiled into the public source. Replaced with 32+16 random bytes persisted to `<system-root>/auth/machinekey.dat`. `SecureRandomBytes` added in zencore/crypto wrapping BCryptGenRandom / OpenSSL / mbedTLS CTR_DRBG. Partial override (only one of `--encryption-aes-key`/`--encryption-aes-iv`) is now rejected instead of silently using the hardcoded half. - **Wrap the machine key with OS-protected storage.** `machinekey.dat` is now a tagged format (4-byte magic + flags + wrapped-or-raw payload). Windows wraps via DPAPI (`CryptProtectData` at per-user scope) so a stolen disk copy cannot decrypt without the OS master key. macOS uses Keychain Services (GenericPassword under `org.unrealengine.zen.auth`, `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`). Linux uses libsecret (opt-in via `--zenlibsecret=yes`, off by default because headless servers typically have no Secret Service daemon). All platforms fall back to raw persistence with `0600` perms on POSIX when wrapping is unavailable. Legacy files from the prior commit are detected by size and still read. > Note: argv-redaction before Sentry on crash was previously part of this PR but was superseded by `ScrubSensitiveValues()` from #989; this PR now just calls that helper instead of walking argv itself. ## Path traversal - **Reject unsafe filenames from the remote oplog in `oplog-mirror`.** The filename from each oplog entry was joined to the mirror root without normalisation; a compromised remote could use drive letters, UNC shares, device path prefixes, absolute paths, or `..` components to write anywhere the zen user could write. An `UnsafeFileNameReason` check runs immediately after extraction, logs the offending filename, and aborts the mirror. - **Use the resolved absolute download-spec path in `builds download`.** `--download-spec-path` was computed into a sanitised absolute path, then the original unresolved path was passed to `ParseBuildManifest`, bypassing the `MakeSafeAbsolutePath` mitigations and reading from the process cwd rather than `--local-path`. ## Input validation - **Stop asserting on malformed `--build-id` / `--build-part-id`.** `Oid::FromHexString` asserts on bad input and `ZEN_ASSERT` is active in release, so a too-short or non-hex user value aborted the process instead of surfacing an `OptionParseException`. Routed all callers through `TryFromHexString`. Also fixes `ParseBuildPartId` reporting errors under the wrong option name. - **Check the JSON parse error in `oplog-export --builds-metadata-path`.** The single-arg `LoadCompactBinaryFromJson` overload discarded the parser error; malformed JSON shipped a truncated compact-binary `metadata` field to the server with no indication. Switched to the two-arg overload and throws a descriptive error naming the file and reason. - **Format the actual value in the malformed `--url` error.** The message was constructed with a literal `{}` placeholder and no `fmt::format` call, so users saw the placeholder instead of the offending URL. - **Require `--output-path` in `cache get` unless `--as-text` is set.** Previously an empty path auto-filled from the value key / attachment hash and wrote into the process cwd; the `--as-text && empty path` stdout branch was unreachable because the auto-fill ran first. - **Clear the cxxopts `allow_unrecognised_options` flag after permissive parse.** `ParseOptionsPermissive` set the flag on the Options it received and never cleared it, priming that Options for silent typo acceptance on any later reuse. Added `disallow_unrecognised_options()` to the vendored cxxopts (local patch — flagged at the declaration) and wrapped the toggle in RAII. ## Resource lifecycle - **Restore signal handlers via RAII.** `wipe`, `builds`, and `oplog-mirror` installed SIGINT/SIGBREAK handlers with raw `signal()` and never restored them; an option-parse throw left the handler targeting an abort flag nothing reads. Added `zen::ScopedSignalHandler` in zen.h and applied at all three sites (builds uses `std::optional` members so the guards survive past `OnParentOptionsParsed` into the subcommand's Run). - **Route SIGINT in `oplog-mirror` to the worker-pool abort flag.** The command declared a local `std::atomic<bool> AbortFlag` but no handler targeted it — Ctrl-C killed the process instead of cleanly aborting. Added a `MirrorAbortFlag` / `MirrorSignalCallbackHandler` pair in projectstore_impl and bound the local as a reference; existing `.store`/`.load`/capture sites unchanged. - **Clean up the `cache get` temp download on every exit path.** `Http.Download` parks the payload in the system temp dir; a failed `MoveToFile` (cross-volume, denied target) or an exception could leave the temp file behind. The downloaded buffer is already flagged delete-on-close by `HttpClient`, so the fix is just to clear that flag after a successful `MoveToFile` so the renamed-out file isn't reaped. ## Other - **Fix wrong URL fields in `oplog-export` / `oplog-import` builds-branch descriptions.** Two operator-facing "[builds] URL/namespace/bucket/buildsid" messages formatted `m_CloudUrl` instead of `m_BuildsUrl` / `m_BuildsHost` (copy-paste from neighbouring `[cloud]` branches), shown as empty or stale at the start of an export/import. - **Fix "Can't find oplog in project '{}'" formatting and a "Failed top mirror" typo in projectstore_cmd.** - **Fix a misleading `oplog-export` comment on the `--zen` scheme default** ("Assume https" vs. the `http://` the code writes). - **Fail `ScrambleDir` when `RemoveFile` doesn't delete.** The `zen builds test` scramble phase used `(void)RemoveFile(FilePath)`, discarding both the bool return and the error. A quiet delete failure let verification run against stale state; switched to the two-arg overload and throw on false return or non-empty `error_code`.
Diffstat (limited to 'src/zen/cmds/projectstore_cmd.cpp')
-rw-r--r--src/zen/cmds/projectstore_cmd.cpp91
1 files changed, 83 insertions, 8 deletions
diff --git a/src/zen/cmds/projectstore_cmd.cpp b/src/zen/cmds/projectstore_cmd.cpp
index 2bd4803f6..d9ce1cfa7 100644
--- a/src/zen/cmds/projectstore_cmd.cpp
+++ b/src/zen/cmds/projectstore_cmd.cpp
@@ -8,6 +8,7 @@
#include <zencore/compactbinarybuilder.h>
#include <zencore/compactbinaryutil.h>
#include <zencore/compress.h>
+#include <zencore/except_fmt.h>
#include <zencore/filesystem.h>
#include <zencore/fmtutils.h>
#include <zencore/logging.h>
@@ -120,6 +121,26 @@ namespace projectstore_impl {
}
}
+ // `OplogMirrorCommand::Run` uses a latching boolean flag rather than the
+ // SignalCounter above, because it drives a worker pool that aborts on any
+ // interrupt. Kept separate from SignalCallbackHandler so neither interferes
+ // with the other when both are installed in the same process.
+ static std::atomic<bool> MirrorAbortFlag{false};
+
+ static void MirrorSignalCallbackHandler(int SigNum)
+ {
+ if (SigNum == SIGINT)
+ {
+ MirrorAbortFlag.store(true);
+ }
+#if ZEN_PLATFORM_WINDOWS
+ if (SigNum == SIGBREAK)
+ {
+ MirrorAbortFlag.store(true);
+ }
+#endif
+ }
+
void ExecuteAsyncOperation(HttpClient& Http, std::string_view Url, IoBuffer&& Payload, bool PlainProgress)
{
signal(SIGINT, SignalCallbackHandler);
@@ -577,7 +598,7 @@ DropProjectCommand::Run(const ZenCliOptions& GlobalOptions, int argc, char** arg
m_OplogName = ResolveOplog(Http, m_ProjectName, m_OplogName);
if (m_OplogName.empty())
{
- throw std::runtime_error(fmt::format("Can't find oplog in project '{}'", m_OplogName, m_ProjectName));
+ throw zen::runtime_error("Can't find oplog '{}' in project '{}'", m_OplogName, m_ProjectName);
}
if (m_DryRun)
{
@@ -1098,7 +1119,7 @@ ExportOplogCommand::Run(const ZenCliOptions& GlobalOptions, int argc, char** arg
std::string TargetUrlBase = m_ZenUrl;
if (TargetUrlBase.find("://") == std::string::npos)
{
- // Assume https URL
+ // Assume http URL
TargetUrlBase = fmt::format("http://{}", TargetUrlBase);
}
@@ -1287,7 +1308,14 @@ ExportOplogCommand::Run(const ZenCliOptions& GlobalOptions, int argc, char** arg
std::filesystem::path MetadataPath(m_BuildsMetadataPath);
IoBuffer MetaDataJson = ReadFile(MetadataPath).Flatten();
std::string_view Json(reinterpret_cast<const char*>(MetaDataJson.GetData()), MetaDataJson.GetSize());
- CbFieldIterator MetaData = LoadCompactBinaryFromJson(Json);
+ std::string JsonError;
+ CbFieldIterator MetaData = LoadCompactBinaryFromJson(Json, JsonError);
+ if (!JsonError.empty())
+ {
+ throw zen::runtime_error("builds metadata file '{}' is malformed. Reason: '{}'",
+ MetadataPath.string(),
+ JsonError);
+ }
Writer.AddBinary("metadata"sv, MetaData.GetBuffer());
}
if (!m_BuildsMetadata.empty())
@@ -1297,7 +1325,7 @@ ExportOplogCommand::Run(const ZenCliOptions& GlobalOptions, int argc, char** arg
size_t SplitPos = Pair.find('=');
if (SplitPos == std::string::npos || SplitPos == 0)
{
- throw std::runtime_error(fmt::format("builds metadata key-value pair '{}' is malformed", Pair));
+ throw zen::runtime_error("builds metadata key-value pair '{}' is malformed", Pair);
}
MetaDataWriter.AddString(Pair.substr(0, SplitPos), Pair.substr(SplitPos + 1));
return true;
@@ -1307,7 +1335,7 @@ ExportOplogCommand::Run(const ZenCliOptions& GlobalOptions, int argc, char** arg
}
}
Writer.EndObject(); // "builds"
- TargetDescription = fmt::format("[builds] {}/{}/{}/{}", m_CloudUrl, m_JupiterNamespace, m_JupiterBucket, m_BuildsId);
+ TargetDescription = fmt::format("[builds] {}/{}/{}/{}", m_BuildsUrl, m_JupiterNamespace, m_JupiterBucket, m_BuildsId);
}
if (!m_ZenUrl.empty())
{
@@ -1716,7 +1744,11 @@ ImportOplogCommand::Run(const ZenCliOptions& GlobalOptions, int argc, char** arg
}
}
Writer.EndObject(); // "builds"
- SourceDescription = fmt::format("[builds] {}/{}/{}/{}", m_CloudUrl, m_JupiterNamespace, m_JupiterBucket, m_BuildsId);
+ SourceDescription = fmt::format("[builds] {}/{}/{}/{}",
+ m_BuildsHost.empty() ? m_BuildsOverrideHost : m_BuildsHost,
+ m_JupiterNamespace,
+ m_JupiterBucket,
+ m_BuildsId);
}
if (!m_ZenUrl.empty())
{
@@ -2062,16 +2094,59 @@ OplogMirrorCommand::Run(const ZenCliOptions& GlobalOptions, int argc, char** arg
std::unordered_set<std::u8string> FileNames;
std::atomic<uint64_t> WrittenByteCount = 0;
- std::atomic<bool> AbortFlag(false);
+ // Install Ctrl-C handler so SIGINT aborts the worker pool rather than killing
+ // the process. Without this the local AbortFlag would shadow whatever global
+ // handler is installed elsewhere and interrupts would be dropped. RAII so
+ // the previous handler is restored when the function returns or throws.
+ MirrorAbortFlag.store(false);
+ ScopedSignalHandler SigIntGuard(SIGINT, MirrorSignalCallbackHandler);
+#if ZEN_PLATFORM_WINDOWS
+ ScopedSignalHandler SigBreakGuard(SIGBREAK, MirrorSignalCallbackHandler);
+#endif
+ std::atomic<bool>& AbortFlag = MirrorAbortFlag;
Stopwatch WriteStopWatch;
+ // Filenames come from the remote oplog, which may be compromised or untrusted.
+ // Reject anything that could escape the mirror root via an absolute path, drive
+ // letter / UNC / device path prefix, or '..' component before it is joined to
+ // RootPath. Returns nullptr when the filename is safe.
+ auto UnsafeFileNameReason = [](const std::filesystem::path& FileName) -> const char* {
+ if (FileName.empty())
+ {
+ return "filename is empty";
+ }
+ if (FileName.has_root_name())
+ {
+ return "filename has a root name (drive letter, UNC share, or device path)";
+ }
+ if (FileName.has_root_directory())
+ {
+ return "filename is absolute";
+ }
+ for (const std::filesystem::path& Component : FileName)
+ {
+ const std::u8string C = Component.u8string();
+ if (C.empty() || C == u8"..")
+ {
+ return "filename contains a '..' or empty component";
+ }
+ }
+ return nullptr;
+ };
+
auto EmitFilesForDataArray = [&](CbArrayView DataArray) {
for (auto DataIter : DataArray)
{
if (CbObjectView Data = DataIter.AsObjectView())
{
std::filesystem::path FileName(Data["filename"sv].AsU8String());
+ if (const char* Reason = UnsafeFileNameReason(FileName))
+ {
+ ZEN_CONSOLE_ERROR("Rejecting unsafe filename '{}' from remote oplog: {}", FileName.string(), Reason);
+ AbortFlag.store(true);
+ break;
+ }
if (!m_FilenameFilter.empty())
{
std::string FileNameLowerCase = ToLower(FileName.string());
@@ -2217,7 +2292,7 @@ OplogMirrorCommand::Run(const ZenCliOptions& GlobalOptions, int argc, char** arg
if (AbortFlag)
{
- throw std::runtime_error("Failed top mirror oplog");
+ throw std::runtime_error("Failed to mirror oplog");
}
}
else