Compare commits

..

1 Commits

Author SHA1 Message Date
Richard Holland
2637f6ae26 init 2026-08-26 21:56:18 +10:00
33 changed files with 1331 additions and 712 deletions

View File

@@ -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)

View File

@@ -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')

View 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

View File

@@ -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);

View File

@@ -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

View File

@@ -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;

View File

@@ -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)

View File

@@ -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.

View File

@@ -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)

View File

@@ -74,6 +74,7 @@ TRANSACTION(ttACCOUNT_SET, 3, AccountSet, ({
{sfTickSize, soeOPTIONAL},
{sfNFTokenMinter, soeOPTIONAL},
{sfHookStateScale, soeOPTIONAL},
{sfAppLoader, soeOPTIONAL},
}))
/** This transaction type cancels an existing escrow. */

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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
{

View File

@@ -49,8 +49,6 @@ TxFormats::TxFormats()
{sfNetworkID, soeOPTIONAL},
{sfHookParameters, soeOPTIONAL},
{sfHookName, soeOPTIONAL},
{sfTime, soeOPTIONAL},
{sfJsonTxDelta, soeOPTIONAL},
};
#pragma push_macro("UNWRAP")

View File

@@ -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());

View 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

View File

@@ -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(

View File

@@ -612,6 +612,7 @@ LedgerEntryTypesMatch::visitEntry(
case ltMPTOKEN:
case ltCREDENTIAL:
case ltPERMISSIONED_DOMAIN:
case ltAPP_LOADER:
break;
default:
invalidTypeAdded_ = true;

View File

@@ -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.";

View File

@@ -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
//

View File

@@ -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);

View File

@@ -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;

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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.";

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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"

View File

@@ -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
{

View File

@@ -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,

View File

@@ -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())))
{

View File

@@ -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(),