Skip to content

Commit 297d231

Browse files
authored
Merge pull request #967 from jadonamite/feat/issue-809-createstream-ownership
fix(streams): enforce sender ownership on createStream (#809)
2 parents 749c9f5 + 1193203 commit 297d231

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
@@ -95,8 +95,34 @@ function parseRequiredBigIntField(fieldName: string, value: unknown): bigint {
9595
*/
9696
export const createStream = async (req: Request, res: Response) => {
9797
try {
98+
const callerPublicKey = (req as AuthenticatedRequest).user?.publicKey;
99+
if (!callerPublicKey) {
100+
return res.status(401).json({ error: 'Unauthorized', message: 'Authentication required' });
101+
}
102+
98103
const { streamId, sender, recipient, tokenAddress, ratePerSecond, depositedAmount, startTime } = req.body;
99104

105+
// Issue #809: validate identity fields before any DB write.
106+
if (typeof sender !== 'string' || sender.length === 0) {
107+
return res.status(400).json({ error: 'Invalid sender: must be a non-empty string' });
108+
}
109+
if (typeof recipient !== 'string' || recipient.length === 0) {
110+
return res.status(400).json({ error: 'Invalid recipient: must be a non-empty string' });
111+
}
112+
if (typeof tokenAddress !== 'string' || tokenAddress.length === 0) {
113+
return res.status(400).json({ error: 'Invalid tokenAddress: must be a non-empty string' });
114+
}
115+
116+
// Issue #809: the authenticated wallet may only create/modify streams it owns.
117+
// Without this, any logged-in wallet could POST an arbitrary `sender` and have
118+
// it persisted, or flip another owner's cancelled stream back to active.
119+
if (sender !== callerPublicKey) {
120+
return res.status(403).json({
121+
error: 'Forbidden',
122+
message: 'sender must match the authenticated wallet',
123+
});
124+
}
125+
100126
const parsedStreamId = Number.parseInt(streamId, 10);
101127
const parsedStartTime = Number.parseInt(startTime, 10);
102128

@@ -133,6 +159,18 @@ export const createStream = async (req: Request, res: Response) => {
133159

134160
const endTime = parsedStartTime + Number(parsedDepositedAmount / parsedRatePerSecond);
135161

162+
// Issue #809: never let the upsert update branch touch a stream owned by a
163+
// different wallet. The caller is already proven to equal `sender` above, so
164+
// reject any existing row whose sender differs — this blocks reactivating or
165+
// overwriting someone else's (e.g. cancelled) stream.
166+
const existing = await prisma.stream.findUnique({ where: { streamId: parsedStreamId } });
167+
if (existing && existing.sender !== callerPublicKey) {
168+
return res.status(403).json({
169+
error: 'Forbidden',
170+
message: 'Cannot modify a stream owned by another wallet',
171+
});
172+
}
173+
136174
const stream = await prisma.stream.upsert({
137175
where: { streamId: parsedStreamId },
138176
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)