Skip to content

Commit 908b27e

Browse files
author
Minh Vu
authored
fix(api): validate source connection ownership (#2801)
## Summary The internal source API accepts any non-empty `connection` value when creating or updating a source. Unlike the external API v2 and MCP paths, it does not verify that the connection exists or belongs to the authenticated team. That can leave sources pointing at missing connections or another team's ClickHouse credentials. ## What changed - Moved the existing connection validation into the connection controller. - Applied it to internal `POST /sources` and `PUT /sources/:id`. - Kept the external API v2 and MCP paths on the same shared validator. - Preserved the existing 400 responses for malformed, missing, and inaccessible connection IDs. - Kept local app mode compatible with its string team ID. ## Tests - Added internal API coverage for malformed connection IDs. - Added coverage for missing and foreign-team connections. - Verified rejected updates leave the existing source unchanged. - Preserved coverage for successful source creation and updates. - Targeted ESLint passes.
1 parent 584ae6d commit 908b27e

7 files changed

Lines changed: 184 additions & 48 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@hyperdx/api': patch
3+
---
4+
5+
Reject source writes that reference malformed, missing, or another team's
6+
connection.

‎packages/api/src/controllers/connection.ts‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,39 @@
1+
import type { ObjectId } from '@/models';
12
import Connection, { IConnection } from '@/models/connection';
3+
import { objectIdSchema } from '@/utils/zod';
4+
5+
export type ConnectionValidation =
6+
| { ok: true }
7+
| { ok: false; status: 400 | 403; message: string };
8+
9+
export async function validateConnectionId(
10+
connection: unknown,
11+
teamId: string | ObjectId | undefined,
12+
): Promise<ConnectionValidation> {
13+
const parsed = objectIdSchema.safeParse(connection);
14+
if (!parsed.success) {
15+
return {
16+
ok: false,
17+
status: 400,
18+
message: 'connection must be a valid connection id',
19+
};
20+
}
21+
if (teamId == null) {
22+
return { ok: false, status: 403, message: 'Forbidden' };
23+
}
24+
const connectionExists = await Connection.exists({
25+
_id: parsed.data,
26+
team: teamId,
27+
});
28+
if (connectionExists == null) {
29+
return {
30+
ok: false,
31+
status: 400,
32+
message: 'connection must be an existing connection id',
33+
};
34+
}
35+
return { ok: true };
36+
}
237

338
// Returns all connections across all teams. Only intended for instance-level
439
// operations (e.g. startup auto-provisioning); user-facing routes must use

‎packages/api/src/mcp/tools/sources/saveSource.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,14 @@ import { SourceSchemaNoId } from '@hyperdx/common-utils/dist/types';
22
import mongoose from 'mongoose';
33

44
import * as config from '@/config';
5+
import { validateConnectionId } from '@/controllers/connection';
56
import { createSource, getSource, updateSource } from '@/controllers/sources';
67
import type { ToolRegistrar } from '@/mcp/tools/types';
78
import {
89
mcpServerError,
910
mcpUserError,
1011
validateObjectId,
1112
} from '@/mcp/utils/errors';
12-
import { validateConnectionId } from '@/routers/external-api/v2/sources';
1313
import { isDuplicateKeyError } from '@/utils/errors';
1414

1515
import { buildSourceInput, mcpSaveSourceSchema } from './schemas';

‎packages/api/src/routers/api/__tests__/sources.int.test.ts‎

Lines changed: 119 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,12 @@ import express from 'express';
77
import { Types } from 'mongoose';
88
import request from 'supertest';
99

10-
import { getLoggedInAgent, getServer } from '@/fixtures';
10+
import {
11+
getLoggedInAgent as getFixtureLoggedInAgent,
12+
getServer,
13+
} from '@/fixtures';
1114
import { appErrorHandler } from '@/middleware/error';
15+
import Connection from '@/models/connection';
1216
import { Source } from '@/models/source';
1317
import sourcesRouter from '@/routers/api/sources';
1418

@@ -43,6 +47,27 @@ const MOCK_METRIC_SOURCE: Omit<Extract<TSource, { kind: 'metric' }>, 'id'> = {
4347
},
4448
};
4549

