Merge pull request 'Security Sprint M7.1: Fix P1 Security Issues (#283, #288, #289, #290)' (#319) from fix/283-connection-status-validation into develop
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed

Reviewed-on: #319
This commit was merged in pull request #319.
This commit is contained in:
2026-02-04 03:38:19 +00:00
10 changed files with 121 additions and 55 deletions

View File

@@ -154,6 +154,15 @@ describe("CommandService", () => {
signature: "signature-123", signature: "signature-123",
}) })
); );
// Verify status was checked in the query
expect(prisma.federationConnection.findUnique).toHaveBeenCalledWith({
where: {
id: mockConnectionId,
workspaceId: mockWorkspaceId,
status: FederationConnectionStatus.ACTIVE,
},
});
}); });
it("should throw error if connection not found", async () => { it("should throw error if connection not found", async () => {
@@ -165,19 +174,21 @@ describe("CommandService", () => {
}); });
it("should throw error if connection is not active", async () => { it("should throw error if connection is not active", async () => {
const mockConnection = { // Connection should not be found by query because it's not ACTIVE
id: mockConnectionId, vi.spyOn(prisma.federationConnection, "findUnique").mockResolvedValue(null);
workspaceId: mockWorkspaceId,
status: FederationConnectionStatus.SUSPENDED,
};
vi.spyOn(prisma.federationConnection, "findUnique").mockResolvedValue(
mockConnection as never
);
await expect( await expect(
service.sendCommand(mockWorkspaceId, mockConnectionId, "test", {}) service.sendCommand(mockWorkspaceId, mockConnectionId, "test", {})
).rejects.toThrow("Connection is not active"); ).rejects.toThrow("Connection not found");
// Verify status was checked in the query
expect(prisma.federationConnection.findUnique).toHaveBeenCalledWith({
where: {
id: mockConnectionId,
workspaceId: mockWorkspaceId,
status: FederationConnectionStatus.ACTIVE,
},
});
}); });
it("should mark command as failed if sending fails", async () => { it("should mark command as failed if sending fails", async () => {

View File

@@ -41,19 +41,19 @@ export class CommandService {
commandType: string, commandType: string,
payload: Record<string, unknown> payload: Record<string, unknown>
): Promise<CommandMessageDetails> { ): Promise<CommandMessageDetails> {
// Validate connection exists and is active // Validate connection exists and is active (enforced in query)
const connection = await this.prisma.federationConnection.findUnique({ const connection = await this.prisma.federationConnection.findUnique({
where: { id: connectionId, workspaceId }, where: {
id: connectionId,
workspaceId,
status: FederationConnectionStatus.ACTIVE,
},
}); });
if (!connection) { if (!connection) {
throw new Error("Connection not found"); throw new Error("Connection not found");
} }
if (connection.status !== FederationConnectionStatus.ACTIVE) {
throw new Error("Connection is not active");
}
// Get local instance identity // Get local instance identity
const identity = await this.federationService.getInstanceIdentity(); const identity = await this.federationService.getInstanceIdentity();
@@ -132,7 +132,7 @@ export class CommandService {
throw new Error("Command timestamp is outside acceptable range"); throw new Error("Command timestamp is outside acceptable range");
} }
// Find connection for remote instance // Find connection for remote instance (status enforced in query)
const connection = await this.prisma.federationConnection.findFirst({ const connection = await this.prisma.federationConnection.findFirst({
where: { where: {
remoteInstanceId: commandMessage.instanceId, remoteInstanceId: commandMessage.instanceId,
@@ -144,11 +144,6 @@ export class CommandService {
throw new Error("No connection found for remote instance"); throw new Error("No connection found for remote instance");
} }
// Validate connection is active
if (connection.status !== FederationConnectionStatus.ACTIVE) {
throw new Error("Connection is not active");
}
// Verify signature // Verify signature
const { signature, ...messageToVerify } = commandMessage; const { signature, ...messageToVerify } = commandMessage;
const verificationResult = await this.signatureService.verifyMessage( const verificationResult = await this.signatureService.verifyMessage(

View File

@@ -2,10 +2,11 @@
* Crypto Service Tests * Crypto Service Tests
*/ */
import { describe, it, expect, beforeEach } from "vitest"; import { describe, it, expect, beforeEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing"; import { Test, TestingModule } from "@nestjs/testing";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { CryptoService } from "./crypto.service"; import { CryptoService } from "./crypto.service";
import { Logger } from "@nestjs/common";
describe("CryptoService", () => { describe("CryptoService", () => {
let service: CryptoService; let service: CryptoService;
@@ -137,6 +138,31 @@ describe("CryptoService", () => {
// Act & Assert // Act & Assert
expect(() => service.decrypt(corrupted)).toThrow("Failed to decrypt data"); expect(() => service.decrypt(corrupted)).toThrow("Failed to decrypt data");
}); });
it("should not log sensitive data in error messages", () => {
// Arrange
const loggerErrorSpy = vi.spyOn(Logger.prototype, "error");
const corruptedData = "corrupted:data:here";
// Act & Assert
expect(() => service.decrypt(corruptedData)).toThrow("Failed to decrypt data");
// Verify logger was called with safe message
expect(loggerErrorSpy).toHaveBeenCalled();
const logCall = loggerErrorSpy.mock.calls[0];
// First argument should contain error type but not sensitive data
expect(logCall[0]).toMatch(/Decryption failed:/);
// Should NOT log the actual error object with stack traces
expect(logCall.length).toBe(1); // Only one argument (the message)
// Verify the corrupted data is not in the log
const logMessage = logCall[0] as string;
expect(logMessage).not.toContain(corruptedData);
loggerErrorSpy.mockRestore();
});
}); });
describe("encrypt/decrypt round-trip", () => { describe("encrypt/decrypt round-trip", () => {

View File

@@ -90,7 +90,10 @@ export class CryptoService {
return decrypted; return decrypted;
} catch (error) { } catch (error) {
this.logger.error("Decryption failed", error); // Security: Do not log error details which may contain sensitive data
// Only log error type/code without stack trace or encrypted content
const errorType = error instanceof Error ? error.constructor.name : "Unknown";
this.logger.error(`Decryption failed: ${errorType}`);
throw new Error("Failed to decrypt data"); throw new Error("Failed to decrypt data");
} }
} }

View File

@@ -198,6 +198,18 @@ describe("FederationService", () => {
expect(result1.publicKey).not.toEqual(result2.publicKey); expect(result1.publicKey).not.toEqual(result2.publicKey);
expect(result1.privateKey).not.toEqual(result2.privateKey); expect(result1.privateKey).not.toEqual(result2.privateKey);
}); });
it("should generate RSA-4096 key pairs for future-proof security", () => {
// Act
const result = service.generateKeypair();
// Assert - Verify key size by checking approximate length
// RSA-4096 keys are significantly larger than RSA-2048
// Private key in PKCS8 format: RSA-2048 ≈ 1700 bytes, RSA-4096 ≈ 3200 bytes
// Public key in SPKI format: RSA-2048 ≈ 400 bytes, RSA-4096 ≈ 800 bytes
expect(result.privateKey.length).toBeGreaterThan(3000);
expect(result.publicKey.length).toBeGreaterThan(700);
});
}); });
describe("regenerateKeypair", () => { describe("regenerateKeypair", () => {

View File

@@ -57,10 +57,11 @@ export class FederationService {
/** /**
* Generate a new RSA key pair for instance signing * Generate a new RSA key pair for instance signing
* Uses RSA-4096 for future-proof security (NIST recommendation)
*/ */
generateKeypair(): KeyPair { generateKeypair(): KeyPair {
const { publicKey, privateKey } = generateKeyPairSync("rsa", { const { publicKey, privateKey } = generateKeyPairSync("rsa", {
modulusLength: 2048, modulusLength: 4096,
publicKeyEncoding: { publicKeyEncoding: {
type: "spki", type: "spki",
format: "pem", format: "pem",

View File

@@ -90,6 +90,15 @@ describe("IdentityLinkingController", () => {
}); });
describe("POST /identity/verify", () => { describe("POST /identity/verify", () => {
it("should have AuthGuard and Throttle decorators applied", () => {
// Verify that the endpoint has proper guards and rate limiting
const verifyMetadata = Reflect.getMetadata(
"__guards__",
IdentityLinkingController.prototype.verifyIdentity
);
expect(verifyMetadata).toBeDefined();
});
it("should verify identity with valid request", async () => { it("should verify identity with valid request", async () => {
const dto: VerifyIdentityDto = { const dto: VerifyIdentityDto = {
localUserId: "local-user-id", localUserId: "local-user-id",

View File

@@ -5,6 +5,7 @@
*/ */
import { Controller, Post, Get, Patch, Delete, Body, Param, UseGuards } from "@nestjs/common"; import { Controller, Post, Get, Patch, Delete, Body, Param, UseGuards } from "@nestjs/common";
import { Throttle } from "@nestjs/throttler";
import { AuthGuard } from "../auth/guards/auth.guard"; import { AuthGuard } from "../auth/guards/auth.guard";
import { IdentityLinkingService } from "./identity-linking.service"; import { IdentityLinkingService } from "./identity-linking.service";
import { IdentityResolutionService } from "./identity-resolution.service"; import { IdentityResolutionService } from "./identity-resolution.service";
@@ -45,8 +46,11 @@ export class IdentityLinkingController {
* *
* Verify a user's identity from a remote instance. * Verify a user's identity from a remote instance.
* Validates signature and OIDC token. * Validates signature and OIDC token.
* Rate limit: "strict" tier (10 req/min) - public endpoint requiring authentication
*/ */
@Post("verify") @Post("verify")
@UseGuards(AuthGuard)
@Throttle({ strict: { limit: 10, ttl: 60000 } })
async verifyIdentity(@Body() dto: VerifyIdentityDto): Promise<IdentityVerificationResponse> { async verifyIdentity(@Body() dto: VerifyIdentityDto): Promise<IdentityVerificationResponse> {
return this.identityLinkingService.verifyIdentity(dto); return this.identityLinkingService.verifyIdentity(dto);
} }

View File

@@ -143,7 +143,11 @@ describe("QueryService", () => {
expect(result.messageType).toBe(FederationMessageType.QUERY); expect(result.messageType).toBe(FederationMessageType.QUERY);
expect(result.query).toBe(query); expect(result.query).toBe(query);
expect(mockPrisma.federationConnection.findUnique).toHaveBeenCalledWith({ expect(mockPrisma.federationConnection.findUnique).toHaveBeenCalledWith({
where: { id: connectionId, workspaceId }, where: {
id: connectionId,
workspaceId,
status: FederationConnectionStatus.ACTIVE,
},
}); });
expect(mockPrisma.federationMessage.create).toHaveBeenCalled(); expect(mockPrisma.federationMessage.create).toHaveBeenCalled();
expect(mockHttpService.post).toHaveBeenCalledWith( expect(mockHttpService.post).toHaveBeenCalledWith(
@@ -168,17 +172,21 @@ describe("QueryService", () => {
}); });
it("should throw error if connection not active", async () => { it("should throw error if connection not active", async () => {
const mockConnection = { // Connection should not be found by query because it's not ACTIVE
id: "connection-1", mockPrisma.federationConnection.findUnique.mockResolvedValue(null);
workspaceId: "workspace-1",
status: FederationConnectionStatus.PENDING,
};
mockPrisma.federationConnection.findUnique.mockResolvedValue(mockConnection);
await expect( await expect(
service.sendQuery("workspace-1", "connection-1", "SELECT * FROM tasks") service.sendQuery("workspace-1", "connection-1", "SELECT * FROM tasks")
).rejects.toThrow("Connection is not active"); ).rejects.toThrow("Connection not found");
// Verify that findUnique was called with status: ACTIVE in the query
expect(mockPrisma.federationConnection.findUnique).toHaveBeenCalledWith({
where: {
id: "connection-1",
workspaceId: "workspace-1",
status: FederationConnectionStatus.ACTIVE,
},
});
}); });
it("should handle network errors gracefully", async () => { it("should handle network errors gracefully", async () => {
@@ -305,19 +313,21 @@ describe("QueryService", () => {
signature: "valid-signature", signature: "valid-signature",
}; };
const mockConnection = { // Connection should not be found because status filter in query excludes non-ACTIVE
id: "connection-1", mockPrisma.federationConnection.findFirst.mockResolvedValue(null);
workspaceId: "workspace-1",
remoteInstanceId: "remote-instance-1",
status: FederationConnectionStatus.PENDING,
};
mockPrisma.federationConnection.findFirst.mockResolvedValue(mockConnection);
mockSignatureService.validateTimestamp.mockReturnValue(true); mockSignatureService.validateTimestamp.mockReturnValue(true);
await expect(service.handleIncomingQuery(queryMessage)).rejects.toThrow( await expect(service.handleIncomingQuery(queryMessage)).rejects.toThrow(
"Connection is not active" "No connection found for remote instance"
); );
// Verify the findFirst was called with status: ACTIVE filter
expect(mockPrisma.federationConnection.findFirst).toHaveBeenCalledWith({
where: {
remoteInstanceId: "remote-instance-1",
status: FederationConnectionStatus.ACTIVE,
},
});
}); });
it("should reject query from unknown instance", async () => { it("should reject query from unknown instance", async () => {

View File

@@ -38,19 +38,19 @@ export class QueryService {
query: string, query: string,
context?: Record<string, unknown> context?: Record<string, unknown>
): Promise<QueryMessageDetails> { ): Promise<QueryMessageDetails> {
// Validate connection exists and is active // Validate connection exists and is active (enforced in query)
const connection = await this.prisma.federationConnection.findUnique({ const connection = await this.prisma.federationConnection.findUnique({
where: { id: connectionId, workspaceId }, where: {
id: connectionId,
workspaceId,
status: FederationConnectionStatus.ACTIVE,
},
}); });
if (!connection) { if (!connection) {
throw new Error("Connection not found"); throw new Error("Connection not found");
} }
if (connection.status !== FederationConnectionStatus.ACTIVE) {
throw new Error("Connection is not active");
}
// Get local instance identity // Get local instance identity
const identity = await this.federationService.getInstanceIdentity(); const identity = await this.federationService.getInstanceIdentity();
@@ -129,7 +129,7 @@ export class QueryService {
throw new Error("Query timestamp is outside acceptable range"); throw new Error("Query timestamp is outside acceptable range");
} }
// Find connection for remote instance // Find connection for remote instance (status enforced in query)
const connection = await this.prisma.federationConnection.findFirst({ const connection = await this.prisma.federationConnection.findFirst({
where: { where: {
remoteInstanceId: queryMessage.instanceId, remoteInstanceId: queryMessage.instanceId,
@@ -141,11 +141,6 @@ export class QueryService {
throw new Error("No connection found for remote instance"); throw new Error("No connection found for remote instance");
} }
// Validate connection is active
if (connection.status !== FederationConnectionStatus.ACTIVE) {
throw new Error("Connection is not active");
}
// Verify signature // Verify signature
const { signature, ...messageToVerify } = queryMessage; const { signature, ...messageToVerify } = queryMessage;
const verificationResult = await this.signatureService.verifyMessage( const verificationResult = await this.signatureService.verifyMessage(