[econ] cap message len just in case

This commit is contained in:
Devin Zuczek
2026-08-26 11:24:30 -04:00
parent 19e1d48807
commit 35350ffa19
2 changed files with 53 additions and 9 deletions
+34 -9
View File
@@ -656,21 +656,46 @@ const COACH_ACCOUNT_ID = 1
const DEFAULT_GIFT_MESSAGE = 'A gift for you <3'
/**
* The note a gift box carries, masked the way every other string a player typed is.
* The most a gift note may carry — the same 150 the client's own input field stops typing at,
* so a longer one is a client that ignored its own limit rather than a longer note.
*/
const MAX_GIFT_MESSAGE_LENGTH = 150
/**
* The note a gift box carries: capped at {@link MAX_GIFT_MESSAGE_LENGTH}, then masked the way
* every other string a player typed is.
*
* The buyer writes this and someone ELSE reads it — off the box, out of the hub frame, and
* for as long as the box goes unopened — so a gift is a way to put text in front of a player
* who never chose to hear from you. The same filter runs in `chat` for the same reason:
* nothing obliges a client to have called `POST /api/sanitize/v1` first, and this is the last
* point before the note is stored.
* who never chose to hear from you. That is why both rules are re-applied here: nothing
* obliges a client to have called `POST /api/sanitize/v1` first, or to have honoured its own
* character limit, and this is the last point before the note is stored. `chat` censors its
* messages again for the same reason.
*
* Masking (not refusing) matches the rest of this server: the purchase goes through, the
* swear comes out as asterisks, and the buyer is never told their gift was rejected. Blocked
* characters are deliberately left alone, as in chat — a note is emoji-carrying text, and
* stripping format characters would break the joiners inside a multi-person emoji.
* Trimming and masking (rather than refusing) matches the rest of this server: the purchase
* goes through, the swear comes out as asterisks, the overrun is dropped, and the buyer is
* never told their gift was rejected. Blocked characters are deliberately left alone, as in
* chat — a note is emoji-carrying text, and stripping format characters would break the
* joiners inside a multi-person emoji.
*
* The cap is applied FIRST so what gets filtered is what gets stored: cutting a word in half
* can leave a swear where there wasn't one ("assassin" ending as "ass"), and cutting after
* the mask would leave a half-masked word instead. Both counts are UTF-16 units, as the
* client's are — a trailing lone surrogate is dropped rather than stored as half a character.
*/
function giftMessage(gift: GiftRequest | null): string {
return typeof gift?.Message === 'string' ? censorSwears(gift.Message) : DEFAULT_GIFT_MESSAGE
if (typeof gift?.Message !== 'string') return DEFAULT_GIFT_MESSAGE
return censorSwears(truncateGiftMessage(gift.Message))
}
/** `message` cut to the cap, never through the middle of a surrogate pair. */
function truncateGiftMessage(message: string): string {
if (message.length <= MAX_GIFT_MESSAGE_LENGTH) return message
const cut = message.slice(0, MAX_GIFT_MESSAGE_LENGTH)
const last = cut.charCodeAt(cut.length - 1)
// A high surrogate at the end lost its partner to the cut, and alone it is not a
// character at all — the client would draw the replacement glyph for it.
return last >= 0xd800 && last <= 0xdbff ? cut.slice(0, -1) : cut
}
/**
@@ -1489,6 +1489,25 @@ describe('econ endpoints', () => {
expect(received?.payload).toMatchObject({ Message: masked })
})
test('POST /api/storefronts/v2/buyItem caps the gift message at the clients 150 characters', async () => {
await seedAccount(209, 'LongNoteReceiver')
await drainFrames()
// The client's input field stops at 150, so a 200-character note is a client that
// ignored its own limit — the overrun is dropped, the purchase still goes through.
const long = 'a'.repeat(140) + 'b'.repeat(60)
const res = await giftBackpack('335', { ToPlayerId: 209, Message: long, Anonymous: false })
expect(res.status).toBe(200)
const [gift] = await pendingGifts('209')
expect(gift?.Message).toBe(long.slice(0, 150))
expect((gift?.Message as string).length).toBe(150)
// An emoji straddling the cut is dropped whole rather than stored as half a character.
const emoji = `${'x'.repeat(149)}😀tail`
const second = await giftBackpack('336', { ToPlayerId: 209, Message: emoji, Anonymous: false })
expect(second.status).toBe(200)
const notes = (await pendingGifts('209')).map((box) => box.Message)
expect(notes).toContain('x'.repeat(149))
})
test('POST /api/storefronts/v2/buyItem 404s a gift to a player that does not exist', async () => {
await drainFrames()
const res = await giftBackpack('332', { ToPlayerId: 999999, Message: 'hi', Anonymous: false })