50+
const createTestConnection = (team: Types.ObjectId, id: string) =>
51+
Connection.create({
52+
_id: id,
53+
team,
54+
name: 'Test Connection',
55+
host: 'http://localhost:8123',
56+
username: 'default',
57+
password: 'password',
58+
});
59+
60+
const getLoggedInAgent = async (server: ReturnType<typeof getServer>) => {
61+
const result = await getFixtureLoggedInAgent(server);
62+
63+
await Promise.all([
64+
createTestConnection(result.team._id, MOCK_SOURCE.connection),
65+
createTestConnection(result.team._id, MOCK_METRIC_SOURCE.connection),
66+
]);
67+
68+
return result;
69+
};
70+
4671
describe('sources router', () => {
4772
const server = getServer();
4873

@@ -103,6 +128,87 @@ describe('sources router', () => {
103128
expect(sources).toHaveLength(1);
104129
});
105130

131+
describe('connection validation', () => {
132+
it('POST / - returns 400 for a malformed connection id', async () => {
133+
const { agent } = await getLoggedInAgent(server);
134+
135+
await agent
136+
.post('/sources')
137+
.send({ ...MOCK_SOURCE, connection: 'not-an-object-id' })
138+
.expect(400);
139+
});
140+
141+
it('POST / - returns 400 for a nonexistent connection id', async () => {
142+
const { agent } = await getLoggedInAgent(server);
143+
144+
await agent
145+
.post('/sources')
146+
.send({
147+
...MOCK_SOURCE,
148+
connection: new Types.ObjectId().toString(),
149+
})
150+
.expect(400);
151+
});
152+
153+
it('POST / - returns 400 for another team connection', async () => {
154+
const { agent } = await getLoggedInAgent(server);
155+
const otherConnection = await createTestConnection(
156+
new Types.ObjectId(),
157+
new Types.ObjectId().toString(),
158+
);
159+
160+
await agent
161+
.post('/sources')
162+
.send({
163+
...MOCK_SOURCE,
164+
connection: otherConnection._id.toString(),
165+
})
166+
.expect(400);
167+
});
168+
169+
it('PUT /:id - rejects an inaccessible connection without changing the source', async () => {
170+
const { agent, team } = await getLoggedInAgent(server);
171+
const source = await Source.create({
172+
...MOCK_SOURCE,
173+
team: team._id,
174+
});
175+
const otherConnection = await createTestConnection(
176+
new Types.ObjectId(),
177+
new Types.ObjectId().toString(),
178+
);
179+
180+
await agent
181+
.put(`/sources/${source._id}`)
182+
.send({
183+
...MOCK_SOURCE,
184+
id: source._id.toString(),
185+
connection: otherConnection._id.toString(),
186+
})
187+
.expect(400);
188+
189+
const unchanged = await Source.findById(source._id);
190+
expect(unchanged?.name).toBe(MOCK_SOURCE.name);
191+
expect(unchanged?.connection.toString()).toBe(MOCK_SOURCE.connection);
192+
});
193+
194+
it('PUT /:id - returns 400 for a nonexistent connection id', async () => {
195+
const { agent, team } = await getLoggedInAgent(server);
196+
const source = await Source.create({
197+
...MOCK_SOURCE,
198+
team: team._id,
199+
});
200+
201+
await agent
202+
.put(`/sources/${source._id}`)
203+
.send({
204+
...MOCK_SOURCE,
205+
id: source._id.toString(),
206+
connection: new Types.ObjectId().toString(),
207+
})
208+
.expect(400);
209+
});
210+
});
211+
106212
it('POST / - returns 400 when request body is invalid', async () => {
107213
const { agent } = await getLoggedInAgent(server);
108214

@@ -284,7 +390,7 @@ describe('sources router', () => {
284390
const metricSource = await Source.create({
285391
kind: SourceKind.Metric,
286392
name: 'Test Metric Source',
287-
connection: new Types.ObjectId().toString(),
393+
connection: MOCK_METRIC_SOURCE.connection,
288394
from: {
289395
databaseName: 'test_db',
290396
tableName: 'otel_metrics',
@@ -346,7 +452,7 @@ describe('sources router', () => {
346452
const metricSource = await Source.create({
347453
kind: SourceKind.Metric,
348454
name: 'Test Metric Source',
349-
connection: new Types.ObjectId().toString(),
455+
connection: MOCK_METRIC_SOURCE.connection,
350456
from: {
351457
databaseName: 'test_db',
352458
tableName: 'otel_metrics',
@@ -693,7 +799,7 @@ describe('sources router', () => {
693799
it('successfully updates a legacy Session source when timestampValueExpression is provided', async () => {
694800
const { agent, team } = await getLoggedInAgent(server);
695801

696-
const connectionId = new Types.ObjectId();
802+
const connectionId = new Types.ObjectId(MOCK_SOURCE.connection);
697803
const result = await Source.collection.insertOne({
698804
kind: SourceKind.Session,
699805
name: 'Legacy Session',
@@ -841,6 +947,10 @@ describe('sources router', () => {
841947

842948
it('POST / - creates a source when team id is a string', async () => {
843949
const app = getLocalAppModeApp();
950+
await createTestConnection(
951+
new Types.ObjectId('_local_team_'),
952+
MOCK_SOURCE.connection,
953+
);
844954

845955
const response = await request(app)
846956
.post('/sources')
@@ -858,6 +968,10 @@ describe('sources router', () => {
858968

859969
it('PUT /:id - updates a source when team id is a string', async () => {
860970
const app = getLocalAppModeApp();
971+
await createTestConnection(
972+
new Types.ObjectId('_local_team_'),
973+
MOCK_SOURCE.connection,
974+
);
861975

862976
const source = await Source.create({
863977
...MOCK_SOURCE,
@@ -991,7 +1105,7 @@ describe('sources router', () => {
9911105
const traceSource: Omit<Extract<TSource, { kind: 'trace' }>, 'id'> = {
9921106
kind: SourceKind.Trace,
9931107
name: 'Trace with text index pref',
994-
connection: new Types.ObjectId().toString(),
1108+
connection: MOCK_SOURCE.connection,
9951109
from: { databaseName: 'test_db', tableName: 'otel_traces' },
9961110
timestampValueExpression: 'Timestamp',
9971111
defaultTableSelectExpression: '*',

‎packages/api/src/routers/api/sources.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import express from 'express';
66
import { z } from 'zod';
77
import { validateRequest } from 'zod-express-middleware';
88

9+
import { validateConnectionId } from '@/controllers/connection';
910
import {
1011
createSource,
1112
deleteSource,
@@ -43,6 +44,16 @@ router.post(
4344
try {
4445
const { teamId } = getNonNullUserWithTeam(req);
4546

47+
const connectionCheck = await validateConnectionId(
48+
req.body.connection,
49+
teamId,
50+
);
51+
if (!connectionCheck.ok) {
52+
return res
53+
.status(connectionCheck.status)
54+
.json({ message: connectionCheck.message });
55+
}
56+
4657
const source = await createSource(teamId.toString(), {
4758
...req.body,
4859
team: teamId.toString(),
@@ -67,6 +78,16 @@ router.put(
6778
try {
6879
const { teamId } = getNonNullUserWithTeam(req);
6980

81+
const connectionCheck = await validateConnectionId(
82+
req.body.connection,
83+
teamId,
84+
);
85+
if (!connectionCheck.ok) {
86+
return res
87+
.status(connectionCheck.status)
88+
.json({ message: connectionCheck.message });
89+
}
90+
7091
const source = await updateSource(teamId.toString(), req.params.id, {
7192
...req.body,
7293
team: teamId.toString(),

‎packages/api/src/routers/external-api/__tests__/sources-crud.int.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import mongoose from 'mongoose';
33
import request, { SuperAgentTest } from 'supertest';
44

55
import * as config from '@/config';
6+
import { validateConnectionId } from '@/controllers/connection';
67
import {
78
DEFAULT_DATABASE,
89
DEFAULT_LOGS_TABLE,
@@ -13,7 +14,6 @@ import Connection, { IConnection } from '@/models/connection';
1314
import { LogSource, Source, TraceSource } from '@/models/source';
1415
import { ITeam } from '@/models/team';
1516
import { IUser } from '@/models/user';
16-
import { validateConnectionId } from '@/routers/external-api/v2/sources';
1717

1818
describe('External API v2 Sources CRUD', () => {
1919
const server = getServer();

‎packages/api/src/routers/external-api/v2/sources.ts‎

Lines changed: 1 addition & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,14 @@ import {
77
import express from 'express';
88
import { z } from 'zod';
99

10+
import { validateConnectionId } from '@/controllers/connection';
1011
import {
1112
createSource,
1213
deleteSource,
1314
getSource,
1415
getSources,
1516
updateSource,
1617
} from '@/controllers/sources';
17-
import Connection from '@/models/connection';
1818
import { SourceDocument } from '@/models/source';
1919
import { processRequestWithEnhancedErrors as validateRequest } from '@/utils/enhancedErrors';
2020
import logger from '@/utils/logger';
@@ -91,46 +91,6 @@ function mapRequestGranularitiesToInternalFormat(
9191
next();
9292
}
9393

94-
type ConnectionValidation =
95-
| { ok: true }
96-
| { ok: false; status: 400 | 403; message: string };
97-
98-
// Validates that `connection` is a valid ObjectId referencing a connection
99-
// owned by the given team. SourceSchemaNoId only requires `connection` to be a
100-
// non-empty string, but the Mongoose model declares it as an ObjectId ref — a
101-
// non-ObjectId value would surface as a 500 CastError instead of a 400. The
102-
// team-scoped existence check also ensures a source can never reference another
103-
// team's ClickHouse credentials. Kept separate from the middleware so it's
104-
// readable and unit-testable on its own.
105-
export async function validateConnectionId(
106-
connection: unknown,
107-
teamId: Express.User['team'] | undefined,
108-
): Promise<ConnectionValidation> {
109-
const parsed = objectIdSchema.safeParse(connection);
110-
if (!parsed.success) {
111-
return {
112-
ok: false,
113-
status: 400,
114-
message: 'connection must be a valid connection id',
115-
};
116-
}
117-
if (teamId == null) {
118-
return { ok: false, status: 403, message: 'Forbidden' };
119-
}
120-
const connectionExists = await Connection.exists({
121-
_id: parsed.data,
122-
team: teamId,
123-
});
124-
if (connectionExists == null) {
125-
return {
126-
ok: false,
127-
status: 400,
128-
message: 'connection must be an existing connection id',
129-
};
130-
}
131-
return { ok: true };
132-
}
133-
13494
// Runs after body validation so req.body is the parsed shape.
13595
async function requireValidConnectionId(
13696
req: express.Request,

0 commit comments

Comments
 (0)