From e963b5608d3c0cf191b1c0a8fe8d7411bebae670 Mon Sep 17 00:00:00 2001 From: ojuotimi932 Date: Mon, 27 Jul 2026 15:23:33 +0000 Subject: [PATCH] fix(security): strip HTML/script tags from user memos before DB save Apply xss() with { whiteList: {}, stripIgnoreTag: true, stripIgnoreTagBody: ['script'] } to the memo field in both /register endpoints (legacy server.js and v1 userRoutes.js) so user-provided memos are stripped of HTML and script tags before being persisted. Closes: memo XSS sanitization issue (stored XSS prevention). --- stellar-payment-platform/integration.test.js | 59 +++++++ stellar-payment-platform/server.js | 14 +- stellar-payment-platform/server.test.js | 162 ++++++++++++++++++ .../src/routes/v1/userRoutes.js | 14 +- 4 files changed, 247 insertions(+), 2 deletions(-) diff --git a/stellar-payment-platform/integration.test.js b/stellar-payment-platform/integration.test.js index 6265956f..0c4e8f69 100644 --- a/stellar-payment-platform/integration.test.js +++ b/stellar-payment-platform/integration.test.js @@ -220,4 +220,63 @@ describe('API Integration Lifecycle Suite', () => { expect(duplicateRes.status).toBe(409); expect(duplicateRes.body).toHaveProperty("error"); }); + + test('v1 /register sanitizes memo of type text — strips HTML/script tags before save', async () => { + const address = 'GBDQD3WTQ6W2VQ2W4V74UZ5WYF6B72GZ6EHD7I3L3WYH357Y4K5H3E4W'; + + // Mixed payload: inner safe text + dangerous tag — inner text must remain + // in the saved row, dangerous tags must be removed. + const malicious = 'paymentx'; + + const res = await request(app) + .post('/api/v1/register') + .send({ + username: 'memo_xss', + address, + memo_type: 'text', + memo: malicious, + }); + + expect(res.status).toBe(201); + + // Saved/returned memo must not contain any angle brackets, script tags, + // javascript: scheme, etc. Inner text 'payment' is preserved. + expect(res.body.memo).not.toMatch(/[<>]/); + expect(res.body.memo).not.toMatch(/script/i); + expect(res.body.memo).not.toMatch(/javascript:/i); + expect(res.body.memo).toContain('payment'); + + // Lookup confirms the sanitized value is what is actually stored. + const lookup = await request(app).get(`/api/v1/lookup?address=${address}`); + expect(lookup.status).toBe(200); + expect(lookup.body).toHaveProperty('username', 'memo_xss*localhost'); + + // Federation response echoes the sanitized memo. + const fed = await request(app).get(`/api/v1/federation?q=GBDQD3WTQ6W2VQ2W4V74UZ5WYF6B72GZ6EHD7I3L3WYH357Y4K5H3E4W&type=id`); + expect(fed.status).toBe(200); + expect(fed.body.memo_type).toBe('text'); + expect(fed.body.memo).not.toMatch(/[<>]/); + expect(fed.body.memo).not.toMatch(/script/i); + }); + + test('v1 /register rejects memo whose sanitization removes all content', async () => { + const address = 'GFFZF43FJB7Q5K6SWFKJQTNAXYVF7KAVN4GYJ3ZU3VZMYR5SX5QGYBS3'; + + const malicious = ''; + + const res = await request(app) + .post('/api/v1/register') + .send({ + username: 'memo_xss_empty', + address, + memo_type: 'text', + memo: malicious, + }); + + // Sanitization emptied the memo, validateMemo rejects; no row is created. + expect(res.status).toBe(400); + + const lookup = await request(app).get(`/api/v1/lookup?address=${address}`); + expect(lookup.status).toBe(404); + }); }); diff --git a/stellar-payment-platform/server.js b/stellar-payment-platform/server.js index 7d57ca7e..5a687bfd 100644 --- a/stellar-payment-platform/server.js +++ b/stellar-payment-platform/server.js @@ -448,7 +448,19 @@ app.post('/register', idempotencyMiddleware(redisClient), async (req, res, next) const username = normalizeNameTag(safeUsername); const address = typeof req.body.address === 'string' ? req.body.address.trim() : ''; const memoType = typeof req.body.memo_type === 'string' ? req.body.memo_type.trim() : undefined; - const memo = typeof req.body.memo === 'string' ? req.body.memo.trim() : undefined; + // #memo-sanitization — strip ALL HTML/script tags from user-provided memos + // before they reach the database. We must explicitly enable + // `stripIgnoreTag: true` because the default `xss()` behaviour is to + // *escape* tags as `<`/`>` rather than strip them, which would inflate + // the string past the 28-byte Stellar text-memo limit and reject legitimate + // input. `stripIgnoreTagBody: ['script']` also removes the body inside + // `'; + const expected = 'safe text'; + prisma.user.create.mockResolvedValue({ + username: 'xssuser*localhost', + address: VALID_ADDRESS, + memoType: 'text', + memo: expected, + }); + + const res = await request(app) + .post('/register') + .send({ username: 'xssuser', address: VALID_ADDRESS, memo_type: 'text', memo: dangerous }); + + expect(res.status).toBe(201); + expect(res.body.memo).not.toMatch(/ -> '' (only HTML, no inner text) + const dangerous = ''; + const res = await request(app) + .post('/register') + .send({ username: 'xssuser', address: VALID_ADDRESS, memo_type: 'text', memo: dangerous }); + + // The memo becomes "" after sanitization, and validateMemo rejects a + // memoType with an empty memo (never reaches the DB). + expect(res.status).toBe(400); + expect(prisma.user.create).not.toHaveBeenCalled(); + }); + + test('leaves numeric id memo unchanged (id is strict digits)', async () => { + const numeric = '12345678'; + prisma.user.create.mockResolvedValue({ + username: 'xssuser*localhost', + address: VALID_ADDRESS, + memoType: 'id', + memo: numeric, + }); + + const res = await request(app) + .post('/register') + .send({ username: 'xssuser', address: VALID_ADDRESS, memo_type: 'id', memo: numeric }); + + expect(res.status).toBe(201); + expect(res.body.memo).toBe(numeric); + }); + + test('leaves hex hash memo unchanged (hash is strict hex)', async () => { + const hex = 'a'.repeat(64); + prisma.user.create.mockResolvedValue({ + username: 'xssuser*localhost', + address: VALID_ADDRESS, + memoType: 'hash', + memo: hex, + }); + + const res = await request(app) + .post('/register') + .send({ username: 'xssuser', address: VALID_ADDRESS, memo_type: 'hash', memo: hex }); + + expect(res.status).toBe(201); + expect(res.body.memo).toBe(hex); + }); +}); + describe('GET /federation — memo fields in response', () => { let request; let app; diff --git a/stellar-payment-platform/src/routes/v1/userRoutes.js b/stellar-payment-platform/src/routes/v1/userRoutes.js index 969819ea..8857ff4a 100644 --- a/stellar-payment-platform/src/routes/v1/userRoutes.js +++ b/stellar-payment-platform/src/routes/v1/userRoutes.js @@ -40,7 +40,19 @@ router.post('/register', async (req, res, next) => { const username = normalizeNameTag(safeUsername); const address = typeof req.body.address === 'string' ? req.body.address.trim() : ''; const memoType = typeof req.body.memo_type === 'string' ? req.body.memo_type.trim() : undefined; - const memo = typeof req.body.memo === 'string' ? req.body.memo.trim() : undefined; + // #memo-sanitization — strip ALL HTML/script tags from user-provided memos + // before they reach the database. We must explicitly enable + // `stripIgnoreTag: true` because the default `xss()` behaviour is to + // *escape* tags as `<`/`>` rather than strip them, which would inflate + // the string past the 28-byte Stellar text-memo limit and reject legitimate + // input. `stripIgnoreTagBody: ['script']` also removes the body inside + // `