mirror of
https://github.com/Xahau/xahaud.git
synced 2026-08-27 18:20:53 +00:00
Compare commits
1 Commits
jsontx
...
pwabootloa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2637f6ae26 |
@@ -124,9 +124,6 @@ find_package(date REQUIRED)
|
||||
find_package(xxHash REQUIRED)
|
||||
find_package(magic_enum REQUIRED)
|
||||
|
||||
find_package(fmt REQUIRED)
|
||||
target_link_libraries(ripple_libs INTERFACE fmt::fmt)
|
||||
|
||||
include(deps/WasmEdge)
|
||||
if(TARGET nudb::core)
|
||||
set(nudb nudb::core)
|
||||
|
||||
@@ -35,7 +35,6 @@ class Xrpl(ConanFile):
|
||||
'soci/4.0.3@xahaud/stable',
|
||||
'xxhash/0.8.2',
|
||||
'zlib/1.3.1',
|
||||
'fmt/12.1.0',
|
||||
]
|
||||
|
||||
tool_requires = [
|
||||
@@ -192,7 +191,6 @@ class Xrpl(ConanFile):
|
||||
'sqlite3::sqlite',
|
||||
'xxhash::xxhash',
|
||||
'zlib::zlib',
|
||||
'fmt::fmt',
|
||||
]
|
||||
if self.options.rocksdb:
|
||||
libxrpl.requires.append('rocksdb::librocksdb')
|
||||
|
||||
100
include/xrpl/protocol/AppLoader.h
Normal file
100
include/xrpl/protocol/AppLoader.h
Normal file
@@ -0,0 +1,100 @@
|
||||
//------------------------------------------------------------------------------
|
||||
/*
|
||||
This file is part of rippled: https://github.com/ripple/rippled
|
||||
Copyright (c) 2025 XRPL Labs
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted, provided that the above
|
||||
copyright notice and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
*/
|
||||
//==============================================================================
|
||||
|
||||
#ifndef RIPPLE_PROTOCOL_APPLOADER_H_INCLUDED
|
||||
#define RIPPLE_PROTOCOL_APPLOADER_H_INCLUDED
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
namespace ripple {
|
||||
|
||||
/** Validation of the AccountRoot `AppLoader` blob (sfAppLoader).
|
||||
|
||||
The blob carries the bootstrap document of a Progressive Web App that is
|
||||
served from the account itself. Consensus cannot afford to run a real
|
||||
HTML parser, so enforcement here is deliberately *shallow*: it rejects
|
||||
payloads that plainly are not a UTF-8 HTML document, and nothing more.
|
||||
It is a sanity gate, not a conformance checker -- a blob that passes is
|
||||
not thereby guaranteed to render, and clients must still treat the
|
||||
contents as wholly untrusted input.
|
||||
|
||||
A conforming blob must:
|
||||
- be non-empty and no longer than `maxAppLoaderLength` bytes;
|
||||
- be well-formed UTF-8 (no overlongs, surrogates, or scalar values
|
||||
above U+10FFFF);
|
||||
- contain no C0 control characters other than TAB, LF and CR, and no
|
||||
NUL or DEL;
|
||||
- begin (after an optional BOM and leading whitespace) with either an
|
||||
HTML doctype or an `<html` start tag;
|
||||
- contain an `<html` start tag and a matching `</html>` end tag, in
|
||||
that order, with the end tag being the last non-whitespace content.
|
||||
*/
|
||||
namespace appLoader {
|
||||
|
||||
/** Reasons an AppLoader blob may be rejected.
|
||||
|
||||
Returned by `validate` so that callers can log something more useful
|
||||
than a bare failure. Not part of the protocol: only `ok` vs. everything
|
||||
else is consensus-relevant.
|
||||
*/
|
||||
enum class Result : std::uint8_t {
|
||||
ok = 0,
|
||||
empty, // zero-length blob
|
||||
tooLarge, // exceeds maxAppLoaderLength
|
||||
badUTF8, // malformed UTF-8 sequence
|
||||
badControlChar, // disallowed control character
|
||||
noDoctype, // does not open with a doctype or <html tag
|
||||
noHtmlElement, // missing <html ...> start tag
|
||||
unclosed, // missing </html> end tag, or it precedes <html
|
||||
trailingGarbage // non-whitespace content after </html>
|
||||
};
|
||||
|
||||
/** Validate a UTF-8 byte sequence as an AppLoader document.
|
||||
|
||||
@param data Pointer to the first byte; may be nullptr iff size is 0.
|
||||
@param size Length of the blob in bytes.
|
||||
@return Result::ok if the blob is acceptable, otherwise the first
|
||||
problem encountered.
|
||||
|
||||
@note Total: every input yields a defined result, including inputs
|
||||
whose final bytes form a truncated multi-byte sequence.
|
||||
*/
|
||||
Result
|
||||
validate(std::uint8_t const* data, std::size_t size);
|
||||
|
||||
/** Human-readable description of a validation result, for logging. */
|
||||
char const*
|
||||
to_string(Result r);
|
||||
|
||||
/** Check that a byte sequence is well-formed UTF-8.
|
||||
|
||||
Rejects overlong encodings, UTF-16 surrogate halves (U+D800..U+DFFF),
|
||||
and scalar values above U+10FFFF. Noncharacters such as U+FFFE and
|
||||
U+FFFF are accepted: they are well-formed UTF-8 and are permitted in
|
||||
interchange.
|
||||
*/
|
||||
bool
|
||||
isValidUTF8(std::uint8_t const* data, std::size_t size);
|
||||
|
||||
} // namespace appLoader
|
||||
|
||||
} // namespace ripple
|
||||
|
||||
#endif
|
||||
@@ -328,6 +328,14 @@ xChainCreateAccountClaimID(STXChainBridge const& bridge, std::uint64_t seq);
|
||||
Keylet
|
||||
did(AccountID const& account) noexcept;
|
||||
|
||||
/** An AppLoader belonging to an account.
|
||||
|
||||
There is at most one per account, so the key is derived from the owner's
|
||||
AccountID alone.
|
||||
*/
|
||||
Keylet
|
||||
appLoader(AccountID const& account) noexcept;
|
||||
|
||||
Keylet
|
||||
oracle(AccountID const& account, std::uint32_t const& documentID) noexcept;
|
||||
|
||||
@@ -402,7 +410,7 @@ struct keyletDesc
|
||||
|
||||
// This list should include all of the keylet functions that take a single
|
||||
// AccountID parameter.
|
||||
std::array<keyletDesc<AccountID const&>, 6> const directAccountKeylets{
|
||||
std::array<keyletDesc<AccountID const&>, 7> const directAccountKeylets{
|
||||
{{&keylet::account, jss::AccountRoot, false},
|
||||
{&keylet::ownerDir, jss::DirectoryNode, true},
|
||||
{&keylet::signers, jss::SignerList, true},
|
||||
@@ -410,7 +418,8 @@ std::array<keyletDesc<AccountID const&>, 6> const directAccountKeylets{
|
||||
// test it anyway, since the invariant checks for it.
|
||||
{&keylet::nftpage_min, jss::NFTokenPage, true},
|
||||
{&keylet::nftpage_max, jss::NFTokenPage, true},
|
||||
{&keylet::did, jss::DID, true}}};
|
||||
{&keylet::did, jss::DID, true},
|
||||
{&keylet::appLoader, jss::AppLoader, true}}};
|
||||
|
||||
MPTID
|
||||
makeMptID(std::uint32_t sequence, AccountID const& account);
|
||||
|
||||
@@ -1,557 +0,0 @@
|
||||
//------------------------------------------------------------------------------
|
||||
/*
|
||||
This file is part of rippled: https://github.com/ripple/rippled
|
||||
Copyright (c) 2012-2014 Ripple Labs Inc.
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted, provided that the above
|
||||
copyright notice and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
*/
|
||||
//==============================================================================
|
||||
|
||||
#ifndef RIPPLE_PROTOCOL_JSONTXSIGNATURES_H_INCLUDED
|
||||
#define RIPPLE_PROTOCOL_JSONTXSIGNATURES_H_INCLUDED
|
||||
|
||||
#include <xrpl/json/json_reader.h>
|
||||
#include <xrpl/json/json_writer.h>
|
||||
#include <xrpl/protocol/PublicKey.h>
|
||||
#include <xrpl/protocol/SField.h>
|
||||
#include <xrpl/protocol/STParsedJSON.h>
|
||||
#include <boost/algorithm/string.hpp>
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <fmt/format.h>
|
||||
#include <limits>
|
||||
#include <map>
|
||||
#include <functional>
|
||||
#include <unordered_map>
|
||||
|
||||
namespace ripple {
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// jsontx: plaintext-JSON signing support (featureJsonTx)
|
||||
//
|
||||
// The delta is attacker-controlled: it arrives over the wire beside a binary
|
||||
// transaction and is not covered by the signature it helps reconstruct. Every
|
||||
// bound below is therefore explicit, and unsanitize_jsontx accepts only the
|
||||
// exact encoding sanitize_jsontx would have produced.
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
static constexpr std::size_t jsontx_max_text = 8192; // canonical and original
|
||||
static constexpr std::size_t jsontx_max_diff = 1024; // delta bytes
|
||||
static constexpr std::size_t jsontx_max_ops = 256; // delta instructions
|
||||
static constexpr std::size_t jsontx_min_copy = 4; // encoder match threshold
|
||||
static constexpr std::size_t jsontx_max_cand = 64; // encoder candidate cap
|
||||
|
||||
// Case-insensitive field-name -> canonical SField. Built once from
|
||||
// SField::knownCodeToField, the same table doServerDefinitions publishes, using
|
||||
// its serializability filter (useful, binary, non-pseudo). sfInvalid if
|
||||
// unknown.
|
||||
static SField const&
|
||||
jsontx_field(std::string const& name)
|
||||
{
|
||||
static auto const tbl = [] {
|
||||
std::unordered_map<std::string, SField const*> m;
|
||||
for (auto const& [code, f] : SField::knownCodeToField)
|
||||
if (f->isUseful() && f->isBinary() && f->fieldType < 10000 &&
|
||||
!f->fieldName.empty())
|
||||
m.emplace(boost::algorithm::to_lower_copy(f->fieldName), f);
|
||||
return m;
|
||||
}();
|
||||
|
||||
auto const i = tbl.find(boost::algorithm::to_lower_copy(name));
|
||||
return i == tbl.end() ? sfInvalid : *i->second;
|
||||
}
|
||||
|
||||
// Civil calendar arithmetic (Howard Hinnant's algorithm), used in both
|
||||
// directions. Pure integer maths - no strptime, no timegm, no locale, no
|
||||
// tzdata - because these conversions decide whether a signature verifies and
|
||||
// so must give the same answer on every node forever.
|
||||
static constexpr std::int64_t
|
||||
jsontx_days(int y, unsigned m, unsigned d) // days from 1970-01-01
|
||||
{
|
||||
y -= m <= 2;
|
||||
std::int64_t const era = (y >= 0 ? y : y - 399) / 400;
|
||||
unsigned const yoe = static_cast<unsigned>(y - era * 400);
|
||||
unsigned const doy = (153 * (m + (m > 2 ? -3 : 9)) + 2) / 5 + d - 1;
|
||||
unsigned const doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
return era * 146097 + doe - 719468;
|
||||
}
|
||||
|
||||
static constexpr std::int64_t jsontx_epoch_day = jsontx_days(2000, 1, 1);
|
||||
|
||||
// 9999-12-31T23:59:59.999Z: past this toISOString() switches to expanded years
|
||||
// (+275760-09-13T...) and the fixed 24 character shape no longer holds
|
||||
static constexpr std::uint64_t jsontx_max_time =
|
||||
(static_cast<std::uint64_t>(jsontx_days(9999, 12, 31) - jsontx_epoch_day) *
|
||||
86400 +
|
||||
86399) *
|
||||
1000 +
|
||||
999;
|
||||
|
||||
static_assert(jsontx_epoch_day == 10957); // matches chrono.h epoch_offset
|
||||
|
||||
// Strict Date().toISOString() -> milliseconds since the ripple epoch. Exactly
|
||||
// YYYY-MM-DDTHH:MM:SS.sssZ, always UTC, always three fractional digits.
|
||||
static std::uint64_t
|
||||
jsontx_iso(std::string const& s)
|
||||
{
|
||||
static constexpr char pat[] = "0000-00-00T00:00:00.000Z";
|
||||
if (s.size() != 24)
|
||||
throw std::runtime_error("jsontx: Time must be an ISO 8601 instant");
|
||||
for (std::size_t i = 0; i < 24; ++i)
|
||||
if (pat[i] == '0' ? !std::isdigit(static_cast<unsigned char>(s[i]))
|
||||
: s[i] != pat[i])
|
||||
throw std::runtime_error("jsontx: malformed Time");
|
||||
|
||||
auto const n = [&s](std::size_t i, std::size_t c) {
|
||||
int v = 0;
|
||||
while (c--)
|
||||
v = v * 10 + (s[i++] - '0');
|
||||
return v;
|
||||
};
|
||||
int const y = n(0, 4), mo = n(5, 2), d = n(8, 2), h = n(11, 2),
|
||||
mi = n(14, 2), se = n(17, 2), ms = n(20, 3);
|
||||
if (mo < 1 || mo > 12)
|
||||
throw std::runtime_error("jsontx: Time month out of range");
|
||||
bool const leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;
|
||||
int const dim =
|
||||
mo == 2 ? (leap ? 29 : 28) : ((mo % 2 == 1) == (mo <= 7) ? 31 : 30);
|
||||
// 60 is rejected: JS cannot emit a leap second and the ledger cannot
|
||||
// represent one
|
||||
if (d < 1 || d > dim || h > 23 || mi > 59 || se > 59)
|
||||
throw std::runtime_error("jsontx: Time out of range");
|
||||
|
||||
std::int64_t const t = (jsontx_days(y, mo, d) - jsontx_epoch_day) * 86400 +
|
||||
h * 3600 + mi * 60 + se;
|
||||
if (t < 0)
|
||||
throw std::runtime_error("jsontx: Time precedes the ripple epoch");
|
||||
return static_cast<std::uint64_t>(t) * 1000 + ms;
|
||||
}
|
||||
|
||||
// The exact inverse. Total over [0, jsontx_max_time] and injective, so sfTime
|
||||
// and its ISO spelling are two views of one value and the delta carries
|
||||
// nothing for the field.
|
||||
static std::string
|
||||
jsontx_iso_str(std::uint64_t ms)
|
||||
{
|
||||
if (ms > jsontx_max_time)
|
||||
throw std::runtime_error("jsontx: Time out of range");
|
||||
std::int64_t const z =
|
||||
static_cast<std::int64_t>(ms / 86400000) + jsontx_epoch_day + 719468;
|
||||
unsigned const tod = static_cast<unsigned>(ms / 1000 % 86400);
|
||||
std::int64_t const era = (z >= 0 ? z : z - 146096) / 146097;
|
||||
unsigned const doe = static_cast<unsigned>(z - era * 146097);
|
||||
unsigned const yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
||||
unsigned const doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
unsigned const mp = (5 * doy + 2) / 153;
|
||||
unsigned const d = doy - (153 * mp + 2) / 5 + 1;
|
||||
unsigned const m = mp + (mp < 10 ? 3 : -9);
|
||||
return fmt::format(
|
||||
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:03}Z",
|
||||
static_cast<std::int64_t>(yoe) + era * 400 + (m <= 2),
|
||||
m,
|
||||
d,
|
||||
tod / 3600,
|
||||
tod / 60 % 60,
|
||||
tod % 60,
|
||||
static_cast<unsigned>(ms % 1000));
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// jsoncpp compatibility
|
||||
//
|
||||
// This is xrpl's vendored jsoncpp, which predates JSON_HAS_INT64: Json::Int is
|
||||
// int and Json::UInt is unsigned int, both 32 bit, and there is no isInt64 /
|
||||
// asInt64 / asUInt64. Reader::decodeNumber yields intValue or uintValue only
|
||||
// while the digits fit in 32 bits; on overflow, and for any token carrying a
|
||||
// '.' or an exponent, it falls through to decodeDouble and the number arrives
|
||||
// as a realValue.
|
||||
//
|
||||
// So a large integer is not lost, but it is no longer held as an integer, and
|
||||
// the digits the signer wrote are recoverable only while the double is an
|
||||
// exact integer view of them. That holds to 2^53; above it consecutive doubles
|
||||
// are more than 1 apart and distinct decimal integers collapse onto the same
|
||||
// double. Past that point the value is refused rather than guessed at.
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
static constexpr double jsontx_exact_max = 9007199254740992.0; // 2^53
|
||||
|
||||
// Exact integer view of a json number. False if v is not a number at all, or
|
||||
// is one this build cannot reproduce digit for digit.
|
||||
static bool
|
||||
jsontx_exact(Json::Value const& v, std::int64_t& out)
|
||||
{
|
||||
switch (v.type())
|
||||
{
|
||||
case Json::intValue:
|
||||
out = v.asInt();
|
||||
return true;
|
||||
case Json::uintValue:
|
||||
out = static_cast<std::int64_t>(v.asUInt());
|
||||
return true;
|
||||
case Json::realValue:
|
||||
break;
|
||||
default: // including booleanValue, which isIntegral() would admit
|
||||
return false;
|
||||
}
|
||||
double const d = v.asDouble();
|
||||
if (!std::isfinite(d) || d != std::trunc(d) || d < -jsontx_exact_max ||
|
||||
d > jsontx_exact_max)
|
||||
return false;
|
||||
out = static_cast<std::int64_t>(d);
|
||||
return true;
|
||||
}
|
||||
|
||||
// STI_UINT64 renders as a fixed 16 digit hex string and so needs the value
|
||||
// unsigned. A negative is refused rather than wrapped: the old
|
||||
// static_cast<std::uint64_t>(v.asDouble()) was undefined for a negative or
|
||||
// oversized double, and a silent wrap would let -1 and 18446744073709551615
|
||||
// canonicalize to the same bytes.
|
||||
static std::uint64_t
|
||||
jsontx_u64(Json::Value const& v)
|
||||
{
|
||||
std::int64_t n = 0;
|
||||
if (!jsontx_exact(v, n) || n < 0)
|
||||
throw std::runtime_error(
|
||||
"jsontx: UInt64 must be an exact non-negative integer");
|
||||
return static_cast<std::uint64_t>(n);
|
||||
}
|
||||
|
||||
// Renders a javascript number as an exact integer. Anything this build cannot
|
||||
// reproduce digit for digit - a fraction, an infinity, a magnitude past 2^53 -
|
||||
// is rejected outright: shortest-round-trip rendering of a double is not
|
||||
// portable enough to sit in a consensus preimage, and nothing in a transaction
|
||||
// needs one. Fractional amounts arrive as strings, which is what the ledger
|
||||
// wants anyway.
|
||||
static std::string
|
||||
jsontx_num(Json::Value const& v)
|
||||
{
|
||||
std::int64_t n = 0;
|
||||
if (!jsontx_exact(v, n))
|
||||
throw std::runtime_error("jsontx: number must be an exact integer");
|
||||
return std::to_string(n);
|
||||
}
|
||||
|
||||
// Returns { sanitized, diff }. `sanitized` is the canonical form: whitespace
|
||||
// stripped, field names capitalized to their xahau spelling, members reordered
|
||||
// by field code, numbers reformatted per field type. `diff` is a binary delta
|
||||
// which, applied to `sanitized` by unsanitize_jsontx, reproduces `raw` byte for
|
||||
// byte. Throws on anything it cannot canonicalize.
|
||||
static std::pair<std::string, std::string>
|
||||
sanitize_jsontx(std::string_view raw)
|
||||
{
|
||||
if (raw.size() > jsontx_max_text)
|
||||
throw std::runtime_error("jsontx: document too large");
|
||||
|
||||
Json::Value jv;
|
||||
if (Json::Reader r; !r.parse(raw.data(), raw.data() + raw.size(), jv) ||
|
||||
!jv.isObject())
|
||||
throw std::runtime_error("jsontx: malformed json");
|
||||
|
||||
// (a plain recursive lambda; deducing-this would drop the std::function)
|
||||
std::function<
|
||||
void(Json::Value const&, SerializedTypeID, SField const*, std::string&)>
|
||||
emit = [&](Json::Value const& v,
|
||||
SerializedTypeID ty,
|
||||
SField const* fld,
|
||||
std::string& o) {
|
||||
if (v.isObject())
|
||||
{
|
||||
auto keys = v.getMemberNames();
|
||||
o += '{';
|
||||
if (ty == STI_OBJECT) // keys are xahau fields
|
||||
{
|
||||
std::vector<std::pair<SField const*, std::string>> ks;
|
||||
for (auto const& k : keys)
|
||||
{
|
||||
auto const& f = jsontx_field(k);
|
||||
if (f == sfInvalid)
|
||||
throw std::runtime_error(
|
||||
"jsontx: unknown field '" + k + "'");
|
||||
ks.emplace_back(&f, k);
|
||||
}
|
||||
std::sort(
|
||||
ks.begin(), ks.end(), [](auto const& a, auto const& b) {
|
||||
return a.first->fieldCode < b.first->fieldCode;
|
||||
});
|
||||
for (std::size_t n = 0; n < ks.size(); ++n)
|
||||
{
|
||||
auto const& [f, k] = ks[n];
|
||||
if (n && f == ks[n - 1].first) // e.g. "Fee" and "fee"
|
||||
throw std::runtime_error(
|
||||
"jsontx: duplicate field '" + f->fieldName +
|
||||
"'");
|
||||
if (o.back() != '{')
|
||||
o += ',';
|
||||
o += Json::valueToQuotedString(f->fieldName.c_str()) +
|
||||
':';
|
||||
emit(v[k], f->fieldType, f, o);
|
||||
}
|
||||
}
|
||||
else // amount / issue style subobject: lexicographic, quoted
|
||||
{
|
||||
std::sort(keys.begin(), keys.end());
|
||||
for (auto const& k : keys)
|
||||
{
|
||||
if (o.back() != '{')
|
||||
o += ',';
|
||||
o += Json::valueToQuotedString(k.c_str()) + ':';
|
||||
emit(v[k], STI_NOTPRESENT, nullptr, o);
|
||||
}
|
||||
}
|
||||
o += '}';
|
||||
}
|
||||
else if (v.isArray())
|
||||
{
|
||||
o += '[';
|
||||
for (auto const& e : v)
|
||||
{
|
||||
if (o.back() != '[')
|
||||
o += ',';
|
||||
emit(e, STI_OBJECT, nullptr, o);
|
||||
}
|
||||
o += ']';
|
||||
}
|
||||
else if (v.isString())
|
||||
{
|
||||
// Time is spelled Date().toISOString() in the preimage and
|
||||
// stored as an sfTime u64 of milliseconds. Re-emitting the
|
||||
// round-tripped spelling rather than the input is what makes
|
||||
// the canonical form a fixed point: any string that is not
|
||||
// exactly what jsontx_iso_str produces is rejected here.
|
||||
if (fld && *fld == sfTime)
|
||||
o += Json::valueToQuotedString(
|
||||
jsontx_iso_str(jsontx_iso(v.asString())).c_str());
|
||||
else
|
||||
o += Json::valueToQuotedString(v.asCString());
|
||||
}
|
||||
else if (v.isBool())
|
||||
o += v.asBool() ? "true" : "false";
|
||||
else if (v.isNull())
|
||||
throw std::runtime_error("jsontx: null value");
|
||||
else if (ty == STI_UINT8 || ty == STI_UINT16 || ty == STI_UINT32)
|
||||
o += jsontx_num(v); // small ints stay bare
|
||||
else if (ty == STI_UINT64)
|
||||
o += '"' + fmt::format("{:016X}", jsontx_u64(v)) + '"';
|
||||
else // amounts, u64, everything else the ledger wants as a string
|
||||
o += Json::valueToQuotedString(jsontx_num(v).c_str());
|
||||
};
|
||||
|
||||
std::string out;
|
||||
emit(jv, STI_OBJECT, nullptr, out);
|
||||
|
||||
// Greedy copy/insert delta over `raw`, sourcing from `out`.
|
||||
// op 0x00 <varint len> <bytes> literal
|
||||
// op 0x01 <varint off> <varint len> copy from sanitized
|
||||
std::string diff, lit;
|
||||
auto const gram = [](std::string_view s, std::size_t i) {
|
||||
return std::uint32_t(std::uint8_t(s[i])) << 24 |
|
||||
std::uint32_t(std::uint8_t(s[i + 1])) << 16 |
|
||||
std::uint32_t(std::uint8_t(s[i + 2])) << 8 |
|
||||
std::uint32_t(std::uint8_t(s[i + 3]));
|
||||
};
|
||||
auto const varint = [](std::string& o, std::uint64_t v) {
|
||||
do
|
||||
{
|
||||
std::uint8_t const c = v & 0x7F;
|
||||
v >>= 7;
|
||||
o += static_cast<char>(c | (v ? 0x80 : 0));
|
||||
} while (v);
|
||||
};
|
||||
auto const flush = [&] {
|
||||
if (lit.empty())
|
||||
return;
|
||||
diff += char(0);
|
||||
varint(diff, lit.size());
|
||||
diff += lit;
|
||||
lit.clear();
|
||||
};
|
||||
|
||||
// This encoder is normative - unsanitize_jsontx only accepts its exact
|
||||
// output - so it must emit identical bytes on every node and every stdlib.
|
||||
// An unordered container would not: bucket order for equal keys is
|
||||
// unspecified, and with a candidate cap that changes which match wins.
|
||||
std::map<std::uint32_t, std::vector<std::uint32_t>> idx;
|
||||
for (std::size_t i = 0; i + jsontx_min_copy <= out.size(); ++i)
|
||||
idx[gram(out, i)].push_back(i);
|
||||
|
||||
for (std::size_t i = 0; i < raw.size();)
|
||||
{
|
||||
std::size_t bo = 0, bl = 0;
|
||||
if (i + jsontx_min_copy <= raw.size())
|
||||
if (auto const it = idx.find(gram(raw, i)); it != idx.end())
|
||||
{
|
||||
std::size_t tried = 0;
|
||||
for (auto const off : it->second) // ascending, so ties keep
|
||||
{ // the lowest offset
|
||||
if (++tried > jsontx_max_cand)
|
||||
break;
|
||||
std::size_t l = 0;
|
||||
while (i + l < raw.size() && off + l < out.size() &&
|
||||
out[off + l] == raw[i + l])
|
||||
++l;
|
||||
if (l > bl)
|
||||
bl = l, bo = off;
|
||||
}
|
||||
}
|
||||
if (bl >= jsontx_min_copy)
|
||||
{
|
||||
flush();
|
||||
diff += char(1);
|
||||
varint(diff, bo);
|
||||
varint(diff, bl);
|
||||
i += bl;
|
||||
}
|
||||
else
|
||||
lit += raw[i++];
|
||||
}
|
||||
flush();
|
||||
|
||||
return {std::move(out), std::move(diff)};
|
||||
}
|
||||
|
||||
// Applies an UNTRUSTED delta to a canonical form the node derived itself.
|
||||
// Copies read only from `sanitized`, never from the output being built, so a
|
||||
// short delta cannot expand geometrically. Offsets and lengths are range
|
||||
// checked before use, varints are length- and minimality-bounded, and the two
|
||||
// encodings the encoder can never emit - an unmerged literal run, and a copy
|
||||
// abutting the previous copy in the source - are rejected. Throws on anything
|
||||
// else.
|
||||
static std::string
|
||||
unsanitize_jsontx(std::string_view sanitized, std::string_view diff)
|
||||
{
|
||||
if (sanitized.size() > jsontx_max_text || diff.size() > jsontx_max_diff)
|
||||
throw std::runtime_error("jsontx: oversize delta input");
|
||||
|
||||
std::string out;
|
||||
std::size_t p = 0, ops = 0, prevEnd = 0;
|
||||
int prev = -1;
|
||||
|
||||
auto const varint = [&](std::uint64_t max) -> std::uint64_t {
|
||||
std::uint64_t v = 0;
|
||||
for (int s = 0; s <= 21; s += 7) // four bytes; caps far under a shift
|
||||
{ // wide enough to be undefined
|
||||
if (p >= diff.size())
|
||||
throw std::runtime_error("jsontx: truncated delta");
|
||||
std::uint8_t const c = diff[p++];
|
||||
v |= std::uint64_t(c & 0x7F) << s;
|
||||
if (c & 0x80)
|
||||
continue;
|
||||
if (s && !(c & 0x7F))
|
||||
throw std::runtime_error("jsontx: non-minimal varint");
|
||||
if (v > max)
|
||||
throw std::runtime_error("jsontx: delta value out of range");
|
||||
return v;
|
||||
}
|
||||
throw std::runtime_error("jsontx: overlong varint");
|
||||
};
|
||||
|
||||
while (p < diff.size())
|
||||
{
|
||||
if (++ops > jsontx_max_ops)
|
||||
throw std::runtime_error("jsontx: too many delta ops");
|
||||
|
||||
std::uint8_t const op = diff[p++];
|
||||
if (op > 1)
|
||||
throw std::runtime_error("jsontx: unknown delta op");
|
||||
|
||||
std::size_t n = 0;
|
||||
if (op == 0) // literal
|
||||
{
|
||||
if (prev == 0)
|
||||
throw std::runtime_error("jsontx: unmerged literal run");
|
||||
n = varint(jsontx_max_text);
|
||||
if (n == 0 || n > diff.size() - p)
|
||||
throw std::runtime_error("jsontx: bad literal length");
|
||||
if (out.size() + n > jsontx_max_text)
|
||||
throw std::runtime_error("jsontx: delta expands too far");
|
||||
out += diff.substr(p, n);
|
||||
p += n;
|
||||
}
|
||||
else // copy from the canonical form
|
||||
{
|
||||
auto const off = varint(sanitized.size());
|
||||
n = varint(sanitized.size() - off);
|
||||
if (n < jsontx_min_copy)
|
||||
throw std::runtime_error("jsontx: undersize copy");
|
||||
if (prev == 1 && off == prevEnd)
|
||||
throw std::runtime_error("jsontx: unmerged copy run");
|
||||
if (out.size() + n > jsontx_max_text)
|
||||
throw std::runtime_error("jsontx: delta expands too far");
|
||||
out += sanitized.substr(off, n);
|
||||
prevEnd = off + n;
|
||||
}
|
||||
prev = op;
|
||||
}
|
||||
|
||||
if (out.empty())
|
||||
throw std::runtime_error("jsontx: empty delta");
|
||||
return out;
|
||||
}
|
||||
|
||||
// The complete untrusted-side check, in one place so the RPC path and the
|
||||
// relay/consensus path cannot drift. Takes the transaction exactly as it came
|
||||
// off the wire and returns the reconstructed preimage.
|
||||
static std::string
|
||||
jsontx_verify(STTx const& stx, std::string_view diff)
|
||||
{
|
||||
if (!stx.isFieldPresent(sfTxnSignature) || stx.isFieldPresent(sfSigners))
|
||||
throw std::runtime_error("jsontx: expects a lone TxnSignature");
|
||||
|
||||
// Out comes everything the signer did not have in front of them: the
|
||||
// signature, and (once the field exists) the delta carrier. SigningPubKey
|
||||
// stays. It being inside the preimage is what binds key to signature and
|
||||
// stops a third party re-signing a captured preimage under their own key.
|
||||
auto txj = stx.STObject::getJson(JsonOptions::none);
|
||||
txj.removeMember(sfTxnSignature.fieldName);
|
||||
|
||||
// sfTime is a u64 of milliseconds on the wire and an ISO 8601 instant in
|
||||
// the preimage. The two are a bijection over the representable range, so
|
||||
// this is a rewrite rather than a reconstruction and the delta carries
|
||||
// nothing for the field.
|
||||
if (stx.isFieldPresent(sfTime))
|
||||
txj[sfTime.fieldName] = jsontx_iso_str(stx.getFieldU64(sfTime));
|
||||
|
||||
auto const pkb = stx.getSigningPubKey();
|
||||
if (publicKeyType(makeSlice(pkb)) != KeyType::ed25519)
|
||||
throw std::runtime_error("jsontx: SigningPubKey must be ed25519");
|
||||
|
||||
// canonical form, derived only from data the node has already validated
|
||||
auto const san = sanitize_jsontx(Json::FastWriter{}.write(txj)).first;
|
||||
|
||||
// reconstruct the signed preimage under the caps above
|
||||
auto const raw = unsanitize_jsontx(san, diff);
|
||||
|
||||
// Bind the preimage to the transaction. This is the load-bearing check,
|
||||
// not a sanity check: a delta of pure literals can reconstruct ANY text,
|
||||
// so without it any ed25519 signature the key ever produced over anything
|
||||
// at all would authorise this transaction. Comparing the delta too - not
|
||||
// just the canonical form - makes the delta a pure function of the
|
||||
// preimage, which rules out a second delta reconstructing the same bytes
|
||||
// and yielding a second valid transaction id.
|
||||
auto const [san2, diff2] = sanitize_jsontx(raw);
|
||||
if (san2 != san || diff2 != diff)
|
||||
throw std::runtime_error("jsontx: preimage does not match transaction");
|
||||
|
||||
if (!verify(
|
||||
PublicKey(makeSlice(pkb)),
|
||||
makeSlice(raw),
|
||||
makeSlice(stx.getFieldVL(sfTxnSignature))))
|
||||
throw std::runtime_error("jsontx: signature does not verify");
|
||||
|
||||
return raw;
|
||||
}
|
||||
|
||||
} // namespace ripple
|
||||
#endif
|
||||
@@ -96,6 +96,25 @@ std::size_t constexpr maxDIDAttestationLength = 256;
|
||||
/** The maximum length of a domain */
|
||||
std::size_t constexpr maxDomainLength = 256;
|
||||
|
||||
/** The maximum length of an AppLoader document.
|
||||
|
||||
The AppLoader holds the bootstrap document of a Progressive Web App
|
||||
served from an account. It is deliberately capped: the blob lives in a
|
||||
ledger object that carries no owner reserve at all, so the only things
|
||||
standing between it and unbounded ledger growth are this limit and the
|
||||
per-byte transaction fee below.
|
||||
*/
|
||||
std::size_t constexpr maxAppLoaderLength = 4096;
|
||||
|
||||
/** Drops of transaction fee charged per byte of AppLoader document.
|
||||
|
||||
Storage is paid for through the fee rather than through an owner
|
||||
reserve, so that a loader can be published by an account sitting at its
|
||||
reserve floor. The fee is charged on every AccountSet that carries the
|
||||
field, including overwrites.
|
||||
*/
|
||||
std::uint64_t constexpr appLoaderFeeDropsPerByte = 1;
|
||||
|
||||
/** The maximum length of a URI inside a Credential */
|
||||
std::size_t constexpr maxCredentialURILength = 256;
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
// If you add an amendment here, then do not forget to increment `numFeatures`
|
||||
// in include/xrpl/protocol/Feature.h.
|
||||
|
||||
XRPL_FEATURE(JsonTx, Supported::yes, VoteBehavior::DefaultNo)
|
||||
XRPL_FEATURE(PWALoader, Supported::yes, VoteBehavior::DefaultNo)
|
||||
XRPL_FIX (HookMap, Supported::yes, VoteBehavior::DefaultYes)
|
||||
XRPL_FIX (GuardDepth32, Supported::yes, VoteBehavior::DefaultNo)
|
||||
XRPL_FEATURE(NamedHooks, Supported::yes, VoteBehavior::DefaultNo)
|
||||
|
||||
@@ -223,6 +223,29 @@ LEDGER_ENTRY(ltURI_TOKEN, 0x0055, URIToken, uri_token, ({
|
||||
{sfPreviousTxnLgrSeq, soeREQUIRED},
|
||||
}))
|
||||
|
||||
/** A ledger object holding the bootstrap document of a Progressive Web App.
|
||||
|
||||
There is at most one of these per account, keyed on the owner's AccountID.
|
||||
It is linked directly from the owning AccountRoot via sfAppLoaderID and is
|
||||
deliberately NOT placed in the owner directory: it takes no owner reserve
|
||||
and contributes nothing to OwnerCount, because its storage is paid for by
|
||||
a per-byte fee on the AccountSet that publishes it.
|
||||
|
||||
Two consequences follow from staying out of the directory. It is invisible
|
||||
to account_objects, so it must be reached via sfAppLoaderID (or by
|
||||
recomputing the keylet). And DeleteAccount's directory walk cannot find
|
||||
it, so DeleteAccount erases it explicitly; without that, account deletion
|
||||
would orphan it and trip AccountRootsDeletedClean.
|
||||
|
||||
\sa keylet::appLoader
|
||||
*/
|
||||
LEDGER_ENTRY(ltAPP_LOADER, 'L', AppLoader, app_loader, ({
|
||||
{sfOwner, soeREQUIRED},
|
||||
{sfAppLoader, soeREQUIRED},
|
||||
{sfPreviousTxnID, soeREQUIRED},
|
||||
{sfPreviousTxnLgrSeq, soeREQUIRED},
|
||||
}))
|
||||
|
||||
/** A ledger object which describes an account.
|
||||
|
||||
\sa keylet::account
|
||||
@@ -262,6 +285,7 @@ LEDGER_ENTRY(ltACCOUNT_ROOT, 0x0061, AccountRoot, account, ({
|
||||
{sfHookStateScale, soeOPTIONAL},
|
||||
{sfCron, soeOPTIONAL},
|
||||
{sfAMMID, soeOPTIONAL},
|
||||
{sfAppLoaderID, soeOPTIONAL},
|
||||
}))
|
||||
|
||||
/** A ledger object which contains a list of object identifiers.
|
||||
|
||||
@@ -153,7 +153,6 @@ TYPED_SFIELD(sfOutstandingAmount, UINT64, 25, SField::sMD_BaseTen|SFie
|
||||
TYPED_SFIELD(sfMPTAmount, UINT64, 26, SField::sMD_BaseTen|SField::sMD_Default)
|
||||
TYPED_SFIELD(sfIssuerNode, UINT64, 27)
|
||||
TYPED_SFIELD(sfSubjectNode, UINT64, 28)
|
||||
TYPED_SFIELD(sfTime, UINT64, 96)
|
||||
TYPED_SFIELD(sfTouchCount, UINT64, 97)
|
||||
TYPED_SFIELD(sfAccountIndex, UINT64, 98)
|
||||
TYPED_SFIELD(sfAccountCount, UINT64, 99)
|
||||
@@ -211,6 +210,7 @@ TYPED_SFIELD(sfOfferID, UINT256, 34)
|
||||
TYPED_SFIELD(sfEscrowID, UINT256, 35)
|
||||
TYPED_SFIELD(sfURITokenID, UINT256, 36)
|
||||
TYPED_SFIELD(sfDomainID, UINT256, 37)
|
||||
TYPED_SFIELD(sfAppLoaderID, UINT256, 92)
|
||||
TYPED_SFIELD(sfHookOnOutgoing, UINT256, 93)
|
||||
TYPED_SFIELD(sfHookOnIncoming, UINT256, 94)
|
||||
TYPED_SFIELD(sfCron, UINT256, 95)
|
||||
@@ -294,7 +294,7 @@ TYPED_SFIELD(sfAssetClass, VL, 29)
|
||||
TYPED_SFIELD(sfProvider, VL, 30)
|
||||
TYPED_SFIELD(sfMPTokenMetadata, VL, 31)
|
||||
TYPED_SFIELD(sfCredentialType, VL, 32)
|
||||
TYPED_SFIELD(sfJsonTxDelta, VL, 96)
|
||||
TYPED_SFIELD(sfAppLoader, VL, 96)
|
||||
TYPED_SFIELD(sfHookName, VL, 97)
|
||||
TYPED_SFIELD(sfRemarkValue, VL, 98)
|
||||
TYPED_SFIELD(sfRemarkName, VL, 99)
|
||||
|
||||
@@ -74,6 +74,7 @@ TRANSACTION(ttACCOUNT_SET, 3, AccountSet, ({
|
||||
{sfTickSize, soeOPTIONAL},
|
||||
{sfNFTokenMinter, soeOPTIONAL},
|
||||
{sfHookStateScale, soeOPTIONAL},
|
||||
{sfAppLoader, soeOPTIONAL},
|
||||
}))
|
||||
|
||||
/** This transaction type cancels an existing escrow. */
|
||||
|
||||
@@ -630,7 +630,6 @@ JSS(server_status); // out: NetworkOPs
|
||||
JSS(server_version); // out: NetworkOPs
|
||||
JSS(settle_delay); // out: AccountChannels
|
||||
JSS(severity); // in: LogLevel
|
||||
JSS(sig);
|
||||
JSS(signature); // out: NetworkOPs, ChannelAuthorize
|
||||
JSS(signature_verified); // out: ChannelVerify
|
||||
JSS(signing_key); // out: NetworkOPs
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
#include <xrpl/json/Output.h>
|
||||
#include <xrpl/json/json_value.h>
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace ripple {
|
||||
|
||||
void
|
||||
@@ -30,7 +33,9 @@ HTTPReply(
|
||||
int nStatus,
|
||||
std::string const& strMsg,
|
||||
Json::Output const&,
|
||||
beast::Journal j);
|
||||
beast::Journal j,
|
||||
std::string const& contentType = "application/json; charset=UTF-8",
|
||||
std::vector<std::string> const& extraHeaders = {});
|
||||
|
||||
} // namespace ripple
|
||||
|
||||
|
||||
286
src/libxrpl/protocol/AppLoader.cpp
Normal file
286
src/libxrpl/protocol/AppLoader.cpp
Normal file
@@ -0,0 +1,286 @@
|
||||
//------------------------------------------------------------------------------
|
||||
/*
|
||||
This file is part of rippled: https://github.com/ripple/rippled
|
||||
Copyright (c) 2025 XRPL Labs
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted, provided that the above
|
||||
copyright notice and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
*/
|
||||
//==============================================================================
|
||||
|
||||
#include <xrpl/protocol/AppLoader.h>
|
||||
#include <xrpl/protocol/Protocol.h>
|
||||
|
||||
#include <string_view>
|
||||
|
||||
namespace ripple {
|
||||
namespace appLoader {
|
||||
|
||||
namespace {
|
||||
|
||||
// HTML "ASCII whitespace" per the WHATWG spec: TAB, LF, FF, CR, SPACE.
|
||||
inline bool
|
||||
isSpace(std::uint8_t c) noexcept
|
||||
{
|
||||
return c == 0x09 || c == 0x0A || c == 0x0C || c == 0x0D || c == 0x20;
|
||||
}
|
||||
|
||||
// Control characters we refuse to store. Everything in C0 that is not HTML
|
||||
// whitespace, plus DEL. These have no business in a loader document and
|
||||
// their presence is a strong smell of a non-HTML payload being smuggled
|
||||
// through as "text".
|
||||
inline bool
|
||||
isForbiddenControl(std::uint8_t c) noexcept
|
||||
{
|
||||
if (isSpace(c))
|
||||
return false;
|
||||
return c < 0x20 || c == 0x7F;
|
||||
}
|
||||
|
||||
inline std::uint8_t
|
||||
asciiLower(std::uint8_t c) noexcept
|
||||
{
|
||||
return (c >= 'A' && c <= 'Z') ? static_cast<std::uint8_t>(c + 32) : c;
|
||||
}
|
||||
|
||||
// True if `lit` (which must be lowercase ASCII) occurs at `pos`, compared
|
||||
// case-insensitively. A match that would run past the end is no match.
|
||||
bool
|
||||
matchAt(
|
||||
std::uint8_t const* data,
|
||||
std::size_t size,
|
||||
std::size_t pos,
|
||||
std::string_view lit) noexcept
|
||||
{
|
||||
if (pos > size || size - pos < lit.size())
|
||||
return false;
|
||||
for (std::size_t i = 0; i < lit.size(); ++i)
|
||||
if (asciiLower(data[pos + i]) != static_cast<std::uint8_t>(lit[i]))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Index of the first case-insensitive occurrence of `lit` at or after
|
||||
// `from`, or `size` if absent.
|
||||
std::size_t
|
||||
findFrom(
|
||||
std::uint8_t const* data,
|
||||
std::size_t size,
|
||||
std::size_t from,
|
||||
std::string_view lit) noexcept
|
||||
{
|
||||
if (lit.empty() || lit.size() > size)
|
||||
return size;
|
||||
for (std::size_t i = from; i + lit.size() <= size; ++i)
|
||||
if (matchAt(data, size, i, lit))
|
||||
return i;
|
||||
return size;
|
||||
}
|
||||
|
||||
// A tag name must be terminated by whitespace, '>' or '/', otherwise
|
||||
// "<htmlish>" would be mistaken for "<html>".
|
||||
inline bool
|
||||
isTagNameEnd(std::uint8_t c) noexcept
|
||||
{
|
||||
return isSpace(c) || c == '>' || c == '/';
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool
|
||||
isValidUTF8(std::uint8_t const* data, std::size_t size)
|
||||
{
|
||||
// Table-free decoder in the style of Markus Kuhn's utf8_check.c. Each
|
||||
// branch establishes that the whole sequence is present before reading
|
||||
// any continuation byte, so a truncated tail is simply invalid.
|
||||
std::size_t i = 0;
|
||||
while (i < size)
|
||||
{
|
||||
std::uint8_t const c0 = data[i];
|
||||
|
||||
if (c0 < 0x80)
|
||||
{
|
||||
// 0xxxxxxx
|
||||
++i;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((c0 & 0xE0) == 0xC0)
|
||||
{
|
||||
// 110xxxxx 10xxxxxx
|
||||
if (size - i < 2)
|
||||
return false;
|
||||
if ((data[i + 1] & 0xC0) != 0x80)
|
||||
return false;
|
||||
if ((c0 & 0xFE) == 0xC0) // overlong
|
||||
return false;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((c0 & 0xF0) == 0xE0)
|
||||
{
|
||||
// 1110xxxx 10xxxxxx 10xxxxxx
|
||||
if (size - i < 3)
|
||||
return false;
|
||||
std::uint8_t const c1 = data[i + 1];
|
||||
if ((c1 & 0xC0) != 0x80 || (data[i + 2] & 0xC0) != 0x80)
|
||||
return false;
|
||||
if (c0 == 0xE0 && (c1 & 0xE0) == 0x80) // overlong
|
||||
return false;
|
||||
if (c0 == 0xED && (c1 & 0xE0) == 0xA0) // UTF-16 surrogate
|
||||
return false;
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((c0 & 0xF8) == 0xF0)
|
||||
{
|
||||
// 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
|
||||
if (size - i < 4)
|
||||
return false;
|
||||
std::uint8_t const c1 = data[i + 1];
|
||||
if ((c1 & 0xC0) != 0x80 || (data[i + 2] & 0xC0) != 0x80 ||
|
||||
(data[i + 3] & 0xC0) != 0x80)
|
||||
return false;
|
||||
if (c0 == 0xF0 && (c1 & 0xF0) == 0x80) // overlong
|
||||
return false;
|
||||
if (c0 > 0xF4 || (c0 == 0xF4 && c1 > 0x8F)) // > U+10FFFF
|
||||
return false;
|
||||
i += 4;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Stray continuation byte or 0xF8..0xFF.
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Result
|
||||
validate(std::uint8_t const* data, std::size_t size)
|
||||
{
|
||||
if (size == 0 || data == nullptr)
|
||||
return Result::empty;
|
||||
|
||||
if (size > maxAppLoaderLength)
|
||||
return Result::tooLarge;
|
||||
|
||||
if (!isValidUTF8(data, size))
|
||||
return Result::badUTF8;
|
||||
|
||||
for (std::size_t i = 0; i < size; ++i)
|
||||
if (isForbiddenControl(data[i]))
|
||||
return Result::badControlChar;
|
||||
|
||||
// Skip an optional UTF-8 BOM, then any leading whitespace.
|
||||
std::size_t begin = 0;
|
||||
if (size >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF)
|
||||
begin = 3;
|
||||
while (begin < size && isSpace(data[begin]))
|
||||
++begin;
|
||||
|
||||
if (begin == size)
|
||||
return Result::empty;
|
||||
|
||||
// The document must open with a doctype or with the <html> tag itself.
|
||||
// We accept "<!doctype" followed by whitespace, because that is the
|
||||
// only doctype form any browser treats as standards mode for HTML.
|
||||
bool const opensWithDoctype = matchAt(data, size, begin, "<!doctype") &&
|
||||
begin + 9 < size && isSpace(data[begin + 9]);
|
||||
|
||||
bool const opensWithHtml = matchAt(data, size, begin, "<html") &&
|
||||
begin + 5 < size && isTagNameEnd(data[begin + 5]);
|
||||
|
||||
if (!opensWithDoctype && !opensWithHtml)
|
||||
return Result::noDoctype;
|
||||
|
||||
// Locate the <html> start tag.
|
||||
std::size_t open = begin;
|
||||
for (;;)
|
||||
{
|
||||
open = findFrom(data, size, open, "<html");
|
||||
if (open == size)
|
||||
return Result::noHtmlElement;
|
||||
if (open + 5 < size && isTagNameEnd(data[open + 5]))
|
||||
break;
|
||||
++open;
|
||||
}
|
||||
|
||||
// Locate the last </html> end tag. Scanning backwards means a literal
|
||||
// "</html>" inside a script string does not shadow the real one.
|
||||
std::size_t close = size;
|
||||
std::size_t closeEnd = size;
|
||||
{
|
||||
std::size_t probe = open;
|
||||
for (;;)
|
||||
{
|
||||
std::size_t const found = findFrom(data, size, probe, "</html");
|
||||
if (found == size)
|
||||
break;
|
||||
|
||||
// Allow whitespace between the tag name and '>'.
|
||||
std::size_t j = found + 6;
|
||||
while (j < size && isSpace(data[j]))
|
||||
++j;
|
||||
if (j < size && data[j] == '>')
|
||||
{
|
||||
close = found;
|
||||
closeEnd = j + 1;
|
||||
}
|
||||
probe = found + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (close == size)
|
||||
return Result::unclosed;
|
||||
|
||||
if (close < open)
|
||||
return Result::unclosed;
|
||||
|
||||
// Nothing but whitespace may follow the closing tag.
|
||||
for (std::size_t i = closeEnd; i < size; ++i)
|
||||
if (!isSpace(data[i]))
|
||||
return Result::trailingGarbage;
|
||||
|
||||
return Result::ok;
|
||||
}
|
||||
|
||||
char const*
|
||||
to_string(Result r)
|
||||
{
|
||||
switch (r)
|
||||
{
|
||||
case Result::ok:
|
||||
return "ok";
|
||||
case Result::empty:
|
||||
return "empty document";
|
||||
case Result::tooLarge:
|
||||
return "document exceeds maxAppLoaderLength";
|
||||
case Result::badUTF8:
|
||||
return "malformed UTF-8";
|
||||
case Result::badControlChar:
|
||||
return "forbidden control character";
|
||||
case Result::noDoctype:
|
||||
return "does not open with a doctype or <html> tag";
|
||||
case Result::noHtmlElement:
|
||||
return "missing <html> start tag";
|
||||
case Result::unclosed:
|
||||
return "missing or misplaced </html> end tag";
|
||||
case Result::trailingGarbage:
|
||||
return "content after </html>";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
} // namespace appLoader
|
||||
} // namespace ripple
|
||||
@@ -81,6 +81,7 @@ enum class LedgerNameSpace : std::uint16_t {
|
||||
IMPORT_VLSEQ = 'I',
|
||||
UNL_REPORT = 'R',
|
||||
CRON = 'L',
|
||||
APP_LOADER = 'W',
|
||||
AMM = 'A',
|
||||
BRIDGE = LEDGER_NAMESPACE2(0x01, 'H'),
|
||||
XCHAIN_CLAIM_ID = 'Q',
|
||||
@@ -612,6 +613,12 @@ did(AccountID const& account) noexcept
|
||||
return {ltDID, indexHash(LedgerNameSpace::DID, account)};
|
||||
}
|
||||
|
||||
Keylet
|
||||
appLoader(AccountID const& account) noexcept
|
||||
{
|
||||
return {ltAPP_LOADER, indexHash(LedgerNameSpace::APP_LOADER, account)};
|
||||
}
|
||||
|
||||
Keylet
|
||||
oracle(AccountID const& account, std::uint32_t const& documentID) noexcept
|
||||
{
|
||||
|
||||
@@ -49,8 +49,6 @@ TxFormats::TxFormats()
|
||||
{sfNetworkID, soeOPTIONAL},
|
||||
{sfHookParameters, soeOPTIONAL},
|
||||
{sfHookName, soeOPTIONAL},
|
||||
{sfTime, soeOPTIONAL},
|
||||
{sfJsonTxDelta, soeOPTIONAL},
|
||||
};
|
||||
|
||||
#pragma push_macro("UNWRAP")
|
||||
|
||||
@@ -25,6 +25,9 @@
|
||||
#include <xrpl/server/detail/JSONRPCUtil.h>
|
||||
#include <boost/algorithm/string.hpp>
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace ripple {
|
||||
|
||||
std::string
|
||||
@@ -57,7 +60,9 @@ HTTPReply(
|
||||
int nStatus,
|
||||
std::string const& content,
|
||||
Json::Output const& output,
|
||||
beast::Journal j)
|
||||
beast::Journal j,
|
||||
std::string const& contentType,
|
||||
std::vector<std::string> const& extraHeaders)
|
||||
{
|
||||
JLOG(j.trace()) << "HTTP Reply " << nStatus << " " << content;
|
||||
|
||||
@@ -143,9 +148,11 @@ HTTPReply(
|
||||
// output ("Access-Control-Allow-Origin: *\r\n");
|
||||
|
||||
output(std::to_string(content.size() + 2));
|
||||
output(
|
||||
"\r\n"
|
||||
"Content-Type: application/json; charset=UTF-8\r\n");
|
||||
output("\r\n");
|
||||
output("Content-Type: " + contentType + "\r\n");
|
||||
|
||||
for (auto const& h : extraHeaders)
|
||||
output(h + "\r\n");
|
||||
|
||||
output("Server: " + systemName() + "-json-rpc/");
|
||||
output(BuildInfo::getFullVersionString());
|
||||
|
||||
523
src/test/app/PWALoader_test.cpp
Normal file
523
src/test/app/PWALoader_test.cpp
Normal file
@@ -0,0 +1,523 @@
|
||||
//------------------------------------------------------------------------------
|
||||
/*
|
||||
This file is part of rippled: https://github.com/ripple/rippled
|
||||
Copyright (c) 2025 XRPL Labs
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted, provided that the above
|
||||
copyright notice and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
*/
|
||||
//==============================================================================
|
||||
|
||||
#include <test/jtx.h>
|
||||
#include <xrpl/basics/StringUtilities.h>
|
||||
#include <xrpl/protocol/AppLoader.h>
|
||||
#include <xrpl/protocol/Feature.h>
|
||||
#include <xrpl/protocol/Indexes.h>
|
||||
#include <xrpl/protocol/Protocol.h>
|
||||
#include <xrpl/protocol/jss.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace ripple {
|
||||
namespace test {
|
||||
|
||||
struct PWALoader_test : public beast::unit_test::suite
|
||||
{
|
||||
// A minimal, realistic loader: enough markup to mount the app, no more.
|
||||
static std::string
|
||||
goodDoc()
|
||||
{
|
||||
return "<!DOCTYPE html>\n"
|
||||
"<html lang=\"en\">\n"
|
||||
"<head><meta charset=\"utf-8\"><title>xapp</title></head>\n"
|
||||
"<body><div id=\"root\"></div>"
|
||||
"<script src=\"/app.js\"></script></body>\n"
|
||||
"</html>\n";
|
||||
}
|
||||
|
||||
static appLoader::Result
|
||||
check(std::string const& s)
|
||||
{
|
||||
return appLoader::validate(
|
||||
reinterpret_cast<std::uint8_t const*>(s.data()), s.size());
|
||||
}
|
||||
|
||||
// Build a valid document padded out to exactly `len` bytes.
|
||||
static std::string
|
||||
docOfSize(std::size_t len)
|
||||
{
|
||||
std::string const head = "<!DOCTYPE html><html><body>";
|
||||
std::string const tail = "</body></html>";
|
||||
if (len < head.size() + tail.size())
|
||||
return head + tail;
|
||||
return head + std::string(len - head.size() - tail.size(), 'x') + tail;
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Validator unit tests. These drive appLoader::validate directly, with no
|
||||
// ledger involved.
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
void
|
||||
testValidator()
|
||||
{
|
||||
testcase("validator");
|
||||
|
||||
using R = appLoader::Result;
|
||||
|
||||
// --- accepted ---------------------------------------------------
|
||||
BEAST_EXPECT(check(goodDoc()) == R::ok);
|
||||
BEAST_EXPECT(check("<!DOCTYPE html><html></html>") == R::ok);
|
||||
BEAST_EXPECT(check("<!doctype html><html></html>") == R::ok);
|
||||
BEAST_EXPECT(check("<!DoCtYpE html><html></html>") == R::ok);
|
||||
// No doctype, but opens with <html> directly.
|
||||
BEAST_EXPECT(check("<html></html>") == R::ok);
|
||||
BEAST_EXPECT(check("<html lang=\"en\"></html>") == R::ok);
|
||||
// Leading and trailing whitespace is tolerated.
|
||||
BEAST_EXPECT(check(" \n\t<html></html>\n\n ") == R::ok);
|
||||
// Whitespace inside the closing tag is legal HTML.
|
||||
BEAST_EXPECT(check("<html></html >") == R::ok);
|
||||
// A UTF-8 BOM is skipped.
|
||||
BEAST_EXPECT(check("\xEF\xBB\xBF<html></html>") == R::ok);
|
||||
// Multi-byte UTF-8 in the body.
|
||||
BEAST_EXPECT(
|
||||
check("<html><body>\xE6\xBC\xA2\xE5\xAD\x97"
|
||||
"</body></html>") == R::ok);
|
||||
// A literal </html> inside a script must not shadow the real one.
|
||||
BEAST_EXPECT(
|
||||
check("<html><script>var s=\"</html>\";</script></html>") == R::ok);
|
||||
// TAB / LF / CR / FF are HTML whitespace and are fine.
|
||||
BEAST_EXPECT(check("<html>\t\n\r\f</html>") == R::ok);
|
||||
|
||||
// --- rejected ---------------------------------------------------
|
||||
BEAST_EXPECT(check("") == R::empty);
|
||||
BEAST_EXPECT(check(" \n\t ") == R::empty);
|
||||
|
||||
// Plain text, JSON, and other non-documents.
|
||||
BEAST_EXPECT(check("hello world") == R::noDoctype);
|
||||
BEAST_EXPECT(check("{\"a\":1}") == R::noDoctype);
|
||||
BEAST_EXPECT(check("<?xml version=\"1.0\"?>") == R::noDoctype);
|
||||
BEAST_EXPECT(check("<body></body>") == R::noDoctype);
|
||||
// A fragment is not a document.
|
||||
BEAST_EXPECT(check("<div>hi</div>") == R::noDoctype);
|
||||
// Doctype must be followed by whitespace.
|
||||
BEAST_EXPECT(check("<!doctypehtml><html></html>") == R::noDoctype);
|
||||
// Tag name must actually end.
|
||||
BEAST_EXPECT(check("<htmlish></htmlish>") == R::noDoctype);
|
||||
|
||||
// Structure problems.
|
||||
BEAST_EXPECT(check("<!DOCTYPE html>") == R::noHtmlElement);
|
||||
BEAST_EXPECT(check("<!DOCTYPE html><p>orphan</p>") == R::noHtmlElement);
|
||||
BEAST_EXPECT(check("<html>") == R::unclosed);
|
||||
BEAST_EXPECT(check("<html><body>no end tag</body>") == R::unclosed);
|
||||
// Missing '>' on the end tag.
|
||||
BEAST_EXPECT(check("<html></html") == R::unclosed);
|
||||
BEAST_EXPECT(check("<html></html>trailing") == R::trailingGarbage);
|
||||
BEAST_EXPECT(
|
||||
check("<html></html><script>x</script>") == R::trailingGarbage);
|
||||
|
||||
// Control characters.
|
||||
BEAST_EXPECT(
|
||||
check("<html>\x01"
|
||||
"</html>") == R::badControlChar);
|
||||
// Embedded NUL.
|
||||
BEAST_EXPECT(
|
||||
check(std::string("<html>\0</html>", 14)) == R::badControlChar);
|
||||
BEAST_EXPECT(
|
||||
check("<html>\x7F"
|
||||
"</html>") == R::badControlChar);
|
||||
|
||||
// Size, at the 4 KiB boundary.
|
||||
BEAST_EXPECT(maxAppLoaderLength == 4096);
|
||||
BEAST_EXPECT(check(docOfSize(maxAppLoaderLength - 1)) == R::ok);
|
||||
BEAST_EXPECT(check(docOfSize(maxAppLoaderLength)) == R::ok);
|
||||
BEAST_EXPECT(check(docOfSize(maxAppLoaderLength + 1)) == R::tooLarge);
|
||||
}
|
||||
|
||||
void
|
||||
testUTF8()
|
||||
{
|
||||
testcase("utf8");
|
||||
|
||||
using R = appLoader::Result;
|
||||
|
||||
auto const wrap = [](std::string const& mid) {
|
||||
return "<html>" + mid + "</html>";
|
||||
};
|
||||
|
||||
// Well-formed sequences of each length.
|
||||
BEAST_EXPECT(check(wrap("\x24")) == R::ok); // U+0024
|
||||
BEAST_EXPECT(check(wrap("\xC2\xA2")) == R::ok); // U+00A2
|
||||
BEAST_EXPECT(check(wrap("\xE0\xA4\xB9")) == R::ok); // U+0939
|
||||
BEAST_EXPECT(check(wrap("\xF0\x90\x8D\x88")) == R::ok); // U+10348
|
||||
BEAST_EXPECT(check(wrap("\xF4\x8F\xBF\xBF")) == R::ok); // U+10FFFF
|
||||
// U+FFFE / U+FFFF are noncharacters but are valid UTF-8, and are
|
||||
// permitted in interchange.
|
||||
BEAST_EXPECT(check(wrap("\xEF\xBF\xBE")) == R::ok);
|
||||
BEAST_EXPECT(check(wrap("\xEF\xBF\xBF")) == R::ok);
|
||||
|
||||
// Overlong encodings.
|
||||
BEAST_EXPECT(check(wrap("\xC0\xAF")) == R::badUTF8);
|
||||
BEAST_EXPECT(check(wrap("\xC1\xBF")) == R::badUTF8);
|
||||
BEAST_EXPECT(check(wrap("\xE0\x80\xAF")) == R::badUTF8);
|
||||
BEAST_EXPECT(check(wrap("\xF0\x80\x80\xAF")) == R::badUTF8);
|
||||
|
||||
// UTF-16 surrogate halves.
|
||||
BEAST_EXPECT(check(wrap("\xED\xA0\x80")) == R::badUTF8); // D800
|
||||
BEAST_EXPECT(check(wrap("\xED\xBF\xBF")) == R::badUTF8); // DFFF
|
||||
|
||||
// Beyond U+10FFFF.
|
||||
BEAST_EXPECT(check(wrap("\xF4\x90\x80\x80")) == R::badUTF8);
|
||||
BEAST_EXPECT(check(wrap("\xF5\x80\x80\x80")) == R::badUTF8);
|
||||
BEAST_EXPECT(check(wrap("\xF8\x88\x80\x80\x80")) == R::badUTF8);
|
||||
|
||||
// Stray continuation bytes.
|
||||
BEAST_EXPECT(check(wrap("\x80")) == R::badUTF8);
|
||||
BEAST_EXPECT(check(wrap("\xBF")) == R::badUTF8);
|
||||
|
||||
// Truncated sequences at the very end of the buffer.
|
||||
BEAST_EXPECT(check("<html></html>\xC2") == R::badUTF8);
|
||||
BEAST_EXPECT(check("<html></html>\xE0\xA4") == R::badUTF8);
|
||||
BEAST_EXPECT(check("<html></html>\xF0\x90\x8D") == R::badUTF8);
|
||||
BEAST_EXPECT(check(std::string("\xC2", 1)) == R::badUTF8);
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Ledger-level tests.
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
void
|
||||
testEnabled(FeatureBitset features)
|
||||
{
|
||||
testcase("enabled");
|
||||
using namespace jtx;
|
||||
|
||||
for (bool const withLoader : {false, true})
|
||||
{
|
||||
auto const amend =
|
||||
withLoader ? features : features - featurePWALoader;
|
||||
Env env{*this, amend};
|
||||
|
||||
auto const alice = Account("alice");
|
||||
env.fund(XRP(1000), alice);
|
||||
env.close();
|
||||
|
||||
auto jt = noop(alice);
|
||||
jt[sfAppLoader.fieldName] = strHex(goodDoc());
|
||||
|
||||
if (withLoader)
|
||||
{
|
||||
env(jt, fee(XRP(1)));
|
||||
env.close();
|
||||
BEAST_EXPECT(env.le(keylet::appLoader(alice.id())));
|
||||
}
|
||||
else
|
||||
{
|
||||
env(jt, ter(temDISABLED));
|
||||
env.close();
|
||||
BEAST_EXPECT(!env.le(keylet::appLoader(alice.id())));
|
||||
}
|
||||
}
|
||||
|
||||
// Removal is gated too: an empty blob is still a present field.
|
||||
{
|
||||
Env env{*this, features - featurePWALoader};
|
||||
auto const alice = Account("alice");
|
||||
env.fund(XRP(1000), alice);
|
||||
env.close();
|
||||
|
||||
auto jt = noop(alice);
|
||||
jt[sfAppLoader.fieldName] = "";
|
||||
env(jt, ter(temDISABLED));
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
testSetAndRemove(FeatureBitset features)
|
||||
{
|
||||
testcase("set and remove");
|
||||
using namespace jtx;
|
||||
|
||||
Env env{*this, features};
|
||||
auto const alice = Account("alice");
|
||||
env.fund(XRP(1000), alice);
|
||||
env.close();
|
||||
|
||||
std::string const doc = goodDoc();
|
||||
auto const klLoader = keylet::appLoader(alice.id());
|
||||
|
||||
// Nothing to begin with.
|
||||
BEAST_EXPECT(!env.le(klLoader));
|
||||
if (auto const root = env.le(alice); BEAST_EXPECT(root))
|
||||
BEAST_EXPECT(!root->isFieldPresent(sfAppLoaderID));
|
||||
|
||||
// --- create ---
|
||||
auto jt = noop(alice);
|
||||
jt[sfAppLoader.fieldName] = strHex(doc);
|
||||
env(jt, fee(XRP(1)));
|
||||
env.close();
|
||||
|
||||
{
|
||||
auto const sle = env.le(klLoader);
|
||||
if (BEAST_EXPECT(sle))
|
||||
{
|
||||
BEAST_EXPECT((*sle)[sfAppLoader] == makeSlice(doc));
|
||||
BEAST_EXPECT((*sle)[sfOwner] == alice.id());
|
||||
}
|
||||
// The AccountRoot points at it...
|
||||
auto const root = env.le(alice);
|
||||
if (BEAST_EXPECT(root))
|
||||
{
|
||||
BEAST_EXPECT((*root)[sfAppLoaderID] == klLoader.key);
|
||||
// ...and the blob is NOT inline on the AccountRoot.
|
||||
BEAST_EXPECT(!root->isFieldPresent(sfAppLoader));
|
||||
}
|
||||
}
|
||||
|
||||
// No reserve is taken, and because the object is not in the owner
|
||||
// directory it is invisible to account_objects. Reaching it requires
|
||||
// sfAppLoaderID (or recomputing the keylet); this asserts the
|
||||
// trade-off deliberately rather than by omission.
|
||||
{
|
||||
auto const jrr = env.rpc(
|
||||
"json",
|
||||
"account_objects",
|
||||
R"({"account":")" + alice.human() + R"("})");
|
||||
BEAST_EXPECT(jrr[jss::result][jss::account_objects].size() == 0);
|
||||
|
||||
// ledger_entry by index still resolves it.
|
||||
auto const le = env.rpc(
|
||||
"json",
|
||||
"ledger_entry",
|
||||
R"({"index":")" + to_string(klLoader.key) + R"("})");
|
||||
BEAST_EXPECT(
|
||||
le[jss::result][jss::node][sfLedgerEntryType.fieldName] ==
|
||||
jss::AppLoader);
|
||||
}
|
||||
|
||||
// --- overwrite in place ---
|
||||
std::string const doc2 = "<html><body>v2</body></html>";
|
||||
jt[sfAppLoader.fieldName] = strHex(doc2);
|
||||
env(jt, fee(XRP(1)));
|
||||
env.close();
|
||||
{
|
||||
auto const sle = env.le(klLoader);
|
||||
if (BEAST_EXPECT(sle))
|
||||
BEAST_EXPECT((*sle)[sfAppLoader] == makeSlice(doc2));
|
||||
// The pointer is unchanged by an overwrite.
|
||||
if (auto const root = env.le(alice); BEAST_EXPECT(root))
|
||||
BEAST_EXPECT((*root)[sfAppLoaderID] == klLoader.key);
|
||||
}
|
||||
|
||||
// --- remove with an empty blob ---
|
||||
jt[sfAppLoader.fieldName] = "";
|
||||
env(jt, fee(XRP(1)));
|
||||
env.close();
|
||||
BEAST_EXPECT(!env.le(klLoader));
|
||||
if (auto const root = env.le(alice); BEAST_EXPECT(root))
|
||||
BEAST_EXPECT(!root->isFieldPresent(sfAppLoaderID));
|
||||
|
||||
// Removing when already absent is a no-op, not an error.
|
||||
env(jt, fee(XRP(1)));
|
||||
env.close();
|
||||
BEAST_EXPECT(!env.le(klLoader));
|
||||
|
||||
// An AccountSet that does not mention AppLoader leaves it alone.
|
||||
jt[sfAppLoader.fieldName] = strHex(doc);
|
||||
env(jt, fee(XRP(1)));
|
||||
env.close();
|
||||
env(noop(alice));
|
||||
env.close();
|
||||
if (auto const sle = env.le(klLoader); BEAST_EXPECT(sle))
|
||||
BEAST_EXPECT((*sle)[sfAppLoader] == makeSlice(doc));
|
||||
}
|
||||
|
||||
void
|
||||
testFee(FeatureBitset features)
|
||||
{
|
||||
testcase("fee");
|
||||
using namespace jtx;
|
||||
|
||||
Env env{*this, features};
|
||||
auto const alice = Account("alice");
|
||||
env.fund(XRP(1000), alice);
|
||||
env.close();
|
||||
|
||||
auto const base = env.current()->fees().base;
|
||||
std::string const doc = docOfSize(1024);
|
||||
|
||||
// The required fee is base + one drop per byte.
|
||||
auto const expected = base + XRPAmount{1024};
|
||||
|
||||
auto jt = noop(alice);
|
||||
jt[sfAppLoader.fieldName] = strHex(doc);
|
||||
|
||||
// One drop short is rejected.
|
||||
env(jt, fee(expected - XRPAmount{1}), ter(telINSUF_FEE_P));
|
||||
env.close();
|
||||
BEAST_EXPECT(!env.le(keylet::appLoader(alice.id())));
|
||||
|
||||
// Exactly the required fee succeeds, and that much is burned.
|
||||
auto const before = env.balance(alice);
|
||||
env(jt, fee(expected));
|
||||
env.close();
|
||||
BEAST_EXPECT(env.le(keylet::appLoader(alice.id())));
|
||||
BEAST_EXPECT(before - env.balance(alice) == drops(expected));
|
||||
|
||||
// Removal carries an empty blob, so it costs only the base fee.
|
||||
auto jtDel = noop(alice);
|
||||
jtDel[sfAppLoader.fieldName] = "";
|
||||
env(jtDel, fee(base));
|
||||
env.close();
|
||||
BEAST_EXPECT(!env.le(keylet::appLoader(alice.id())));
|
||||
}
|
||||
|
||||
void
|
||||
testMalformed(FeatureBitset features)
|
||||
{
|
||||
testcase("malformed");
|
||||
using namespace jtx;
|
||||
|
||||
Env env{*this, features};
|
||||
auto const alice = Account("alice");
|
||||
env.fund(XRP(1000), alice);
|
||||
env.close();
|
||||
|
||||
auto reject = [&](std::string const& doc) {
|
||||
auto jt = noop(alice);
|
||||
jt[sfAppLoader.fieldName] = strHex(doc);
|
||||
env(jt, fee(XRP(1)), ter(temMALFORMED));
|
||||
env.close();
|
||||
BEAST_EXPECT(!env.le(keylet::appLoader(alice.id())));
|
||||
};
|
||||
|
||||
reject("hello world");
|
||||
reject("<div>fragment</div>");
|
||||
reject("<!DOCTYPE html>");
|
||||
reject("<html>");
|
||||
reject("<html></html>trailing");
|
||||
reject(std::string("<html>\0</html>", 14));
|
||||
reject("<html>\xC0\xAF</html>");
|
||||
reject(docOfSize(maxAppLoaderLength + 1));
|
||||
|
||||
// The largest legal document is accepted.
|
||||
auto jt = noop(alice);
|
||||
jt[sfAppLoader.fieldName] = strHex(docOfSize(maxAppLoaderLength));
|
||||
env(jt, fee(XRP(1)));
|
||||
env.close();
|
||||
BEAST_EXPECT(env.le(keylet::appLoader(alice.id())));
|
||||
}
|
||||
|
||||
void
|
||||
testAccountDelete(FeatureBitset features)
|
||||
{
|
||||
testcase("account delete");
|
||||
using namespace jtx;
|
||||
|
||||
Env env{*this, features};
|
||||
auto const alice = Account("alice");
|
||||
auto const bob = Account("bob");
|
||||
env.fund(XRP(1000), alice, bob);
|
||||
env.close();
|
||||
|
||||
auto jt = noop(alice);
|
||||
jt[sfAppLoader.fieldName] = strHex(goodDoc());
|
||||
env(jt, fee(XRP(1)));
|
||||
env.close();
|
||||
BEAST_EXPECT(env.le(keylet::appLoader(alice.id())));
|
||||
|
||||
// AccountDelete requires the account to be well seasoned.
|
||||
for (int i = 0; i < 256; ++i)
|
||||
env.close();
|
||||
|
||||
// The AppLoader is not in the owner directory, so it must not block
|
||||
// deletion -- and DeleteAccount must still erase it, or
|
||||
// AccountRootsDeletedClean fires on the orphan.
|
||||
env(acctdelete(alice, bob),
|
||||
fee(drops(env.current()->fees().increment)));
|
||||
env.close();
|
||||
|
||||
BEAST_EXPECT(!env.le(alice));
|
||||
BEAST_EXPECT(!env.le(keylet::appLoader(alice.id())));
|
||||
}
|
||||
|
||||
void
|
||||
testURITokenUTF8Gate(FeatureBitset features)
|
||||
{
|
||||
testcase("uritoken utf8 gate");
|
||||
using namespace jtx;
|
||||
|
||||
// U+FFFF is well-formed UTF-8, but the pre-amendment URIToken check
|
||||
// rejected it. Under featurePWALoader it is accepted. This is the
|
||||
// observable behaviour change the amendment gate exists to cover.
|
||||
std::string const uri = "ipfs://x\xEF\xBF\xBF";
|
||||
|
||||
for (bool const withLoader : {false, true})
|
||||
{
|
||||
auto const amend =
|
||||
withLoader ? features : features - featurePWALoader;
|
||||
Env env{*this, amend};
|
||||
|
||||
auto const alice = Account("alice");
|
||||
env.fund(XRP(1000), alice);
|
||||
env.close();
|
||||
|
||||
env(uritoken::mint(alice, uri),
|
||||
fee(XRP(1)),
|
||||
ter(withLoader ? TER{tesSUCCESS} : TER{temMALFORMED}));
|
||||
env.close();
|
||||
}
|
||||
|
||||
// Genuinely malformed UTF-8 is rejected either side of the amendment.
|
||||
for (bool const withLoader : {false, true})
|
||||
{
|
||||
auto const amend =
|
||||
withLoader ? features : features - featurePWALoader;
|
||||
Env env{*this, amend};
|
||||
|
||||
auto const alice = Account("alice");
|
||||
env.fund(XRP(1000), alice);
|
||||
env.close();
|
||||
|
||||
env(uritoken::mint(alice, std::string("bad\xC0\xAF")),
|
||||
fee(XRP(1)),
|
||||
ter(temMALFORMED));
|
||||
env.close();
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
testWithFeats(FeatureBitset features)
|
||||
{
|
||||
testEnabled(features);
|
||||
testSetAndRemove(features);
|
||||
testFee(features);
|
||||
testMalformed(features);
|
||||
testAccountDelete(features);
|
||||
testURITokenUTF8Gate(features);
|
||||
}
|
||||
|
||||
public:
|
||||
void
|
||||
run() override
|
||||
{
|
||||
using namespace test::jtx;
|
||||
testValidator();
|
||||
testUTF8();
|
||||
testWithFeats(supported_amendments());
|
||||
}
|
||||
};
|
||||
|
||||
BEAST_DEFINE_TESTSUITE(PWALoader, app, ripple);
|
||||
|
||||
} // namespace test
|
||||
} // namespace ripple
|
||||
@@ -428,6 +428,19 @@ DeleteAccount::doApply()
|
||||
if (src->isFieldPresent(sfHookNamespaces) || src->isFieldPresent(sfHooks))
|
||||
return tecHAS_OBLIGATIONS;
|
||||
|
||||
// The AppLoader is linked directly from the AccountRoot via sfAppLoaderID
|
||||
// rather than through the owner directory, so the cleanup walk below will
|
||||
// never visit it. Erase it here, or account deletion would orphan it --
|
||||
// which AccountRootsDeletedClean would flag, since keylet::appLoader is
|
||||
// registered in directAccountKeylets.
|
||||
//
|
||||
// Not gated on featurePWALoader: on a ledger where the amendment is not
|
||||
// active no such object can exist, so the peek simply finds nothing. That
|
||||
// is deliberately safer than gating, which would orphan the object in the
|
||||
// event the rule ever read as disabled while one existed.
|
||||
if (auto sleLoader = view().peek(keylet::appLoader(account_)))
|
||||
view().erase(sleLoader);
|
||||
|
||||
// Delete all of the entries in the account directory.
|
||||
Keylet const ownerDirKeylet{keylet::ownerDir(account_)};
|
||||
auto const ter = cleanupOnAccountDelete(
|
||||
|
||||
@@ -612,6 +612,7 @@ LedgerEntryTypesMatch::visitEntry(
|
||||
case ltMPTOKEN:
|
||||
case ltCREDENTIAL:
|
||||
case ltPERMISSIONED_DOMAIN:
|
||||
case ltAPP_LOADER:
|
||||
break;
|
||||
default:
|
||||
invalidTypeAdded_ = true;
|
||||
|
||||
@@ -201,7 +201,7 @@ Remit::preflight(PreflightContext const& ctx)
|
||||
return temMALFORMED;
|
||||
}
|
||||
|
||||
if (!URIToken::validateUTF8(uri))
|
||||
if (!URIToken::validateUTF8(uri, ctx.rules.enabled(featurePWALoader)))
|
||||
{
|
||||
JLOG(ctx.j.warn())
|
||||
<< "Malformed transaction: Invalid UTF8 inside MintURIToken.";
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
#include <xrpld/core/Config.h>
|
||||
#include <xrpld/ledger/View.h>
|
||||
#include <xrpl/basics/Log.h>
|
||||
#include <xrpl/protocol/AppLoader.h>
|
||||
#include <xrpl/protocol/Feature.h>
|
||||
#include <xrpl/protocol/Indexes.h>
|
||||
#include <xrpl/protocol/PublicKey.h>
|
||||
@@ -55,6 +56,26 @@ SetAccount::makeTxConsequences(PreflightContext const& ctx)
|
||||
return TxConsequences{ctx.tx, getTxConsequencesCategory(ctx.tx)};
|
||||
}
|
||||
|
||||
XRPAmount
|
||||
SetAccount::calculateBaseFee(ReadView const& view, STTx const& tx)
|
||||
{
|
||||
XRPAmount extraFee{0};
|
||||
|
||||
if (view.rules().enabled(featurePWALoader))
|
||||
{
|
||||
if (auto const loader = tx[~sfAppLoader])
|
||||
{
|
||||
// One drop per byte. Charged on every AccountSet that carries the
|
||||
// field, including overwrites; removal carries an empty blob and
|
||||
// so costs nothing extra.
|
||||
extraFee += XRPAmount{static_cast<std::int64_t>(
|
||||
loader->size() * appLoaderFeeDropsPerByte)};
|
||||
}
|
||||
}
|
||||
|
||||
return Transactor::calculateBaseFee(view, tx) + extraFee;
|
||||
}
|
||||
|
||||
NotTEC
|
||||
SetAccount::preflight(PreflightContext const& ctx)
|
||||
{
|
||||
@@ -184,6 +205,28 @@ SetAccount::preflight(PreflightContext const& ctx)
|
||||
return temMALFORMED;
|
||||
}
|
||||
|
||||
// AppLoader
|
||||
if (auto const loader = tx[~sfAppLoader])
|
||||
{
|
||||
if (!ctx.rules.enabled(featurePWALoader))
|
||||
return temDISABLED;
|
||||
|
||||
// An empty blob is the removal sentinel, matching Domain and
|
||||
// MessageKey. It is not validated as a document.
|
||||
if (!loader->empty())
|
||||
{
|
||||
auto const result =
|
||||
appLoader::validate(loader->data(), loader->size());
|
||||
|
||||
if (result != appLoader::Result::ok)
|
||||
{
|
||||
JLOG(j.trace()) << "Malformed transaction: AppLoader: "
|
||||
<< appLoader::to_string(result);
|
||||
return temMALFORMED;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HookStateScale
|
||||
if (tx.isFieldPresent(sfHookStateScale))
|
||||
{
|
||||
@@ -565,6 +608,57 @@ SetAccount::doApply()
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// AppLoader
|
||||
//
|
||||
// The document lives in its own ltAPP_LOADER object rather than inline on
|
||||
// the AccountRoot, so that a multi-kilobyte blob is not dragged into
|
||||
// memory every time the account is touched. The AccountRoot keeps only a
|
||||
// pointer.
|
||||
//
|
||||
// The object is not placed in the owner directory and takes no reserve:
|
||||
// its storage is paid for by the per-byte fee added in calculateBaseFee.
|
||||
// DeleteAccount therefore has to erase it explicitly, since its directory
|
||||
// walk will never see it.
|
||||
//
|
||||
if (view().rules().enabled(featurePWALoader) &&
|
||||
tx.isFieldPresent(sfAppLoader))
|
||||
{
|
||||
Blob const loader = tx.getFieldVL(sfAppLoader);
|
||||
Keylet const klLoader = keylet::appLoader(account_);
|
||||
auto sleLoader = view().peek(klLoader);
|
||||
|
||||
if (loader.empty())
|
||||
{
|
||||
// Removal.
|
||||
if (sleLoader)
|
||||
{
|
||||
JLOG(j_.trace()) << "unset app loader";
|
||||
view().erase(sleLoader);
|
||||
}
|
||||
|
||||
if (sle->isFieldPresent(sfAppLoaderID))
|
||||
sle->makeFieldAbsent(sfAppLoaderID);
|
||||
}
|
||||
else if (sleLoader)
|
||||
{
|
||||
// Overwrite in place; the pointer already stands.
|
||||
JLOG(j_.trace()) << "update app loader";
|
||||
sleLoader->setFieldVL(sfAppLoader, loader);
|
||||
view().update(sleLoader);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Create.
|
||||
JLOG(j_.trace()) << "set app loader";
|
||||
sleLoader = std::make_shared<SLE>(klLoader);
|
||||
(*sleLoader)[sfOwner] = account_;
|
||||
sleLoader->setFieldVL(sfAppLoader, loader);
|
||||
view().insert(sleLoader);
|
||||
sle->setFieldH256(sfAppLoaderID, klLoader.key);
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// TransferRate
|
||||
//
|
||||
|
||||
@@ -41,6 +41,14 @@ public:
|
||||
static TxConsequences
|
||||
makeTxConsequences(PreflightContext const& ctx);
|
||||
|
||||
/** Adds appLoaderFeeDropsPerByte per byte of AppLoader document.
|
||||
|
||||
The AppLoader ledger object carries no owner reserve, so the cost of
|
||||
the storage is recovered here instead.
|
||||
*/
|
||||
static XRPAmount
|
||||
calculateBaseFee(ReadView const& view, STTx const& tx);
|
||||
|
||||
static NotTEC
|
||||
preflight(PreflightContext const& ctx);
|
||||
|
||||
|
||||
@@ -518,7 +518,8 @@ SetHook::validateHookSetEntry(SetHookCtx& ctx, STObject const& hookSetObj)
|
||||
if (hookSetObj.isFieldPresent(sfHookName))
|
||||
{
|
||||
auto name = hookSetObj.getFieldVL(sfHookName);
|
||||
if (!validateHookName(name, ctx.j))
|
||||
if (!validateHookName(
|
||||
name, ctx.rules.enabled(featurePWALoader), ctx.j))
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -622,7 +623,10 @@ SetHook::validateHookSetEntry(SetHookCtx& ctx, STObject const& hookSetObj)
|
||||
}
|
||||
|
||||
bool
|
||||
SetHook::validateHookName(Blob const& name, beast::Journal const& j)
|
||||
SetHook::validateHookName(
|
||||
Blob const& name,
|
||||
bool permitNoncharacters,
|
||||
beast::Journal const& j)
|
||||
{
|
||||
if (name.size() != 0 && (name.size() < 4 || 16 < name.size()))
|
||||
{
|
||||
@@ -630,7 +634,7 @@ SetHook::validateHookName(Blob const& name, beast::Journal const& j)
|
||||
<< "sfHookName must be between 8 and 32 hex characters.";
|
||||
return false;
|
||||
}
|
||||
if (!URIToken::validateUTF8(name))
|
||||
if (!URIToken::validateUTF8(name, permitNoncharacters))
|
||||
{
|
||||
JLOG(j.trace()) << "sfHookName must be a valid UTF-8 string.";
|
||||
return false;
|
||||
|
||||
@@ -92,7 +92,10 @@ public:
|
||||
validateHookSetEntry(SetHookCtx& ctx, STObject const& hookSetObj);
|
||||
|
||||
static bool
|
||||
validateHookName(Blob const& name, beast::Journal const& j);
|
||||
validateHookName(
|
||||
Blob const& name,
|
||||
bool permitNoncharacters,
|
||||
beast::Journal const& j);
|
||||
|
||||
static uint32_t
|
||||
computeHookReserve(STObject const& hookObj);
|
||||
|
||||
@@ -155,7 +155,10 @@ preflight1(PreflightContext const& ctx)
|
||||
!ctx.rules.enabled(featureNamedHooks))
|
||||
return temMALFORMED;
|
||||
|
||||
if (!SetHook::validateHookName(ctx.tx.getFieldVL(sfHookName), ctx.j))
|
||||
if (!SetHook::validateHookName(
|
||||
ctx.tx.getFieldVL(sfHookName),
|
||||
ctx.rules.enabled(featurePWALoader),
|
||||
ctx.j))
|
||||
return temMALFORMED;
|
||||
}
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ URIToken::preflight(PreflightContext const& ctx)
|
||||
return temMALFORMED;
|
||||
}
|
||||
|
||||
if (!validateUTF8(uri))
|
||||
if (!validateUTF8(uri, ctx.rules.enabled(featurePWALoader)))
|
||||
{
|
||||
JLOG(ctx.j.warn()) << "Malformed transaction. URI must be a "
|
||||
"valid utf-8 string.";
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
#include <xrpld/app/ledger/Ledger.h>
|
||||
#include <xrpld/app/tx/detail/Transactor.h>
|
||||
#include <xrpl/basics/Log.h>
|
||||
#include <xrpl/protocol/AppLoader.h>
|
||||
#include <xrpl/protocol/Feature.h>
|
||||
#include <xrpl/protocol/Indexes.h>
|
||||
|
||||
namespace ripple {
|
||||
@@ -30,53 +32,37 @@ namespace ripple {
|
||||
class URIToken : public Transactor
|
||||
{
|
||||
public:
|
||||
bool inline static validateUTF8(std::vector<uint8_t> const& u)
|
||||
/** Validate a byte sequence as UTF-8.
|
||||
|
||||
Two things differ across featurePWALoader. Before the amendment,
|
||||
U+FFFE and U+FFFF were rejected; they are well-formed UTF-8, and
|
||||
noncharacters are permitted in interchange, so the amendment accepts
|
||||
them. Because that changes which transactions preflight successfully,
|
||||
it rides an amendment rather than landing directly.
|
||||
|
||||
@param permitNoncharacters Pass rules.enabled(featurePWALoader).
|
||||
Defaults to the post-amendment behaviour so that a caller who
|
||||
omits it is at worst wrong before activation, never after.
|
||||
*/
|
||||
bool inline static validateUTF8(
|
||||
std::vector<uint8_t> const& u,
|
||||
bool permitNoncharacters = true)
|
||||
{
|
||||
// this code is from
|
||||
// https://www.cl.cam.ac.uk/~mgk25/ucs/utf8_check.c
|
||||
uint8_t const* s = (uint8_t const*)u.data();
|
||||
uint8_t const* end = s + u.size();
|
||||
while (s < end)
|
||||
{
|
||||
if (*s < 0x80)
|
||||
/* 0xxxxxxx */
|
||||
s++;
|
||||
else if ((s[0] & 0xe0) == 0xc0)
|
||||
{
|
||||
/* 110XXXXx 10xxxxxx */
|
||||
if ((s[1] & 0xc0) != 0x80 ||
|
||||
(s[0] & 0xfe) == 0xc0) /* overlong? */
|
||||
return false;
|
||||
else
|
||||
s += 2;
|
||||
}
|
||||
else if ((s[0] & 0xf0) == 0xe0)
|
||||
{
|
||||
/* 1110XXXX 10Xxxxxx 10xxxxxx */
|
||||
if ((s[1] & 0xc0) != 0x80 || (s[2] & 0xc0) != 0x80 ||
|
||||
(s[0] == 0xe0 && (s[1] & 0xe0) == 0x80) || /* overlong? */
|
||||
(s[0] == 0xed && (s[1] & 0xe0) == 0xa0) || /* surrogate? */
|
||||
(s[0] == 0xef && s[1] == 0xbf &&
|
||||
(s[2] & 0xfe) == 0xbe)) /* U+FFFE or U+FFFF? */
|
||||
return false;
|
||||
else
|
||||
s += 3;
|
||||
}
|
||||
else if ((s[0] & 0xf8) == 0xf0)
|
||||
{
|
||||
/* 11110XXX 10XXxxxx 10xxxxxx 10xxxxxx */
|
||||
if ((s[1] & 0xc0) != 0x80 || (s[2] & 0xc0) != 0x80 ||
|
||||
(s[3] & 0xc0) != 0x80 ||
|
||||
(s[0] == 0xf0 && (s[1] & 0xf0) == 0x80) || /* overlong? */
|
||||
(s[0] == 0xf4 && s[1] > 0x8f) ||
|
||||
s[0] > 0xf4) /* > U+10FFFF? */
|
||||
return false;
|
||||
else
|
||||
s += 4;
|
||||
}
|
||||
else
|
||||
if (!appLoader::isValidUTF8(u.data(), u.size()))
|
||||
return false;
|
||||
|
||||
if (permitNoncharacters)
|
||||
return true;
|
||||
|
||||
// Pre-amendment behaviour: reject U+FFFE and U+FFFF, which encode as
|
||||
// EF BF BE and EF BF BF. UTF-8 is self-synchronising, so in a
|
||||
// sequence already known to be well-formed these bytes can only be
|
||||
// those two code points.
|
||||
for (std::size_t i = 0; i + 2 < u.size(); ++i)
|
||||
if (u[i] == 0xEF && u[i + 1] == 0xBF &&
|
||||
(u[i + 2] == 0xBE || u[i + 2] == 0xBF))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -309,6 +309,12 @@ public:
|
||||
// Enable the beta API version
|
||||
bool BETA_RPC_API = false;
|
||||
|
||||
// Serve on-ledger AppLoader documents as HTML from the http/https ports
|
||||
// under /pwa/<account>. Off by default: it turns the node into a web
|
||||
// host for content it does not control. See ServerHandler for the
|
||||
// restrictions that apply when it is on.
|
||||
bool PWA_ENABLED = false;
|
||||
|
||||
// First, attempt to load the latest ledger directly from disk.
|
||||
bool FAST_LOAD = false;
|
||||
// When starting rippled with existing database it do not know it has those
|
||||
|
||||
@@ -69,6 +69,7 @@ struct ConfigSection
|
||||
#define SECTION_PATH_SEARCH "path_search"
|
||||
#define SECTION_PATH_SEARCH_FAST "path_search_fast"
|
||||
#define SECTION_PATH_SEARCH_MAX "path_search_max"
|
||||
#define SECTION_PWA "pwa"
|
||||
#define SECTION_PEER_PRIVATE "peer_private"
|
||||
#define SECTION_PEERS_MAX "peers_max"
|
||||
#define SECTION_PEERS_IN_MAX "peers_in_max"
|
||||
|
||||
@@ -849,6 +849,9 @@ Config::loadFromString(std::string const& fileContents)
|
||||
if (getSingleSection(secConfig, SECTION_BETA_RPC_API, strTemp, j_))
|
||||
BETA_RPC_API = beast::lexicalCastThrow<bool>(strTemp);
|
||||
|
||||
if (getSingleSection(secConfig, SECTION_PWA, strTemp, j_))
|
||||
PWA_ENABLED = beast::lexicalCastThrow<bool>(strTemp);
|
||||
|
||||
// Do not load trusted validator configuration for standalone mode
|
||||
do
|
||||
{
|
||||
|
||||
@@ -217,6 +217,16 @@ private:
|
||||
std::shared_ptr<Session> const&,
|
||||
std::shared_ptr<JobQueue::Coro> coro);
|
||||
|
||||
/** Serve an account's on-ledger AppLoader document as HTML.
|
||||
|
||||
Only reached when PWA_ENABLED is set and the request was a GET of
|
||||
/pwa/<account>. Runs on the job queue because it reads the ledger.
|
||||
*/
|
||||
void
|
||||
processPWARequest(
|
||||
std::shared_ptr<Session> const& session,
|
||||
AccountID const& account);
|
||||
|
||||
void
|
||||
processRequest(
|
||||
Port const& port,
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
#include <xrpld/rpc/ServerHandler.h>
|
||||
|
||||
#include <xrpld/app/ledger/LedgerMaster.h>
|
||||
#include <xrpld/app/main/Application.h>
|
||||
#include <xrpld/app/misc/NetworkOPs.h>
|
||||
#include <xrpld/core/ConfigSections.h>
|
||||
@@ -37,7 +38,9 @@
|
||||
#include <xrpl/beast/rfc2616.h>
|
||||
#include <xrpl/json/json_reader.h>
|
||||
#include <xrpl/json/to_string.h>
|
||||
#include <xrpl/protocol/AccountID.h>
|
||||
#include <xrpl/protocol/ErrorCodes.h>
|
||||
#include <xrpl/protocol/Indexes.h>
|
||||
#include <xrpl/protocol/RPCErr.h>
|
||||
#include <xrpl/resource/Fees.h>
|
||||
#include <xrpl/resource/ResourceManager.h>
|
||||
@@ -50,7 +53,10 @@
|
||||
#include <boost/beast/http/string_body.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <optional>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace ripple {
|
||||
|
||||
@@ -294,6 +300,118 @@ buffers_to_string(ConstBufferSequence const& bs)
|
||||
return s;
|
||||
}
|
||||
|
||||
// Parse "/pwa/<account>" and return the account, or nullopt if the target is
|
||||
// not a PWA request or the account is unparseable. A trailing slash and any
|
||||
// query string or fragment are ignored, so /pwa/rXXX/?v=2 resolves the same
|
||||
// as /pwa/rXXX.
|
||||
static std::optional<AccountID>
|
||||
parsePWATarget(boost::beast::string_view target)
|
||||
{
|
||||
static constexpr char prefix[] = "/pwa/";
|
||||
static constexpr std::size_t prefixLen = sizeof(prefix) - 1;
|
||||
|
||||
if (target.size() <= prefixLen || target.substr(0, prefixLen) != prefix)
|
||||
return std::nullopt;
|
||||
|
||||
std::string rest{target.substr(prefixLen)};
|
||||
|
||||
if (auto const cut = rest.find_first_of("?#"); cut != std::string::npos)
|
||||
rest.erase(cut);
|
||||
|
||||
while (!rest.empty() && rest.back() == '/')
|
||||
rest.pop_back();
|
||||
|
||||
// A base58 r-address contains no path separators; reject anything with
|
||||
// further path structure rather than silently taking the first segment.
|
||||
if (rest.empty() || rest.find('/') != std::string::npos)
|
||||
return std::nullopt;
|
||||
|
||||
return parseBase58<AccountID>(rest);
|
||||
}
|
||||
|
||||
void
|
||||
ServerHandler::processPWARequest(
|
||||
std::shared_ptr<Session> const& session,
|
||||
AccountID const& account)
|
||||
{
|
||||
auto const j = app_.journal("PWA");
|
||||
auto out = makeOutput(*session);
|
||||
|
||||
// Serving third-party HTML from the same origin as the JSON-RPC endpoint
|
||||
// is dangerous: script in the document could POST commands back to the
|
||||
// node. Two things guard against that.
|
||||
//
|
||||
// First, refuse outright if this connection would be granted elevated
|
||||
// privileges, or if the port is password protected (in which case a
|
||||
// browser would attach the credentials to same-origin requests).
|
||||
if (!session->port().user.empty() || !session->port().password.empty())
|
||||
{
|
||||
HTTPReply(403, "Forbidden", out, j);
|
||||
session->close(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isUnlimited(requestRole(
|
||||
Role::GUEST,
|
||||
session->port(),
|
||||
Json::objectValue,
|
||||
session->remoteAddress().at_port(0),
|
||||
"")))
|
||||
{
|
||||
JLOG(j.debug()) << "refusing to serve PWA content to a privileged "
|
||||
<< "connection from "
|
||||
<< session->remoteAddress().to_string();
|
||||
HTTPReply(403, "Forbidden", out, j);
|
||||
session->close(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Second, sandbox the document. "sandbox allow-scripts" without
|
||||
// allow-same-origin puts the page in an opaque origin, so its scripts
|
||||
// cannot reach this node's RPC endpoint at all. Note the trade-off: an
|
||||
// opaque origin has no storage and no service workers, so a document
|
||||
// served this way is not a fully functional PWA. Hosting one properly
|
||||
// needs an origin per account, which is out of scope here.
|
||||
static std::vector<std::string> const securityHeaders{
|
||||
"Content-Security-Policy: sandbox allow-scripts allow-forms "
|
||||
"allow-popups",
|
||||
"X-Content-Type-Options: nosniff",
|
||||
"X-Frame-Options: DENY",
|
||||
"Referrer-Policy: no-referrer",
|
||||
"Cross-Origin-Resource-Policy: same-origin"};
|
||||
|
||||
auto const ledger = app_.getLedgerMaster().getClosedLedger();
|
||||
if (!ledger)
|
||||
{
|
||||
HTTPReply(503, "Service Unavailable", out, j);
|
||||
session->close(true);
|
||||
return;
|
||||
}
|
||||
|
||||
auto const sle = ledger->read(keylet::appLoader(account));
|
||||
if (!sle || !sle->isFieldPresent(sfAppLoader))
|
||||
{
|
||||
HTTPReply(404, "Not Found", out, j);
|
||||
session->close(true);
|
||||
return;
|
||||
}
|
||||
|
||||
Blob const blob = sle->getFieldVL(sfAppLoader);
|
||||
|
||||
JLOG(j.trace()) << "serving AppLoader for " << toBase58(account) << " ("
|
||||
<< blob.size() << " bytes) from ledger " << ledger->seq();
|
||||
|
||||
HTTPReply(
|
||||
200,
|
||||
std::string(blob.begin(), blob.end()),
|
||||
out,
|
||||
j,
|
||||
"text/html; charset=utf-8",
|
||||
securityHeaders);
|
||||
|
||||
session->close(true);
|
||||
}
|
||||
|
||||
void
|
||||
ServerHandler::onRequest(Session& session)
|
||||
{
|
||||
@@ -306,6 +424,35 @@ ServerHandler::onRequest(Session& session)
|
||||
return;
|
||||
}
|
||||
|
||||
// PWA content: GET /pwa/<account>. Handled ahead of the RPC path because
|
||||
// it is a plain document request, not a JSON-RPC call.
|
||||
if (app_.config().PWA_ENABLED &&
|
||||
session.request().method() == boost::beast::http::verb::get)
|
||||
{
|
||||
if (auto const account = parsePWATarget(session.request().target()))
|
||||
{
|
||||
std::shared_ptr<Session> detachedSession = session.detach();
|
||||
auto const postResult = m_jobQueue.postCoro(
|
||||
jtCLIENT_RPC,
|
||||
"PWA-Client",
|
||||
[this, detachedSession, account = *account](
|
||||
std::shared_ptr<JobQueue::Coro>) {
|
||||
processPWARequest(detachedSession, account);
|
||||
});
|
||||
|
||||
if (postResult == nullptr)
|
||||
{
|
||||
HTTPReply(
|
||||
503,
|
||||
"Service Unavailable",
|
||||
makeOutput(*detachedSession),
|
||||
app_.journal("PWA"));
|
||||
detachedSession->close(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check user/password authorization
|
||||
if (!authorized(session.port(), build_map(session.request())))
|
||||
{
|
||||
|
||||
@@ -26,17 +26,10 @@
|
||||
#include <xrpld/rpc/GRPCHandlers.h>
|
||||
#include <xrpld/rpc/detail/RPCHelpers.h>
|
||||
#include <xrpld/rpc/detail/TransactionSign.h>
|
||||
#include <xrpl/json/json_reader.h>
|
||||
#include <xrpl/json/json_writer.h>
|
||||
#include <xrpl/protocol/ErrorCodes.h>
|
||||
#include <xrpl/protocol/PublicKey.h>
|
||||
#include <xrpl/protocol/RPCErr.h>
|
||||
#include <xrpl/protocol/SField.h>
|
||||
#include <xrpl/protocol/STParsedJSON.h>
|
||||
#include <xrpl/resource/Fees.h>
|
||||
|
||||
#include <xrpl/protocol/JSONTxSignatures.h>
|
||||
|
||||
namespace ripple {
|
||||
|
||||
static NetworkOPs::FailHard
|
||||
@@ -90,8 +83,7 @@ doInject(RPC::JsonContext& context)
|
||||
}
|
||||
|
||||
// {
|
||||
// tx_blob: <string> XOR tx_json: <object>
|
||||
// XOR { tx: <json text>, signature: <hex> },
|
||||
// tx_blob: <string> XOR tx_json: <object>,
|
||||
// secret: <secret>
|
||||
// }
|
||||
Json::Value
|
||||
@@ -99,18 +91,7 @@ doSubmit(RPC::JsonContext& context)
|
||||
{
|
||||
context.loadType = Resource::feeMediumBurdenRPC;
|
||||
|
||||
bool const hasJsonTx = context.ledgerMaster.getCurrentLedger()->rules().enabled(featureJsonTx);
|
||||
|
||||
bool const isJsonTx = !context.params.isMember(jss::tx_blob) &&
|
||||
context.params.isMember(jss::tx) &&
|
||||
context.params.isMember(jss::sig);
|
||||
|
||||
if (isJsonTx && !hasJsonTx)
|
||||
return RPC::make_error(
|
||||
rpcNOT_SUPPORTED, "JsonTx is not enabled yet.");
|
||||
|
||||
|
||||
if (!context.params.isMember(jss::tx_blob) && !isJsonTx)
|
||||
if (!context.params.isMember(jss::tx_blob))
|
||||
{
|
||||
auto const failType = getFailHard(context);
|
||||
|
||||
@@ -138,73 +119,18 @@ doSubmit(RPC::JsonContext& context)
|
||||
|
||||
Json::Value jvResult;
|
||||
|
||||
std::optional<Blob> ret;
|
||||
if (!isJsonTx)
|
||||
{
|
||||
ret = strUnHex(context.params[jss::tx_blob].asString());
|
||||
auto ret = strUnHex(context.params[jss::tx_blob].asString());
|
||||
|
||||
if (!ret || !ret->size())
|
||||
return rpcError(rpcINVALID_PARAMS);
|
||||
}
|
||||
if (!ret || !ret->size())
|
||||
return rpcError(rpcINVALID_PARAMS);
|
||||
|
||||
SerialIter sitTrans(makeSlice(*ret));
|
||||
|
||||
std::shared_ptr<STTx const> stTx;
|
||||
|
||||
try
|
||||
{
|
||||
if (!isJsonTx)
|
||||
{
|
||||
SerialIter sitTrans(makeSlice(*ret));
|
||||
stTx = std::make_shared<STTx const>(std::ref(sitTrans));
|
||||
}
|
||||
else
|
||||
{
|
||||
std::string const raw = context.params[jss::tx].asString();
|
||||
auto const [san, diff] = sanitize_jsontx(raw);
|
||||
auto const sig = strUnHex(context.params[jss::sig].asString());
|
||||
if (!sig || sig->empty())
|
||||
throw std::runtime_error("JsonTx: bad signature");
|
||||
|
||||
Json::Value jv;
|
||||
if (Json::Reader r; !r.parse(san, jv))
|
||||
throw std::runtime_error("JsonTx: unparsable canonical form");
|
||||
|
||||
// The preimage carries the key but not the signature over itself.
|
||||
for (auto const& n :
|
||||
{sfTxnSignature.fieldName, sfSigners.fieldName})
|
||||
if (jv.isMember(n))
|
||||
throw std::runtime_error(
|
||||
"JsonTx: " + n + " must not appear in tx");
|
||||
if (!jv.isMember(sfSigningPubKey.fieldName))
|
||||
throw std::runtime_error("JsonTx: tx must carry SigningPubKey");
|
||||
|
||||
// Hand the parser the u64 rather than teaching STUInt64 a second
|
||||
// spelling; the ISO form only ever exists in the preimage.
|
||||
std::optional<std::uint64_t> ms;
|
||||
if (jv.isMember(sfTime.fieldName))
|
||||
{
|
||||
ms = jsontx_iso(jv[sfTime.fieldName].asString());
|
||||
jv.removeMember(sfTime.fieldName);
|
||||
}
|
||||
|
||||
STParsedJSONObject parsed("tx_json", jv);
|
||||
if (!parsed.object)
|
||||
throw std::runtime_error(
|
||||
parsed.error[jss::error_message].asString());
|
||||
if (ms)
|
||||
parsed.object->setFieldU64(sfTime, *ms);
|
||||
parsed.object->setFieldVL(sfTxnSignature, *sig);
|
||||
stTx = std::make_shared<STTx const>(std::move(*parsed.object));
|
||||
|
||||
// Round-trip the binary codec, then run the exact check a relaying
|
||||
// node will run, so this path cannot accept anything the network
|
||||
// would later reject.
|
||||
Serializer s;
|
||||
stTx->add(s);
|
||||
SerialIter si(s.slice());
|
||||
STTx const rt{si};
|
||||
if (jsontx_verify(rt, diff) != raw)
|
||||
throw std::runtime_error("JsonTx: does not round-trip");
|
||||
}
|
||||
stTx = std::make_shared<STTx const>(std::ref(sitTrans));
|
||||
}
|
||||
catch (std::exception& e)
|
||||
{
|
||||
@@ -215,9 +141,7 @@ doSubmit(RPC::JsonContext& context)
|
||||
}
|
||||
|
||||
{
|
||||
// JsonTx signs the plaintext preimage rather than the binary one, so
|
||||
// the binary TxnSignature check is satisfied out of band above.
|
||||
if (!context.app.checkSigs() || isJsonTx)
|
||||
if (!context.app.checkSigs())
|
||||
forceValidity(
|
||||
context.app.getHashRouter(),
|
||||
stTx->getTransactionID(),
|
||||
|
||||
Reference in New Issue
Block a user