Skip to content

Commit 1193203

Browse files
committed
fix(streams): enforce sender ownership on createStream (#809)
createStream never read req.user, so any authenticated wallet could POST an arbitrary sender or flip another owner's cancelled stream back to active via the upsert update branch (keyed only on client-supplied streamId). - 401 when unauthenticated; 403 when JWT subject != body sender - validate sender/recipient/tokenAddress before any DB write - reject (403) upsert against a streamId already owned by another wallet, so the update branch can never reactivate someone else's stream - add controller tests covering non-owner reactivation and field validation
1 parent 241c87d commit 1193203

3 files changed

Lines changed: 90 additions & 3 deletions

File tree

backend/src/controllers/stream.controller.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,34 @@ function sumStringI128(values: string[]): string {
6666
*/
6767
export const createStream = async (req: Request, res: Response) => {
6868
try {
69+
const callerPublicKey = (req as AuthenticatedRequest).user?.publicKey;
70+
if (!callerPublicKey) {
71+
return res.status(401).json({ error: 'Unauthorized', message: 'Authentication required' });
72+
}
73+
6974
const { streamId, sender, recipient, tokenAddress, ratePerSecond, depositedAmount, startTime } = req.body;
7075

76+
// Issue #809: validate identity fields before any DB write.
77+
if (typeof sender !== 'string' || sender.length === 0) {
78+
return res.status(400).json({ error: 'Invalid sender: must be a non-empty string' });
79+
}
80+
if (typeof recipient !== 'string' || recipient.length === 0) {
81+
return res.status(400).json({ error: 'Invalid recipient: must be a non-empty string' });
82+
}
83+
if (typeof tokenAddress !== 'string' || tokenAddress.length === 0) {
84+
return res.status(400).json({ error: 'Invalid tokenAddress: must be a non-empty string' });
85+
}
86+
87+
// Issue #809: the authenticated wallet may only create/modify streams it owns.
88+
// Without this, any logged-in wallet could POST an arbitrary `sender` and have
89+
// it persisted, or flip another owner's cancelled stream back to active.
90+
if (sender !== callerPublicKey) {
91+
return res.status(403).json({
92+
error: 'Forbidden',
93+
message: 'sender must match the authenticated wallet',
94+
});
95+
}
96+
7197
const parsedStreamId = Number.parseInt(streamId, 10);
7298
const parsedStartTime = Number.parseInt(startTime, 10);
7399
const parsedRatePerSecond = BigInt(ratePerSecond);
@@ -91,6 +117,18 @@ export const createStream = async (req: Request, res: Response) => {
91117

92118
const endTime = parsedStartTime + Number(parsedDepositedAmount / parsedRatePerSecond);
93119

120+
// Issue #809: never let the upsert update branch touch a stream owned by a
121+
// different wallet. The caller is already proven to equal `sender` above, so
122+
// reject any existing row whose sender differs — this blocks reactivating or
123+
// overwriting someone else's (e.g. cancelled) stream.
124+
const existing = await prisma.stream.findUnique({ where: { streamId: parsedStreamId } });
125+
if (existing && existing.sender !== callerPublicKey) {
126+
return res.status(403).json({
127+
error: 'Forbidden',
128+
message: 'Cannot modify a stream owned by another wallet',
129+
});
130+
}
131+
94132
const stream = await prisma.stream.upsert({
95133
where: { streamId: parsedStreamId },
96134
update: {

backend/tests/stream.controller.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ describe('Stream Controller', () => {
6262
query: {},
6363
params: {},
6464
};
65+
// Authenticated caller matches body.sender by default (Issue #809).
66+
(req as any).user = { publicKey: 'GSENDER' };
6567
res = {
6668
status: vi.fn().mockReturnThis(),
6769
json: vi.fn().mockReturnThis(),
@@ -70,6 +72,7 @@ describe('Stream Controller', () => {
7072

7173
describe('createStream', () => {
7274
it('should create a stream successfully', async () => {
75+
(prisma.stream.findUnique as any).mockResolvedValue(null);
7376
(prisma.stream.upsert as any).mockResolvedValue({ streamId: 123 });
7477

7578
await createStream(req as Request, res as Response);
@@ -78,6 +81,52 @@ describe('Stream Controller', () => {
7881
expect(prisma.stream.upsert).toHaveBeenCalled();
7982
});
8083

84+
it('should return 401 when the request is unauthenticated', async () => {
85+
(req as any).user = undefined;
86+
87+
await createStream(req as Request, res as Response);
88+
89+
expect(res.status).toHaveBeenCalledWith(401);
90+
expect(prisma.stream.upsert).not.toHaveBeenCalled();
91+
});
92+
93+
it('should return 403 when the caller is not the body sender (Issue #809)', async () => {
94+
(req as any).user = { publicKey: 'GATTACKER' };
95+
96+
await createStream(req as Request, res as Response);
97+
98+
expect(res.status).toHaveBeenCalledWith(403);
99+
expect(prisma.stream.upsert).not.toHaveBeenCalled();
100+
});
101+
102+
it('should reject 400 when sender is missing (Issue #809)', async () => {
103+
delete req.body.sender;
104+
(req as any).user = { publicKey: 'GSENDER' };
105+
106+
await createStream(req as Request, res as Response);
107+
108+
expect(res.status).toHaveBeenCalledWith(400);
109+
expect(prisma.stream.upsert).not.toHaveBeenCalled();
110+
});
111+
112+
it('should return 403 and not reactivate a cancelled stream owned by another wallet (Issue #809)', async () => {
113+
// A victim previously created (and cancelled) this stream.
114+
(prisma.stream.findUnique as any).mockResolvedValue({
115+
streamId: 123,
116+
sender: 'GVICTIM',
117+
isActive: false,
118+
});
119+
// Attacker authenticates as themselves and sets sender to their own key,
120+
// trying to hijack the victim's streamId.
121+
req.body.sender = 'GATTACKER';
122+
(req as any).user = { publicKey: 'GATTACKER' };
123+
124+
await createStream(req as Request, res as Response);
125+
126+
expect(res.status).toHaveBeenCalledWith(403);
127+
expect(prisma.stream.upsert).not.toHaveBeenCalled();
128+
});
129+
81130
it('should return 400 for invalid streamId', async () => {
82131
req.body.streamId = 'abc';
83132
await createStream(req as Request, res as Response);

backend/tests/stream.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ describe('POST /v1/streams', () => {
5959
const mockStream = {
6060
id: 'uuid-123',
6161
streamId: 1,
62-
sender: 'GABC123XYZ456DEF789GHI012JKL345MNO678PQR901STU234VWX567YZA',
62+
sender: 'GTEST_USER_PUBLIC_KEY',
6363
recipient: 'GDEF456ABC789GHI012JKL345MNO678PQR901STU234VWX567YZA123BCD',
6464
tokenAddress: 'CBCD789EFG012HIJ345KLM678NOP901QRS234TUV567WXY890ZAB123CDE',
6565
ratePerSecond: '100',
@@ -80,7 +80,7 @@ describe('POST /v1/streams', () => {
8080

8181
const validData = {
8282
streamId: '1',
83-
sender: 'GABC123XYZ456DEF789GHI012JKL345MNO678PQR901STU234VWX567YZA',
83+
sender: 'GTEST_USER_PUBLIC_KEY',
8484
recipient: 'GDEF456ABC789GHI012JKL345MNO678PQR901STU234VWX567YZA123BCD',
8585
tokenAddress: 'CBCD789EFG012HIJ345KLM678NOP901QRS234TUV567WXY890ZAB123CDE',
8686
ratePerSecond: '100',
@@ -108,7 +108,7 @@ describe('POST /v1/streams', () => {
108108

109109
const validData = {
110110
streamId: '2',
111-
sender: 'GABC123XYZ456DEF789GHI012JKL345MNO678PQR901STU234VWX567YZA',
111+
sender: 'GTEST_USER_PUBLIC_KEY',
112112
recipient: 'GDEF456ABC789GHI012JKL345MNO678PQR901STU234VWX567YZA123BCD',
113113
tokenAddress: 'CBCD789EFG012HIJ345KLM678NOP901QRS234TUV567WXY890ZAB123CDE',
114114
ratePerSecond: '100',

0 commit comments

Comments
 (0)