Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions platforms/esigner-api/src/controllers/FileController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ export class FileController {
res.json(signatures.map(sig => ({
id: sig.id,
userId: sig.userId,
fileSigneeId: sig.fileSigneeId || null,
user: sig.user ? {
id: sig.user.id,
name: sig.user.name,
Expand Down
166 changes: 166 additions & 0 deletions platforms/esigner-api/src/controllers/WebhookController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,30 @@ import { Request, Response } from "express";
import { UserService } from "../services/UserService";
import { GroupService } from "../services/GroupService";
import { MessageService } from "../services/MessageService";
import { FileService } from "../services/FileService";
import { Web3Adapter } from "web3-adapter";
import { User } from "../database/entities/User";
import { Group } from "../database/entities/Group";
import { Message } from "../database/entities/Message";
import { File } from "../database/entities/File";
import { SignatureContainer } from "../database/entities/SignatureContainer";
import { AppDataSource } from "../database/data-source";
import axios from "axios";

export class WebhookController {
userService: UserService;
groupService: GroupService;
messageService: MessageService;
fileService: FileService;
adapter: Web3Adapter;
fileRepository = AppDataSource.getRepository(File);
signatureRepository = AppDataSource.getRepository(SignatureContainer);

constructor(adapter: Web3Adapter) {
this.userService = new UserService();
this.groupService = new GroupService();
this.messageService = new MessageService();
this.fileService = new FileService();
this.adapter = adapter;
}

Expand Down Expand Up @@ -242,6 +250,164 @@ export class WebhookController {
});
console.log("Stored mapping for message:", message.id, "->", req.body.id);
}
} else if (mapping.tableName === "files") {
// Extract owner from the file data
// ownerId might be a global reference or local ID
let ownerId: string | null = null;
if (local.data.ownerId && typeof local.data.ownerId === "string") {
// Check if it's a reference format like "users(uuid)"
if (local.data.ownerId.includes("(")) {
ownerId = local.data.ownerId.split("(")[1].split(")")[0];
} else {
ownerId = local.data.ownerId;
}
}

// Resolve global ownerId to local ownerId if needed
if (ownerId) {
const localOwnerId = await this.adapter.mappingDb.getLocalId(ownerId);
ownerId = localOwnerId || ownerId;
}

const owner = ownerId ? await this.userService.getUserById(ownerId) : null;
if (!owner) {
console.error("Owner not found for file");
return res.status(500).send();
}

if (localId) {
// Update existing file
const file = await this.fileService.getFileById(localId);
if (!file) {
console.error("File not found for localId:", localId);
return res.status(500).send();
}

file.name = local.data.name as string;
file.displayName = local.data.displayName as string | null;
file.description = local.data.description as string | null;
file.mimeType = local.data.mimeType as string;
file.size = local.data.size as number;
file.md5Hash = local.data.md5Hash as string;
file.ownerId = owner.id;

// Decode base64 data if provided
if (local.data.data && typeof local.data.data === "string") {
file.data = Buffer.from(local.data.data, "base64");
}

this.adapter.addToLockedIds(localId);
await this.fileRepository.save(file);
} else {
// Create new file with binary data
// Decode base64 data if provided
let fileData: Buffer = Buffer.alloc(0);
if (local.data.data && typeof local.data.data === "string") {
fileData = Buffer.from(local.data.data, "base64");
}

const file = this.fileRepository.create({
name: local.data.name as string,
displayName: local.data.displayName as string | null,
description: local.data.description as string | null,
mimeType: local.data.mimeType as string,
size: local.data.size as number,
md5Hash: local.data.md5Hash as string,
ownerId: owner.id,
data: fileData,
});

this.adapter.addToLockedIds(file.id);
await this.fileRepository.save(file);
await this.adapter.mappingDb.storeMapping({
localId: file.id,
globalId: req.body.id,
});
localId = file.id;
}
} else if (mapping.tableName === "signature_containers") {
// Extract file and user from the signature data
let file: File | null = null;
let user: User | null = null;

// Resolve fileId - might be global reference
let fileId: string | null = null;
if (local.data.fileId && typeof local.data.fileId === "string") {
if (local.data.fileId.includes("(")) {
const fileGlobalId = local.data.fileId.split("(")[1].split(")")[0];
const fileLocalId = await this.adapter.mappingDb.getLocalId(fileGlobalId);
fileId = fileLocalId || fileGlobalId;
} else {
fileId = local.data.fileId;
}
}

// Resolve userId - might be global reference
let userId: string | null = null;
if (local.data.userId && typeof local.data.userId === "string") {
if (local.data.userId.includes("(")) {
userId = local.data.userId.split("(")[1].split(")")[0];
} else {
userId = local.data.userId;
}
}

// Resolve global IDs to local IDs
if (fileId) {
const localFileId = await this.adapter.mappingDb.getLocalId(fileId);
fileId = localFileId || fileId;
}
if (userId) {
const localUserId = await this.adapter.mappingDb.getLocalId(userId);
userId = localUserId || userId;
}

file = fileId ? await this.fileRepository.findOne({ where: { id: fileId } }) : null;
user = userId ? await this.userService.getUserById(userId) : null;

if (!file || !user) {
console.error("File or user not found for signature");
return res.status(500).send();
}

if (localId) {
// Update existing signature
const signature = await this.signatureRepository.findOne({
where: { id: localId },
});
if (!signature) {
console.error("Signature not found for localId:", localId);
return res.status(500).send();
}

signature.fileId = file.id;
signature.userId = user.id;
signature.md5Hash = local.data.md5Hash as string;
signature.signature = local.data.signature as string;
signature.publicKey = local.data.publicKey as string;
signature.message = local.data.message as string;

this.adapter.addToLockedIds(localId);
await this.signatureRepository.save(signature);
} else {
// Create new signature
const signature = this.signatureRepository.create({
fileId: file.id,
userId: user.id,
md5Hash: local.data.md5Hash as string,
signature: local.data.signature as string,
publicKey: local.data.publicKey as string,
message: local.data.message as string,
});

this.adapter.addToLockedIds(signature.id);
await this.signatureRepository.save(signature);
await this.adapter.mappingDb.storeMapping({
localId: signature.id,
globalId: req.body.id,
});
localId = signature.id;
}
}

res.status(200).json({ success: true });
Expand Down
11 changes: 11 additions & 0 deletions platforms/esigner-api/src/services/InvitationService.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { AppDataSource } from "../database/data-source";
import { File } from "../database/entities/File";
import { FileSignee } from "../database/entities/FileSignee";
import { SignatureContainer } from "../database/entities/SignatureContainer";
import { User } from "../database/entities/User";
import { In } from "typeorm";
import { NotificationService } from "./NotificationService";

export class InvitationService {
private fileRepository = AppDataSource.getRepository(File);
private fileSigneeRepository = AppDataSource.getRepository(FileSignee);
private signatureRepository = AppDataSource.getRepository(SignatureContainer);
private userRepository = AppDataSource.getRepository(User);
private notificationService = new NotificationService();

Expand All @@ -25,6 +27,15 @@ export class InvitationService {
throw new Error("File not found or user is not the owner");
}

// Check if file already has signatures (single-use enforcement)
const existingSignatures = await this.signatureRepository.find({
where: { fileId },
});

if (existingSignatures.length > 0) {
throw new Error("This file has already been used in a signature container and cannot be reused");
}
Comment on lines +30 to +37
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: Race condition (TOCTOU) in single-use enforcement.

Between checking for existing signature containers (lines 31-33) and creating invitations (lines 52-96), concurrent requests can both pass the validation and proceed, violating the single-use constraint. This is a classic time-of-check to time-of-use vulnerability.

🔎 Recommended fix: Wrap in transaction with appropriate isolation
 async inviteSignees(
     fileId: string,
     userIds: string[],
     invitedBy: string
 ): Promise<FileSignee[]> {
+    return await AppDataSource.transaction(async (transactionalEntityManager) => {
+        const signatureRepository = transactionalEntityManager.getRepository(SignatureContainer);
+        const fileRepository = transactionalEntityManager.getRepository(File);
+        const fileSigneeRepository = transactionalEntityManager.getRepository(FileSignee);
+        const userRepository = transactionalEntityManager.getRepository(User);
+
         // Verify file exists and user is owner
-        const file = await this.fileRepository.findOne({
+        const file = await fileRepository.findOne({
             where: { id: fileId, ownerId: invitedBy },
+            lock: { mode: "pessimistic_write" },
         });

         if (!file) {
             throw new Error("File not found or user is not the owner");
         }

         // Check if file already has signatures (single-use enforcement)
-        const existingSignatures = await this.signatureRepository.find({
+        const existingSignatureCount = await signatureRepository.count({
             where: { fileId },
         });

-        if (existingSignatures.length > 0) {
+        if (existingSignatureCount > 0) {
             throw new Error("This file has already been used in a signature container and cannot be reused");
         }

         // ... rest of the method using transactionalEntityManager repositories
+    });
 }

This approach:

  • Uses a transaction to ensure atomicity
  • Applies pessimistic write lock on the file to prevent concurrent modifications
  • Improves performance by using count() instead of find()

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In @platforms/esigner-api/src/services/InvitationService.ts around lines 30-37,
The single-use check in InvitationService (the existingSignatures lookup via
this.signatureRepository.find with fileId) is vulnerable to a TOCTOU race; wrap
the check-and-create logic that spans the validation and invitation creation
(the block that precedes and includes the create-invitation flow) in a database
transaction, acquire a pessimistic write/row lock on the target file record (or
an appropriate row representing the file) inside that transaction, replace the
find(...) call with a count(...) query for existence, and perform the invitation
creation only within the same transaction so concurrent requests are serialized
and cannot both pass the check.


// Filter out the owner from userIds (they can't invite themselves)
const filteredUserIds = userIds.filter(userId => userId !== invitedBy);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"tableName": "files",
"schemaId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"ownerEnamePath": "users(owner.ename)",
"ownedJunctionTables": [],
"localToUniversalMap": {
"name": "name",
"displayName": "displayName",
"description": "description",
"mimeType": "mimeType",
"size": "size",
"md5Hash": "md5Hash",
"data": "data",
"ownerId": "users(owner.id),ownerId",
"createdAt": "__date(createdAt)",
"updatedAt": "__date(updatedAt)"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"tableName": "signature_containers",
"schemaId": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"ownerEnamePath": "users(user.ename)",
"ownedJunctionTables": [],
"localToUniversalMap": {
"fileId": "files(file.id),fileId",
"userId": "users(user.id),userId",
"md5Hash": "md5Hash",
"signature": "signature",
"publicKey": "publicKey",
"message": "message",
"createdAt": "__date(createdAt)",
"updatedAt": "__date(updatedAt)"
}
}
Comment on lines +1 to +16
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

CRITICAL: This mapping file is identical to the one in file-manager-api.

Both platforms/file-manager-api/src/web3adapter/mappings/signature.mapping.json and this file share:

  • Identical schemaId: "b2c3d4e5-f6a7-8901-bcde-f12345678901"
  • Identical tableName, ownerEnamePath, and field mappings

This raises several concerns:

  1. If schemaIds must be globally unique: This is a data integrity violation that could cause collisions in distributed systems.
  2. If this is intentional synchronization: The duplication creates a maintenance burden—updates must be kept in sync manually.
  3. Configuration management: Consider whether this shared configuration should live in a common location.

Verify the intended architecture and either:

  • Generate unique schemaIds if required, or
  • Extract to a shared configuration module if both platforms truly share the same schema

Loading
Loading