Compare commits
2
Commits
31e9c2ee2c
...
d16bbf3695
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d16bbf3695 | ||
|
|
83ce73f26b |
@@ -162,6 +162,7 @@ void App::wipeDisplayedSecrets()
|
||||
show_seed_backup_ = false;
|
||||
sodium_memzero(export_result_, sizeof(export_result_));
|
||||
ui::KeyExportDialog::hide(); // zeros its static s_key via secureWipeLiteSecret
|
||||
console_tab_.wipeSecretResult(); // drop any masked key-dump result held in the console
|
||||
}
|
||||
|
||||
// Enqueue a blocking xmrig start/stop op onto the dedicated serialized control thread so the render thread
|
||||
@@ -2411,6 +2412,8 @@ void App::render()
|
||||
console_exec_ = std::make_unique<ui::LiteConsoleExecutor>(this);
|
||||
else
|
||||
console_exec_ = std::make_unique<ui::FullNodeConsoleExecutor>(this);
|
||||
// One-time: route the console's "Copy secret" through the auto-clearing clipboard.
|
||||
console_tab_.setCopySecretHandler([this](const std::string& s) { copySecretToClipboard(s); });
|
||||
}
|
||||
console_tab_.render(*console_exec_);
|
||||
// Persist the console toolbar toggles (ConsoleTab has no settings pointer). Save only when
|
||||
|
||||
@@ -791,12 +791,11 @@ void App::checkAutoLock() {
|
||||
if (capture_mode_) return; // don't auto-lock while a UI sweep forces the encrypted/locked state
|
||||
if (!state_.isEncrypted() || state_.isLocked()) return;
|
||||
|
||||
// Don't auto-lock while mining — mining is a long-running intentional
|
||||
// operation and locking the wallet on the daemon side stops solo mining.
|
||||
bool miningActive = state_.mining.generate
|
||||
|| (xmrig_manager_ && xmrig_manager_->isRunning());
|
||||
if (miningActive) {
|
||||
// Keep resetting the idle timer so we don't lock the instant mining stops
|
||||
// Don't auto-lock while SOLO mining — it runs in the daemon and locking the wallet there would stop
|
||||
// it (and it shields matured rewards, which needs the wallet). Pool mining pays an external address
|
||||
// and needs no unlocked wallet, so it must NOT hold the wallet open indefinitely.
|
||||
if (state_.mining.generate) {
|
||||
// Keep resetting the idle timer so we don't lock the instant solo mining stops.
|
||||
idle_.noteActivity();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -108,8 +108,18 @@ bool ChatDatabase::append(const ChatMessage& message)
|
||||
|
||||
std::vector<unsigned char> nonce;
|
||||
std::vector<unsigned char> cipher;
|
||||
std::string plain = serialize(message); // full plaintext (decrypted body + metadata)
|
||||
const bool encrypted = encrypt(plain, nonce, cipher);
|
||||
std::string plain;
|
||||
bool encrypted = false;
|
||||
try {
|
||||
// serialize() dumps strictly, so an invalid-UTF-8 (attacker-influenceable) body THROWS here;
|
||||
// skip the row instead of letting it escape the write path and crash (appendBatch already does).
|
||||
plain = serialize(message);
|
||||
encrypted = encrypt(plain, nonce, cipher);
|
||||
} catch (const std::exception& e) {
|
||||
if (!plain.empty()) sodium_memzero(&plain[0], plain.size());
|
||||
DEBUG_LOGF("[chat] append skipped a malformed message: %s\n", e.what());
|
||||
return false;
|
||||
}
|
||||
if (!plain.empty()) sodium_memzero(&plain[0], plain.size()); // don't leave it on the heap
|
||||
if (!encrypted) return false;
|
||||
|
||||
@@ -187,8 +197,16 @@ bool ChatDatabase::upsert(const ChatMessage& message)
|
||||
|
||||
std::vector<unsigned char> nonce;
|
||||
std::vector<unsigned char> cipher;
|
||||
std::string plain = serialize(message);
|
||||
const bool encrypted = encrypt(plain, nonce, cipher);
|
||||
std::string plain;
|
||||
bool encrypted = false;
|
||||
try {
|
||||
plain = serialize(message); // strict dump → a malformed body throws; skip rather than crash
|
||||
encrypted = encrypt(plain, nonce, cipher);
|
||||
} catch (const std::exception& e) {
|
||||
if (!plain.empty()) sodium_memzero(&plain[0], plain.size());
|
||||
DEBUG_LOGF("[chat] upsert skipped a malformed message: %s\n", e.what());
|
||||
return false;
|
||||
}
|
||||
if (!plain.empty()) sodium_memzero(&plain[0], plain.size());
|
||||
if (!encrypted) return false;
|
||||
|
||||
@@ -416,7 +434,7 @@ std::string ChatDatabase::serialize(const ChatMessage& message) const
|
||||
json["ts"] = message.timestamp;
|
||||
json["pos"] = static_cast<std::uint64_t>(message.payload_position);
|
||||
json["dl"] = static_cast<int>(message.delivery);
|
||||
return json.dump();
|
||||
return json.dump(); // strict: an invalid-UTF-8 body throws and is DROPPED by the callers (contract)
|
||||
}
|
||||
|
||||
bool ChatDatabase::deserialize(const std::string& json, ChatMessage& out) const
|
||||
|
||||
+9
-18
@@ -460,15 +460,14 @@ bool Connection::createDefaultConfig(const std::string& path)
|
||||
return false;
|
||||
}
|
||||
|
||||
std::ofstream file(path);
|
||||
if (!file.is_open()) {
|
||||
DEBUG_LOGF("Failed to create config file: %s\n", path.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Build the content in memory, then write it 0600 from the start (writeFileAtomically with
|
||||
// restrictPermissions) — the old ofstream+chmod left a brief world-readable window during which
|
||||
// another local user could read the freshly-generated rpcuser/rpcpassword.
|
||||
std::ostringstream file;
|
||||
|
||||
// Get the data directory for exportdir
|
||||
std::string dataDir = getDefaultDataDir();
|
||||
|
||||
|
||||
file << "# DragonX configuration file\n";
|
||||
file << "# Auto-generated by DragonX Wallet\n";
|
||||
file << "\n";
|
||||
@@ -485,18 +484,10 @@ bool Connection::createDefaultConfig(const std::string& path)
|
||||
file << "addnode=seed.dragonx.is\n";
|
||||
file << "addnode=node1.dragonx.is\n";
|
||||
file << "addnode=node5.dragonx.is\n";
|
||||
|
||||
file.close();
|
||||
|
||||
// The file holds the freshly-generated rpcuser/rpcpassword in plaintext. ofstream creates it
|
||||
// with the process umask (typically world-readable 0644), so restrict it to owner read/write
|
||||
// before another local user can read the credentials.
|
||||
{
|
||||
namespace fs = std::filesystem;
|
||||
std::error_code ec;
|
||||
fs::permissions(path, fs::perms::owner_read | fs::perms::owner_write,
|
||||
fs::perm_options::replace, ec);
|
||||
if (ec) DEBUG_LOGF("Could not restrict config permissions on %s: %s\n", path.c_str(), ec.message().c_str());
|
||||
if (!util::Platform::writeFileAtomically(path, file.str(), /*restrictPermissions=*/true)) {
|
||||
DEBUG_LOGF("Failed to create config file: %s\n", path.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
DEBUG_LOGF("Created default config file: %s\n", path.c_str());
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
// tab completion, daemon log display, and color-coded output.
|
||||
|
||||
#include "console_tab.h"
|
||||
#include <sodium.h> // sodium_memzero — scrub masked secret-result plaintext
|
||||
#include "console_command_executor.h"
|
||||
#include "console_command_reference.h"
|
||||
#include "console_input_model.h"
|
||||
@@ -298,7 +299,26 @@ void ConsoleTab::render(ConsoleCommandExecutor& exec)
|
||||
{
|
||||
std::string result;
|
||||
bool isError = false;
|
||||
while (exec.pollResult(result, isError)) addFormattedResult(result, isError);
|
||||
while (exec.pollResult(result, isError)) {
|
||||
const bool secret = secret_result_armed_ && !isError;
|
||||
secret_result_armed_ = false; // the first (non-error) result consumes the arming
|
||||
if (secret) {
|
||||
// Hold the plaintext ONLY in secret_result_ and show a masked placeholder — it never
|
||||
// enters the scrollback model, so Copy All / Ctrl+C / the filter can't leak it.
|
||||
if (!secret_result_.empty()) sodium_memzero(&secret_result_[0], secret_result_.size());
|
||||
secret_result_ = result;
|
||||
secret_result_deadline_ = std::chrono::steady_clock::now() + std::chrono::seconds(60);
|
||||
addLine(TR("console_secret_result_hidden"), ConsoleChannel::Warning);
|
||||
} else {
|
||||
addFormattedResult(result, isError);
|
||||
}
|
||||
if (!result.empty()) sodium_memzero(&result[0], result.size());
|
||||
}
|
||||
}
|
||||
// Auto-wipe a revealed secret result after its deadline so it doesn't linger for the session.
|
||||
if (!secret_result_.empty() && std::chrono::steady_clock::now() >= secret_result_deadline_) {
|
||||
wipeSecretResult();
|
||||
addLine(TR("console_secret_result_expired"), ConsoleChannel::Info);
|
||||
}
|
||||
|
||||
// Drain the ingest queue into the visible model once per frame, after this frame's own
|
||||
@@ -547,7 +567,21 @@ void ConsoleTab::renderToolbar(ConsoleCommandExecutor& exec)
|
||||
if (ImGui::IsItemHovered()) {
|
||||
material::Tooltip("%s", selection_.active() ? TR("console_copy_selected") : TR("console_copy_all"));
|
||||
}
|
||||
|
||||
|
||||
// When a key-revealing result is masked, offer an explicit Copy-secret (through the auto-clearing
|
||||
// clipboard) and a Hide action. The secret is never in the model, so the normal Copy above is safe.
|
||||
if (hasSecretResult()) {
|
||||
ImGui::SameLine();
|
||||
if (TactileButton(TR("console_copy_secret"), ImVec2(0, 0), schema::UI().resolveFont("button"))) {
|
||||
if (copy_secret_cb_) copy_secret_cb_(secret_result_);
|
||||
else ImGui::SetClipboardText(secret_result_.c_str());
|
||||
}
|
||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("console_copy_secret_tip"));
|
||||
ImGui::SameLine();
|
||||
if (TactileButton(TR("console_hide_secret"), ImVec2(0, 0), schema::UI().resolveFont("button")))
|
||||
wipeSecretResult();
|
||||
}
|
||||
|
||||
ImGui::SameLine();
|
||||
|
||||
// Commands reference button — shown whenever the backend offers a reference table (full-node
|
||||
@@ -1565,6 +1599,7 @@ bool IsDestructiveConsoleCommand(const std::string& firstLower, bool fullNode)
|
||||
bool ConsoleTab::submitConsoleCommand(ConsoleCommandExecutor& exec, const std::string& cmd)
|
||||
{
|
||||
if (cmd.empty()) return false;
|
||||
secret_result_armed_ = false; // cleared each submit so a no-result command can't mask the next one
|
||||
|
||||
// Redact secret-bearing commands (walletpassphrase, z_importkey, …) before they reach the visible
|
||||
// log and the recall history. The real `cmd` below is still executed unredacted.
|
||||
@@ -1622,6 +1657,8 @@ bool ConsoleTab::submitConsoleCommand(ConsoleCommandExecutor& exec, const std::s
|
||||
addLine(TR("console_destructive_confirm"), ConsoleChannel::Warning);
|
||||
} else {
|
||||
destructive_confirm_cmd_.clear();
|
||||
// Arm result-masking for key-revealing commands so their OUTPUT is hidden in the scrollback.
|
||||
secret_result_armed_ = ConsoleCommandRevealsSecret(cmd, exec.hasRpcReference());
|
||||
exec.submit(cmd);
|
||||
}
|
||||
return true;
|
||||
@@ -2202,8 +2239,17 @@ void ConsoleTab::addRpcTraceLine(const std::string& source, const std::string& m
|
||||
addLine("[" + rpcTraceTimestamp() + "] [" + source + "] " + method, ConsoleChannel::Rpc);
|
||||
}
|
||||
|
||||
void ConsoleTab::wipeSecretResult()
|
||||
{
|
||||
if (!secret_result_.empty()) sodium_memzero(&secret_result_[0], secret_result_.size());
|
||||
secret_result_.clear();
|
||||
secret_result_armed_ = false;
|
||||
secret_result_deadline_ = {};
|
||||
}
|
||||
|
||||
void ConsoleTab::clear()
|
||||
{
|
||||
wipeSecretResult(); // the clear command / toolbar button also drops any held secret result
|
||||
// View-only clear (main thread). The executor keeps its own log cursors, so new output
|
||||
// still appends. The "cleared" line is ingested and appears on the next frame's drain.
|
||||
model_.clear();
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
#include <vector>
|
||||
#include <deque>
|
||||
#include <mutex>
|
||||
#include <functional>
|
||||
#include <chrono>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
@@ -66,6 +68,13 @@ public:
|
||||
*/
|
||||
void clear();
|
||||
|
||||
// Masked secret-result support: the plaintext of a key-revealing command's output is held ONLY here
|
||||
// (never in the scrollback model), so Copy All / Ctrl+C / the filter can't leak it. An explicit
|
||||
// "Copy secret" action routes through this handler (the app wires it to the auto-clearing clipboard).
|
||||
void setCopySecretHandler(std::function<void(const std::string&)> f) { copy_secret_cb_ = std::move(f); }
|
||||
bool hasSecretResult() const { return !secret_result_.empty(); }
|
||||
void wipeSecretResult(); // zero + drop the buffer, disarm, reset the deadline (call on lock/clear)
|
||||
|
||||
// Ask the console to place the keyboard focus in the command input on the next render (consumed once).
|
||||
// Called when the user switches to the Console tab, gated by the console_auto_focus setting.
|
||||
void requestInputFocus() { focus_input_pending_ = true; }
|
||||
@@ -162,6 +171,10 @@ private:
|
||||
char input_buffer_[4096] = {0};
|
||||
bool stop_confirm_pending_ = false; // 'stop' typed once, awaiting a confirming second 'stop'
|
||||
std::string destructive_confirm_cmd_; // a destructive command typed once, awaiting an identical re-entry
|
||||
bool secret_result_armed_ = false; // the just-submitted command reveals key material in its result
|
||||
std::string secret_result_; // plaintext of the masked result (member-only; never in model_)
|
||||
std::chrono::steady_clock::time_point secret_result_deadline_{}; // auto-wipe the buffer at this time
|
||||
std::function<void(const std::string&)> copy_secret_cb_; // routes "Copy secret" to the app
|
||||
bool focus_input_pending_ = false; // one-shot: focus the command input next render (tab-open auto-focus)
|
||||
// (log-ingestion cursors + result queue moved to the ConsoleCommandExecutor)
|
||||
|
||||
|
||||
@@ -71,6 +71,25 @@ bool ConsoleCommandCarriesSecret(const std::string& cmd)
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ConsoleCommandRevealsSecret(const std::string& cmd, bool fullNode)
|
||||
{
|
||||
size_t end = 0;
|
||||
const std::string name = firstConsoleTokenLower(cmd, end);
|
||||
if (name.empty()) return false;
|
||||
if (fullNode) {
|
||||
static const char* const kFull[] = {
|
||||
"dumpprivkey", "z_exportkey", "z_exportviewingkey",
|
||||
"z_exportmnemonic", "dumpwallet", "z_exportwallet",
|
||||
};
|
||||
for (const char* s : kFull) if (name == s) return true;
|
||||
} else {
|
||||
// Lite backend verbs whose output is the seed phrase / private keys.
|
||||
static const char* const kLite[] = { "seed", "export" };
|
||||
for (const char* s : kLite) if (name == s) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string RedactConsoleCommand(const std::string& cmd)
|
||||
{
|
||||
size_t end = 0;
|
||||
|
||||
@@ -29,6 +29,12 @@ bool ShouldPrintDaemonErrorLine(bool wasError, bool isError,
|
||||
// z_exportkey, z_exportmnemonic) are NOT covered — their secret is in the result, a separate concern.
|
||||
bool ConsoleCommandCarriesSecret(const std::string& cmd);
|
||||
|
||||
// True when the command's RESULT (not its args) reveals key material — dumpprivkey / z_exportkey /
|
||||
// z_exportviewingkey / z_exportmnemonic / dumpwallet / z_exportwallet on the full node, or the lite
|
||||
// `seed` / `export` verbs. Used to mask the output in the console scrollback. `fullNode` selects the
|
||||
// command set (the bare lite verbs `seed`/`export` must not match full-node commands).
|
||||
bool ConsoleCommandRevealsSecret(const std::string& cmd, bool fullNode);
|
||||
|
||||
// A display/history-safe copy of `cmd`: the command name with its arguments replaced by "****" when
|
||||
// it carries a secret, else `cmd` unchanged. The real command is still executed unredacted.
|
||||
std::string RedactConsoleCommand(const std::string& cmd);
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include "../../rpc/rpc_worker.h"
|
||||
#include "../../util/i18n.h"
|
||||
#include "../../util/platform.h"
|
||||
#include "../../util/export_filename.h"
|
||||
#include "../notifications.h"
|
||||
#include "../schema/ui_schema.h"
|
||||
#include "../material/draw_helpers.h"
|
||||
@@ -160,8 +161,11 @@ void ExportAllKeysDialog::render(App* app)
|
||||
if (s_include_t) {
|
||||
for (const auto& a : state.t_addresses) t_addrs.push_back(a.address);
|
||||
}
|
||||
std::string filename(s_filename);
|
||||
|
||||
// Sanitize the typed filename (basename only, no traversal) and reflect it back in the
|
||||
// buffer so the user sees exactly what gets written. Mirrors the CSV export dialog.
|
||||
std::string filename = util::sanitizeExportFilename(s_filename, ".txt", "dragonx_keys.txt");
|
||||
snprintf(s_filename, sizeof(s_filename), "%s", filename.c_str());
|
||||
|
||||
// Run all key exports on worker thread
|
||||
if (app->worker()) {
|
||||
app->worker()->post([rpc = app->rpc(), z_addrs, t_addrs, filename]() -> rpc::RPCWorker::MainCb {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include "../../app.h"
|
||||
#include "../../util/i18n.h"
|
||||
#include "../../util/platform.h"
|
||||
#include "../../util/export_filename.h"
|
||||
#include "../../data/transaction_csv.h"
|
||||
#include "transactions_tab.h"
|
||||
#include "../notifications.h"
|
||||
@@ -49,31 +50,6 @@ static bool parseYmd(const char* s, std::int64_t& outStartOfDay) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Make a user-typed filename safe to write into the config dir: basename only, illegal chars → '_',
|
||||
// no leading dots / trailing dots-or-spaces, length-capped, always ending in .csv.
|
||||
static std::string sanitizeExportFilename(std::string name) {
|
||||
const size_t slash = name.find_last_of("/\\");
|
||||
if (slash != std::string::npos) name = name.substr(slash + 1);
|
||||
auto notspace = [](unsigned char c) { return !std::isspace(c); };
|
||||
name.erase(name.begin(), std::find_if(name.begin(), name.end(), notspace));
|
||||
name.erase(std::find_if(name.rbegin(), name.rend(), notspace).base(), name.end());
|
||||
for (char& c : name) {
|
||||
const unsigned char u = static_cast<unsigned char>(c);
|
||||
if (u < 0x20 || c == '<' || c == '>' || c == ':' || c == '"' || c == '|' || c == '?' || c == '*')
|
||||
c = '_';
|
||||
}
|
||||
const size_t firstGood = name.find_first_not_of('.');
|
||||
if (firstGood == std::string::npos) name.clear(); else name.erase(0, firstGood);
|
||||
while (!name.empty() && (name.back() == '.' || name.back() == ' ')) name.pop_back();
|
||||
if (name.size() > 120) name.resize(120);
|
||||
std::string lower = name;
|
||||
std::transform(lower.begin(), lower.end(), lower.begin(),
|
||||
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||
if (lower.size() < 4 || lower.compare(lower.size() - 4, 4, ".csv") != 0) name += ".csv";
|
||||
if (name.empty() || name == ".csv") name = "dragonx_transactions.csv";
|
||||
return name;
|
||||
}
|
||||
|
||||
void ExportTransactionsDialog::show()
|
||||
{
|
||||
s_open = true;
|
||||
@@ -226,7 +202,7 @@ void ExportTransactionsDialog::render(App* app)
|
||||
std::string configDir = util::Platform::getConfigDir();
|
||||
// Sanitize in-buffer so the user sees exactly what was written (and we can't be pointed outside
|
||||
// the config dir by a pasted path).
|
||||
std::string safeName = sanitizeExportFilename(s_filename);
|
||||
std::string safeName = util::sanitizeExportFilename(s_filename, ".csv", "dragonx_transactions.csv");
|
||||
snprintf(s_filename, sizeof(s_filename), "%s", safeName.c_str());
|
||||
std::string filepath = configDir + "/" + safeName;
|
||||
// Build the chosen subset (scope 0 exports the whole list with no copy), then write atomically
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
|
||||
namespace dragonx {
|
||||
namespace util {
|
||||
|
||||
// Make a user-typed filename safe to write into a trusted config/export directory: basename only
|
||||
// (no path traversal), illegal / control chars -> '_', no leading dots or trailing dots-or-spaces,
|
||||
// Windows reserved device stems prefixed with '_', length-capped, and always ending in requiredExt
|
||||
// (e.g. ".csv"). Returns `fallback` if nothing usable remains. Header-only so every export dialog
|
||||
// shares one implementation.
|
||||
inline std::string sanitizeExportFilename(std::string name, const char* requiredExt, const char* fallback) {
|
||||
const std::size_t slash = name.find_last_of("/\\");
|
||||
if (slash != std::string::npos) name = name.substr(slash + 1);
|
||||
auto notSpace = [](unsigned char c) { return !std::isspace(c); };
|
||||
name.erase(name.begin(), std::find_if(name.begin(), name.end(), notSpace));
|
||||
name.erase(std::find_if(name.rbegin(), name.rend(), notSpace).base(), name.end());
|
||||
for (char& c : name) {
|
||||
const unsigned char u = static_cast<unsigned char>(c);
|
||||
if (u < 0x20 || c == '<' || c == '>' || c == ':' || c == '"' || c == '|' ||
|
||||
c == '?' || c == '*' || c == '/' || c == '\\')
|
||||
c = '_';
|
||||
}
|
||||
const std::size_t firstGood = name.find_first_not_of('.');
|
||||
if (firstGood == std::string::npos) name.clear(); else name.erase(0, firstGood);
|
||||
while (!name.empty() && (name.back() == '.' || name.back() == ' ')) name.pop_back();
|
||||
if (name.size() > 120) name.resize(120);
|
||||
// Windows reserved device names (case-insensitive, checked on the stem before the first dot).
|
||||
{
|
||||
std::string stem = name;
|
||||
const std::size_t dot = stem.find('.');
|
||||
if (dot != std::string::npos) stem = stem.substr(0, dot);
|
||||
std::transform(stem.begin(), stem.end(), stem.begin(),
|
||||
[](unsigned char c) { return static_cast<char>(std::toupper(c)); });
|
||||
static const char* const kReserved[] = {
|
||||
"CON", "PRN", "AUX", "NUL",
|
||||
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
|
||||
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
|
||||
};
|
||||
for (const char* r : kReserved) if (stem == r) { name = "_" + name; break; }
|
||||
}
|
||||
std::string lower = name;
|
||||
std::transform(lower.begin(), lower.end(), lower.begin(),
|
||||
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||
std::string lext = requiredExt ? requiredExt : "";
|
||||
std::transform(lext.begin(), lext.end(), lext.begin(),
|
||||
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||
if (!lext.empty() &&
|
||||
(lower.size() < lext.size() || lower.compare(lower.size() - lext.size(), lext.size(), lext) != 0))
|
||||
name += requiredExt;
|
||||
if (name.empty() || (requiredExt && name == requiredExt)) name = fallback ? fallback : "export";
|
||||
return name;
|
||||
}
|
||||
|
||||
} // namespace util
|
||||
} // namespace dragonx
|
||||
@@ -60,6 +60,20 @@ int cancelXferCb(void* clientp, curl_off_t, curl_off_t, curl_off_t, curl_off_t)
|
||||
return static_cast<const std::atomic<bool>*>(clientp)->load(std::memory_order_relaxed) ? 1 : 0;
|
||||
}
|
||||
|
||||
// Restrict the transfer AND any redirect to HTTPS so a redirect can't silently downgrade to http://,
|
||||
// file://, ftp:// or an unexpected scheme. Every caller of these shared helpers (daemon/xmrig updaters,
|
||||
// pool stats) uses https URLs. (Verify-peer/host stay on at the call sites.)
|
||||
void restrictToHttps(CURL* curl)
|
||||
{
|
||||
#if LIBCURL_VERSION_NUM >= 0x075500 // 7.85.0: the string-form protocol setopts
|
||||
curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "https");
|
||||
curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS_STR, "https");
|
||||
#else
|
||||
curl_easy_setopt(curl, CURLOPT_PROTOCOLS, CURLPROTO_HTTPS);
|
||||
curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS, CURLPROTO_HTTPS);
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
HttpGetResult httpGet(const std::string& url, const char* logTag, const HttpGetOptions& opt)
|
||||
@@ -78,6 +92,7 @@ HttpGetResult httpGet(const std::string& url, const char* logTag, const HttpGetO
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &sink);
|
||||
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, opt.maxRedirects);
|
||||
restrictToHttps(curl);
|
||||
curl_easy_setopt(curl, CURLOPT_USERAGENT, "ObsidianDragon/1.0");
|
||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT, opt.timeoutSec);
|
||||
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, opt.connectTimeoutSec);
|
||||
@@ -131,6 +146,8 @@ bool httpDownloadToFile(const std::string& url, const std::string& destPath,
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writeFileCb);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, fp);
|
||||
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 5L); // bound redirects (this path set none before)
|
||||
restrictToHttps(curl);
|
||||
curl_easy_setopt(curl, CURLOPT_USERAGENT, "ObsidianDragon/1.0");
|
||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 0L);
|
||||
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 30L);
|
||||
|
||||
@@ -2031,6 +2031,11 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["console_clear"] = "Clear";
|
||||
strings_["console_clear_console"] = "Clear Console";
|
||||
strings_["console_cleared"] = "Console cleared";
|
||||
strings_["console_secret_result_hidden"] = "[result hidden — contains private key material. Use \"Copy secret\" to copy it, or it clears automatically.]";
|
||||
strings_["console_secret_result_expired"] = "[hidden secret result cleared]";
|
||||
strings_["console_copy_secret"] = "Copy secret";
|
||||
strings_["console_copy_secret_tip"] = "Copy the hidden key/seed result to the clipboard (auto-clears after ~45s).";
|
||||
strings_["console_hide_secret"] = "Hide";
|
||||
strings_["console_click_commands"] = "Click commands above to insert them";
|
||||
strings_["console_click_insert"] = "Click to insert";
|
||||
strings_["console_click_insert_params"] = "Click to insert with parameters";
|
||||
|
||||
@@ -10,6 +10,29 @@
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstdio>
|
||||
#include <cmath>
|
||||
|
||||
namespace {
|
||||
// Strictly parse a decimal DRGX amount from an (attacker-influenceable) payment URI / QR: digits with at
|
||||
// most one '.', at most 8 fractional digits, in [0, 21000000]. Rejects empty, NaN/inf, signs, whitespace
|
||||
// and trailing garbage that std::stod would otherwise accept — so a crafted URI can't inject a bogus
|
||||
// amount into the Send form.
|
||||
bool parseStrictAmount(const std::string& s, double& out) {
|
||||
if (s.empty() || s.size() > 32) return false;
|
||||
int dots = 0, digits = 0, frac = 0; bool afterDot = false;
|
||||
for (char c : s) {
|
||||
if (c == '.') { if (++dots > 1) return false; afterDot = true; }
|
||||
else if (c >= '0' && c <= '9') { ++digits; if (afterDot && ++frac > 8) return false; }
|
||||
else return false;
|
||||
}
|
||||
if (digits == 0) return false;
|
||||
double v = 0.0;
|
||||
try { v = std::stod(s); } catch (...) { return false; }
|
||||
if (!std::isfinite(v) || v < 0.0 || v > 21000000.0) return false;
|
||||
out = v;
|
||||
return true;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
namespace dragonx {
|
||||
namespace util {
|
||||
@@ -141,11 +164,13 @@ PaymentURI parsePaymentURI(const std::string& uri)
|
||||
std::transform(key.begin(), key.end(), key.begin(), ::tolower);
|
||||
|
||||
if (key == "amount" || key == "amt") {
|
||||
try {
|
||||
result.amount = std::stod(value);
|
||||
} catch (...) {
|
||||
// Invalid amount, keep as 0
|
||||
double a = 0.0;
|
||||
if (!parseStrictAmount(value, a)) {
|
||||
result.error = "Invalid amount";
|
||||
result.valid = false;
|
||||
return result; // reject the whole URI rather than silently treating it as 0
|
||||
}
|
||||
result.amount = a;
|
||||
} else if (key == "label") {
|
||||
result.label = value;
|
||||
} else if (key == "memo") {
|
||||
@@ -174,7 +199,7 @@ PaymentURI parsePaymentURI(const std::string& uri)
|
||||
}
|
||||
|
||||
// Validate amount if present
|
||||
if (result.amount < 0) {
|
||||
if (!std::isfinite(result.amount) || result.amount < 0 || result.amount > 21000000.0) {
|
||||
result.error = "Invalid negative amount";
|
||||
return result;
|
||||
}
|
||||
|
||||
+11
-1
@@ -1033,7 +1033,17 @@ void testPaymentUri()
|
||||
|
||||
auto invalid = dragonx::util::parsePaymentURI("drgx:" + taddr + "?amount=-1");
|
||||
EXPECT_FALSE(invalid.valid);
|
||||
EXPECT_EQ(invalid.error, std::string("Invalid negative amount"));
|
||||
EXPECT_EQ(invalid.error, std::string("Invalid amount")); // strict parse rejects '-' up front now
|
||||
|
||||
// Crafted amounts that std::stod would have accepted are now rejected before the Send form.
|
||||
for (const char* bad : {"nan", "inf", "1e3", "0x10", "1.5abc", "0.000000001", "21000001", ""}) {
|
||||
auto r = dragonx::util::parsePaymentURI("drgx:" + taddr + "?amount=" + bad);
|
||||
EXPECT_FALSE(r.valid);
|
||||
}
|
||||
for (const char* ok : {"1", "0.00000001", "21000000", "1.5"}) {
|
||||
auto r = dragonx::util::parsePaymentURI("drgx:" + taddr + "?amount=" + ok);
|
||||
EXPECT_TRUE(r.valid);
|
||||
}
|
||||
}
|
||||
|
||||
void testWalletFileProbe()
|
||||
|
||||
Reference in New Issue
Block a user