mirror of
https://github.com/Xahau/xahaud.git
synced 2026-08-27 10:10:55 +00:00
Compare commits
1 Commits
reduce-mag
...
pwabootloa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2637f6ae26 |
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);
|
||||
|
||||
@@ -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,6 +34,7 @@
|
||||
// If you add an amendment here, then do not forget to increment `numFeatures`
|
||||
// in include/xrpl/protocol/Feature.h.
|
||||
|
||||
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.
|
||||
|
||||
@@ -210,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)
|
||||
@@ -293,6 +294,7 @@ TYPED_SFIELD(sfAssetClass, VL, 29)
|
||||
TYPED_SFIELD(sfProvider, VL, 30)
|
||||
TYPED_SFIELD(sfMPTokenMetadata, VL, 31)
|
||||
TYPED_SFIELD(sfCredentialType, VL, 32)
|
||||
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. */
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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())))
|
||||
{
|
||||
|
||||
@@ -22,8 +22,12 @@
|
||||
#include <xrpld/app/main/Application.h>
|
||||
#include <xrpld/app/misc/AmendmentTable.h>
|
||||
#include <xrpld/app/misc/NetworkOPs.h>
|
||||
#include <xrpld/rpc/detail/TransactionSign.h>
|
||||
#include <xrpl/json/json_value.h>
|
||||
#include <xrpl/json/json_writer.h>
|
||||
#include <xrpl/protocol/LedgerFormats.h>
|
||||
#include <xrpl/protocol/RPCErr.h>
|
||||
#include <xrpl/protocol/SField.h>
|
||||
#include <xrpl/protocol/TxFlags.h>
|
||||
#include <xrpl/protocol/digest.h>
|
||||
#include <xrpl/protocol/jss.h>
|
||||
@@ -31,6 +35,14 @@
|
||||
#include <magic_enum.hpp>
|
||||
#include <sstream>
|
||||
|
||||
#define MAGIC_ENUM(x, _min, _max) \
|
||||
template <> \
|
||||
struct magic_enum::customize::enum_range<x> \
|
||||
{ \
|
||||
static constexpr int min = _min; \
|
||||
static constexpr int max = _max; \
|
||||
};
|
||||
|
||||
#define MAGIC_ENUM_16(x) \
|
||||
template <> \
|
||||
struct magic_enum::customize::enum_range<x> \
|
||||
@@ -46,6 +58,15 @@
|
||||
static constexpr bool is_flags = true; \
|
||||
};
|
||||
|
||||
MAGIC_ENUM(ripple::SerializedTypeID, -2, 10004);
|
||||
MAGIC_ENUM(ripple::LedgerEntryType, 0, 255);
|
||||
MAGIC_ENUM(ripple::TELcodes, -399, 300);
|
||||
MAGIC_ENUM(ripple::TEMcodes, -299, -200);
|
||||
MAGIC_ENUM(ripple::TEFcodes, -199, -100);
|
||||
MAGIC_ENUM(ripple::TERcodes, -99, -1);
|
||||
MAGIC_ENUM(ripple::TEScodes, 0, 1);
|
||||
MAGIC_ENUM(ripple::TECcodes, 100, 255);
|
||||
MAGIC_ENUM_16(ripple::TxType);
|
||||
MAGIC_ENUM_FLAG(ripple::UniversalFlags);
|
||||
MAGIC_ENUM_FLAG(ripple::AccountSetFlags);
|
||||
MAGIC_ENUM_FLAG(ripple::OfferCreateFlags);
|
||||
@@ -171,19 +192,24 @@ private:
|
||||
|
||||
ret[jss::TYPES]["Done"] = -1;
|
||||
std::map<int32_t, std::string> type_map{{-1, "Done"}};
|
||||
for (auto const& [rawName, typeValue] : sTypeMap)
|
||||
for (auto const& entry : magic_enum::enum_entries<SerializedTypeID>())
|
||||
{
|
||||
std::string typeName =
|
||||
translate(std::string(rawName).substr(4) /* remove STI_ */);
|
||||
ret[jss::TYPES][typeName] = typeValue;
|
||||
type_map[typeValue] = typeName;
|
||||
const auto name = entry.second;
|
||||
std::string type_name =
|
||||
translate(name.data() + 4 /* remove STI_ */);
|
||||
int32_t type_value = static_cast<int32_t>(entry.first);
|
||||
ret[jss::TYPES][type_name] = type_value;
|
||||
type_map[type_value] = type_name;
|
||||
}
|
||||
|
||||
ret[jss::LEDGER_ENTRY_TYPES] = Json::objectValue;
|
||||
ret[jss::LEDGER_ENTRY_TYPES][jss::Invalid] = -1;
|
||||
for (auto const& f : LedgerFormats::getInstance())
|
||||
for (auto const& entry : magic_enum::enum_entries<LedgerEntryType>())
|
||||
{
|
||||
ret[jss::LEDGER_ENTRY_TYPES][f.getName()] = f.getType();
|
||||
const auto name = entry.second;
|
||||
std::string type_name = translate(name.data() + 2 /* remove lt_ */);
|
||||
int32_t type_value = static_cast<int32_t>(entry.first);
|
||||
ret[jss::LEDGER_ENTRY_TYPES][type_name] = type_value;
|
||||
}
|
||||
|
||||
ret[jss::FIELDS] = Json::arrayValue;
|
||||
@@ -300,16 +326,71 @@ private:
|
||||
}
|
||||
|
||||
ret[jss::TRANSACTION_RESULTS] = Json::objectValue;
|
||||
for (auto const& [code, terInfo] : transResults())
|
||||
for (auto const& entry : magic_enum::enum_entries<TELcodes>())
|
||||
{
|
||||
ret[jss::TRANSACTION_RESULTS][terInfo.first] = code;
|
||||
const auto name = entry.second;
|
||||
ret[jss::TRANSACTION_RESULTS][STR(name)] =
|
||||
static_cast<int32_t>(entry.first);
|
||||
}
|
||||
for (auto const& entry : magic_enum::enum_entries<TEMcodes>())
|
||||
{
|
||||
const auto name = entry.second;
|
||||
ret[jss::TRANSACTION_RESULTS][STR(name)] =
|
||||
static_cast<int32_t>(entry.first);
|
||||
}
|
||||
for (auto const& entry : magic_enum::enum_entries<TEFcodes>())
|
||||
{
|
||||
const auto name = entry.second;
|
||||
ret[jss::TRANSACTION_RESULTS][STR(name)] =
|
||||
static_cast<int32_t>(entry.first);
|
||||
}
|
||||
for (auto const& entry : magic_enum::enum_entries<TERcodes>())
|
||||
{
|
||||
const auto name = entry.second;
|
||||
ret[jss::TRANSACTION_RESULTS][STR(name)] =
|
||||
static_cast<int32_t>(entry.first);
|
||||
}
|
||||
for (auto const& entry : magic_enum::enum_entries<TEScodes>())
|
||||
{
|
||||
const auto name = entry.second;
|
||||
ret[jss::TRANSACTION_RESULTS][STR(name)] =
|
||||
static_cast<int32_t>(entry.first);
|
||||
}
|
||||
for (auto const& entry : magic_enum::enum_entries<TECcodes>())
|
||||
{
|
||||
const auto name = entry.second;
|
||||
ret[jss::TRANSACTION_RESULTS][STR(name)] =
|
||||
static_cast<int32_t>(entry.first);
|
||||
}
|
||||
|
||||
auto const translate_tt = [](std::string inp) -> std::string {
|
||||
if (inp == "Amendment")
|
||||
return "EnableAmendment";
|
||||
if (inp == "Fee")
|
||||
return "SetFee";
|
||||
if (inp == "PaychanClaim")
|
||||
return "PaymentChannelClaim";
|
||||
if (inp == "PaychanCreate")
|
||||
return "PaymentChannelCreate";
|
||||
if (inp == "PaychanFund")
|
||||
return "PaymentChannelFund";
|
||||
if (inp == "RegularKeySet")
|
||||
return "SetRegularKey";
|
||||
if (inp == "HookSet")
|
||||
return "SetHook";
|
||||
if (inp == "RemarksSet")
|
||||
return "SetRemarks";
|
||||
return inp;
|
||||
};
|
||||
|
||||
ret[jss::TRANSACTION_TYPES] = Json::objectValue;
|
||||
ret[jss::TRANSACTION_TYPES][jss::Invalid] = -1;
|
||||
for (auto const& f : TxFormats::getInstance())
|
||||
for (auto const& entry : magic_enum::enum_entries<TxType>())
|
||||
{
|
||||
ret[jss::TRANSACTION_TYPES][f.getName()] = f.getType();
|
||||
const auto name = entry.second;
|
||||
std::string type_name = translate_tt(translate(name.data() + 2));
|
||||
int32_t type_value = static_cast<int32_t>(entry.first);
|
||||
ret[jss::TRANSACTION_TYPES][type_name] = type_value;
|
||||
}
|
||||
|
||||
// Transaction Flags:
|
||||
|
||||
Reference in New Issue
Block a user