feat(#292): implement protocol version checking
Add protocol version validation during connection handshake. - Define FEDERATION_PROTOCOL_VERSION constant (1.0) - Validate version on both outgoing and incoming connections - Require exact version match for compatibility - Log and audit version mismatches Fixes #292 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -150,6 +150,31 @@ describe("ConnectionService", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should reject connection to instance with incompatible protocol version", async () => {
|
||||||
|
const incompatibleRemoteIdentity = {
|
||||||
|
...mockRemoteIdentity,
|
||||||
|
capabilities: {
|
||||||
|
...mockRemoteIdentity.capabilities,
|
||||||
|
protocolVersion: "2.0",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockAxiosResponse: AxiosResponse = {
|
||||||
|
data: incompatibleRemoteIdentity,
|
||||||
|
status: 200,
|
||||||
|
statusText: "OK",
|
||||||
|
headers: {},
|
||||||
|
config: {} as never,
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.spyOn(prismaService.federationConnection, "count").mockResolvedValue(5);
|
||||||
|
vi.spyOn(httpService, "get").mockReturnValue(of(mockAxiosResponse));
|
||||||
|
|
||||||
|
await expect(service.initiateConnection(mockWorkspaceId, mockRemoteUrl)).rejects.toThrow(
|
||||||
|
"Incompatible protocol version. Expected 1.0, received 2.0"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("should create a pending connection", async () => {
|
it("should create a pending connection", async () => {
|
||||||
const mockAxiosResponse: AxiosResponse = {
|
const mockAxiosResponse: AxiosResponse = {
|
||||||
data: mockRemoteIdentity,
|
data: mockRemoteIdentity,
|
||||||
@@ -449,6 +474,42 @@ describe("ConnectionService", () => {
|
|||||||
signature: "valid-signature",
|
signature: "valid-signature",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
it("should reject request with incompatible protocol version", async () => {
|
||||||
|
const incompatibleRequest = {
|
||||||
|
...mockRequest,
|
||||||
|
capabilities: {
|
||||||
|
...mockRemoteIdentity.capabilities,
|
||||||
|
protocolVersion: "2.0",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.spyOn(signatureService, "verifyConnectionRequest").mockResolvedValue({ valid: true });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.handleIncomingConnectionRequest(mockWorkspaceId, incompatibleRequest)
|
||||||
|
).rejects.toThrow("Incompatible protocol version. Expected 1.0, received 2.0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should accept request with compatible protocol version", async () => {
|
||||||
|
const compatibleRequest = {
|
||||||
|
...mockRequest,
|
||||||
|
capabilities: {
|
||||||
|
...mockRemoteIdentity.capabilities,
|
||||||
|
protocolVersion: "1.0",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.spyOn(signatureService, "verifyConnectionRequest").mockResolvedValue({ valid: true });
|
||||||
|
vi.spyOn(prismaService.federationConnection, "create").mockResolvedValue(mockConnection);
|
||||||
|
|
||||||
|
const result = await service.handleIncomingConnectionRequest(
|
||||||
|
mockWorkspaceId,
|
||||||
|
compatibleRequest
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.status).toBe(FederationConnectionStatus.PENDING);
|
||||||
|
});
|
||||||
|
|
||||||
it("should validate connection request signature", async () => {
|
it("should validate connection request signature", async () => {
|
||||||
const verifySpy = vi.spyOn(signatureService, "verifyConnectionRequest");
|
const verifySpy = vi.spyOn(signatureService, "verifyConnectionRequest");
|
||||||
vi.spyOn(prismaService.federationConnection, "create").mockResolvedValue(mockConnection);
|
vi.spyOn(prismaService.federationConnection, "create").mockResolvedValue(mockConnection);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { FederationAuditService } from "./audit.service";
|
|||||||
import { firstValueFrom } from "rxjs";
|
import { firstValueFrom } from "rxjs";
|
||||||
import type { ConnectionRequest, ConnectionDetails } from "./types/connection.types";
|
import type { ConnectionRequest, ConnectionDetails } from "./types/connection.types";
|
||||||
import type { PublicInstanceIdentity } from "./types/instance.types";
|
import type { PublicInstanceIdentity } from "./types/instance.types";
|
||||||
|
import { FEDERATION_PROTOCOL_VERSION } from "./constants";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ConnectionService {
|
export class ConnectionService {
|
||||||
@@ -55,6 +56,9 @@ export class ConnectionService {
|
|||||||
// Fetch remote instance identity
|
// Fetch remote instance identity
|
||||||
const remoteIdentity = await this.fetchRemoteIdentity(remoteUrl);
|
const remoteIdentity = await this.fetchRemoteIdentity(remoteUrl);
|
||||||
|
|
||||||
|
// Validate protocol version compatibility
|
||||||
|
this.validateProtocolVersion(remoteIdentity.capabilities.protocolVersion);
|
||||||
|
|
||||||
// Get our instance identity
|
// Get our instance identity
|
||||||
const localIdentity = await this.federationService.getInstanceIdentity();
|
const localIdentity = await this.federationService.getInstanceIdentity();
|
||||||
|
|
||||||
@@ -316,6 +320,25 @@ export class ConnectionService {
|
|||||||
throw new UnauthorizedException("Invalid connection request signature");
|
throw new UnauthorizedException("Invalid connection request signature");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate protocol version compatibility
|
||||||
|
try {
|
||||||
|
this.validateProtocolVersion(request.capabilities.protocolVersion);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
this.logger.warn(`Incompatible protocol version from ${request.instanceId}: ${errorMsg}`);
|
||||||
|
|
||||||
|
// Audit log: Connection rejected
|
||||||
|
this.auditService.logIncomingConnectionRejected({
|
||||||
|
workspaceId,
|
||||||
|
remoteInstanceId: request.instanceId,
|
||||||
|
remoteUrl: request.instanceUrl,
|
||||||
|
reason: "Incompatible protocol version",
|
||||||
|
error: errorMsg,
|
||||||
|
});
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
// Create pending connection
|
// Create pending connection
|
||||||
const connection = await this.prisma.federationConnection.create({
|
const connection = await this.prisma.federationConnection.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -403,4 +426,22 @@ export class ConnectionService {
|
|||||||
disconnectedAt: connection.disconnectedAt,
|
disconnectedAt: connection.disconnectedAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate protocol version compatibility
|
||||||
|
* Currently requires exact version match
|
||||||
|
*/
|
||||||
|
private validateProtocolVersion(remoteVersion: string | undefined): void {
|
||||||
|
if (!remoteVersion) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Protocol version not specified. Expected ${FEDERATION_PROTOCOL_VERSION}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remoteVersion !== FEDERATION_PROTOCOL_VERSION) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Incompatible protocol version. Expected ${FEDERATION_PROTOCOL_VERSION}, received ${remoteVersion}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
13
apps/api/src/federation/constants.ts
Normal file
13
apps/api/src/federation/constants.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Federation Protocol Constants
|
||||||
|
*
|
||||||
|
* Constants for federation protocol versioning and configuration.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current federation protocol version
|
||||||
|
* Format: MAJOR.MINOR
|
||||||
|
* - MAJOR version: Breaking changes to protocol
|
||||||
|
* - MINOR version: Backward-compatible additions
|
||||||
|
*/
|
||||||
|
export const FEDERATION_PROTOCOL_VERSION = "1.0";
|
||||||
Reference in New Issue
Block a user