diff --git a/stellar-payment-platform/integration.test.js b/stellar-payment-platform/integration.test.js index 597be6e3..2cb81fe2 100644 --- a/stellar-payment-platform/integration.test.js +++ b/stellar-payment-platform/integration.test.js @@ -225,4 +225,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.test.js b/stellar-payment-platform/server.test.js index d5044c65..fc9401c8 100644 --- a/stellar-payment-platform/server.test.js +++ b/stellar-payment-platform/server.test.js @@ -729,6 +729,168 @@ describe('POST /register — memo validation', () => { }); }); +describe('POST /register — memo XSS sanitization', () => { + let request; + let app; + let prisma; + + const VALID_ADDRESS = 'GBCDEFGHIJKLMNOPQRSTUVWXYZ'; + + beforeEach(() => { + jest.resetModules(); + ({ app } = require('./server')); + ({ prisma } = require('./prismaClient')); + request = require('supertest'); + + prisma.user.findUnique.mockReset(); + prisma.user.create.mockReset(); + prisma.user.findUnique.mockResolvedValue(null); + prisma.user.create.mockResolvedValue({ + username: 'xssuser*localhost', + address: VALID_ADDRESS, + memoType: 'text', + memo: '', + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('strips ALL HTML tags '; + 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;