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;