documenso-security-basics
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseDocumenso Security Basics
Documenso 安全基础
Overview
概述
Essential security practices for Documenso integrations including API key management, webhook security, and document protection.
Documenso集成的核心安全实践,包括API密钥管理、Webhook安全和文档保护。
Prerequisites
前置条件
- Documenso account with API access
- Understanding of environment variables
- Basic security concepts
- 拥有API访问权限的Documenso账户
- 了解环境变量的使用
- 掌握基础安全概念
Instructions
操作步骤
Step 1: Secure API Key Management
步骤1:安全管理API密钥
typescript
// NEVER do this:
const client = new Documenso({
apiKey: "dcs_abc123...", // Hardcoded - BAD!
});
// ALWAYS do this:
const client = new Documenso({
apiKey: process.env.DOCUMENSO_API_KEY ?? "",
});
// Validate API key is present at startup
function validateEnvironment(): void {
const required = ["DOCUMENSO_API_KEY"];
const missing = required.filter((key) => !process.env[key]);
if (missing.length > 0) {
throw new Error(
`Missing required environment variables: ${missing.join(", ")}`
);
}
// Validate API key format
const apiKey = process.env.DOCUMENSO_API_KEY!;
if (!apiKey.startsWith("dcs_")) {
console.warn("Warning: API key format unexpected (should start with dcs_)");
}
}typescript
// NEVER do this:
const client = new Documenso({
apiKey: "dcs_abc123...", // Hardcoded - BAD!
});
// ALWAYS do this:
const client = new Documenso({
apiKey: process.env.DOCUMENSO_API_KEY ?? "",
});
// Validate API key is present at startup
function validateEnvironment(): void {
const required = ["DOCUMENSO_API_KEY"];
const missing = required.filter((key) => !process.env[key]);
if (missing.length > 0) {
throw new Error(
`Missing required environment variables: ${missing.join(", ")}`
);
}
// Validate API key format
const apiKey = process.env.DOCUMENSO_API_KEY!;
if (!apiKey.startsWith("dcs_")) {
console.warn("Warning: API key format unexpected (should start with dcs_)");
}
}Step 2: API Key Rotation
步骤2:API密钥轮换
typescript
// Support multiple API keys for rotation
interface KeyRotationConfig {
primaryKey: string;
secondaryKey?: string; // Fallback during rotation
}
async function getClientWithFallback(
config: KeyRotationConfig
): Promise<Documenso> {
// Try primary key first
try {
const client = new Documenso({ apiKey: config.primaryKey });
await client.documents.findV0({ perPage: 1 });
return client;
} catch (error: any) {
if (error.statusCode === 401 && config.secondaryKey) {
console.warn("Primary key failed, trying secondary...");
return new Documenso({ apiKey: config.secondaryKey });
}
throw error;
}
}
// Rotation procedure:
// 1. Generate new key in Documenso dashboard
// 2. Set as DOCUMENSO_API_KEY_SECONDARY
// 3. Test secondary key works
// 4. Swap: SECONDARY -> PRIMARY
// 5. Revoke old primary key
// 6. Remove secondary env vartypescript
// Support multiple API keys for rotation
interface KeyRotationConfig {
primaryKey: string;
secondaryKey?: string; // Fallback during rotation
}
async function getClientWithFallback(
config: KeyRotationConfig
): Promise<Documenso> {
// Try primary key first
try {
const client = new Documenso({ apiKey: config.primaryKey });
await client.documents.findV0({ perPage: 1 });
return client;
} catch (error: any) {
if (error.statusCode === 401 && config.secondaryKey) {
console.warn("Primary key failed, trying secondary...");
return new Documenso({ apiKey: config.secondaryKey });
}
throw error;
}
}
// Rotation procedure:
// 1. Generate new key in Documenso dashboard
// 2. Set as DOCUMENSO_API_KEY_SECONDARY
// 3. Test secondary key works
// 4. Swap: SECONDARY -> PRIMARY
// 5. Revoke old primary key
// 6. Remove secondary env varStep 3: Webhook Security
步骤3:Webhook安全
typescript
import express from "express";
const app = express();
// Parse raw body for signature verification
app.use("/webhooks/documenso", express.raw({ type: "application/json" }));
app.post("/webhooks/documenso", (req, res) => {
// Verify webhook secret
const receivedSecret = req.headers["x-documenso-secret"];
const expectedSecret = process.env.DOCUMENSO_WEBHOOK_SECRET;
if (!expectedSecret) {
console.error("DOCUMENSO_WEBHOOK_SECRET not configured");
return res.status(500).json({ error: "Webhook not configured" });
}
// Timing-safe comparison to prevent timing attacks
if (!timingSafeEqual(receivedSecret as string, expectedSecret)) {
console.warn("Invalid webhook secret received");
return res.status(401).json({ error: "Invalid signature" });
}
// Parse and process
try {
const payload = JSON.parse(req.body.toString());
handleWebhookEvent(payload);
res.status(200).json({ received: true });
} catch (error) {
console.error("Webhook processing error:", error);
res.status(400).json({ error: "Invalid payload" });
}
});
function timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
return require("crypto").timingSafeEqual(bufA, bufB);
}typescript
import express from "express";
const app = express();
// Parse raw body for signature verification
app.use("/webhooks/documenso", express.raw({ type: "application/json" }));
app.post("/webhooks/documenso", (req, res) => {
// Verify webhook secret
const receivedSecret = req.headers["x-documenso-secret"];
const expectedSecret = process.env.DOCUMENSO_WEBHOOK_SECRET;
if (!expectedSecret) {
console.error("DOCUMENSO_WEBHOOK_SECRET not configured");
return res.status(500).json({ error: "Webhook not configured" });
}
// Timing-safe comparison to prevent timing attacks
if (!timingSafeEqual(receivedSecret as string, expectedSecret)) {
console.warn("Invalid webhook secret received");
return res.status(401).json({ error: "Invalid signature" });
}
// Parse and process
try {
const payload = JSON.parse(req.body.toString());
handleWebhookEvent(payload);
res.status(200).json({ received: true });
} catch (error) {
console.error("Webhook processing error:", error);
res.status(400).json({ error: "Invalid payload" });
}
});
function timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
return require("crypto").timingSafeEqual(bufA, bufB);
}Step 4: Document Access Control
步骤4:文档访问控制
typescript
// Track document ownership for access control
interface DocumentAccess {
documentId: string;
ownerId: string;
authorizedEmails: string[];
}
class DocumentAccessControl {
private accessMap = new Map<string, DocumentAccess>();
async createDocument(
userId: string,
title: string,
authorizedEmails: string[]
): Promise<string> {
const doc = await client.documents.createV0({ title });
const documentId = doc.documentId!;
this.accessMap.set(documentId, {
documentId,
ownerId: userId,
authorizedEmails,
});
return documentId;
}
canAccess(userId: string, documentId: string): boolean {
const access = this.accessMap.get(documentId);
if (!access) return false;
return access.ownerId === userId;
}
canSign(email: string, documentId: string): boolean {
const access = this.accessMap.get(documentId);
if (!access) return false;
return access.authorizedEmails.includes(email.toLowerCase());
}
}typescript
// Track document ownership for access control
interface DocumentAccess {
documentId: string;
ownerId: string;
authorizedEmails: string[];
}
class DocumentAccessControl {
private accessMap = new Map<string, DocumentAccess>();
async createDocument(
userId: string,
title: string,
authorizedEmails: string[]
): Promise<string> {
const doc = await client.documents.createV0({ title });
const documentId = doc.documentId!;
this.accessMap.set(documentId, {
documentId,
ownerId: userId,
authorizedEmails,
});
return documentId;
}
canAccess(userId: string, documentId: string): boolean {
const access = this.accessMap.get(documentId);
if (!access) return false;
return access.ownerId === userId;
}
canSign(email: string, documentId: string): boolean {
const access = this.accessMap.get(documentId);
if (!access) return false;
return access.authorizedEmails.includes(email.toLowerCase());
}
}Step 5: Signing URL Security
步骤5:签名URL安全
typescript
// Signing URLs should be treated as sensitive
// They grant access to sign documents
// DON'T expose signing URLs in logs
function logDocument(doc: any): void {
const safeDoc = { ...doc };
if (safeDoc.recipients) {
safeDoc.recipients = safeDoc.recipients.map((r: any) => ({
...r,
signingUrl: "[REDACTED]",
signingToken: "[REDACTED]",
}));
}
console.log(JSON.stringify(safeDoc, null, 2));
}
// DON'T store signing URLs in insecure locations
// They should be sent directly to recipients via secure channel
// DO set appropriate expiry on embedded signing sessions
async function getSecureSigningSession(
documentId: string,
recipientEmail: string
): Promise<{ signingUrl: string; expiresAt: Date }> {
const doc = await client.documents.getV0({ documentId });
const recipient = doc.recipients?.find((r) => r.email === recipientEmail);
if (!recipient?.signingUrl) {
throw new Error("Signing URL not available");
}
return {
signingUrl: recipient.signingUrl,
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
};
}typescript
// Signing URLs should be treated as sensitive
// They grant access to sign documents
// DON'T expose signing URLs in logs
function logDocument(doc: any): void {
const safeDoc = { ...doc };
if (safeDoc.recipients) {
safeDoc.recipients = safeDoc.recipients.map((r: any) => ({
...r,
signingUrl: "[REDACTED]",
signingToken: "[REDACTED]",
}));
}
console.log(JSON.stringify(safeDoc, null, 2));
}
// DON'T store signing URLs in insecure locations
// They should be sent directly to recipients via secure channel
// DO set appropriate expiry on embedded signing sessions
async function getSecureSigningSession(
documentId: string,
recipientEmail: string
): Promise<{ signingUrl: string; expiresAt: Date }> {
const doc = await client.documents.getV0({ documentId });
const recipient = doc.recipients?.find((r) => r.email === recipientEmail);
if (!recipient?.signingUrl) {
throw new Error("Signing URL not available");
}
return {
signingUrl: recipient.signingUrl,
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
};
}Step 6: Input Validation
步骤6:输入验证
typescript
import { z } from "zod";
// Validate recipient input
const RecipientInputSchema = z.object({
email: z
.string()
.email("Invalid email format")
.transform((e) => e.toLowerCase().trim()),
name: z
.string()
.min(1, "Name is required")
.max(100, "Name too long")
.transform((n) => n.trim()),
role: z.enum(["SIGNER", "APPROVER", "VIEWER", "CC"]),
});
// Validate document title
const DocumentInputSchema = z.object({
title: z
.string()
.min(1, "Title is required")
.max(255, "Title too long")
.refine(
(t) => !/<script/i.test(t),
"Title contains invalid characters"
),
});
// Validate file upload
async function validatePdfUpload(
file: Buffer,
maxSizeMb = 10
): Promise<boolean> {
// Check file size
const maxBytes = maxSizeMb * 1024 * 1024;
if (file.length > maxBytes) {
throw new Error(`File exceeds ${maxSizeMb}MB limit`);
}
// Verify PDF magic bytes
const pdfMagic = Buffer.from([0x25, 0x50, 0x44, 0x46]); // %PDF
if (!file.slice(0, 4).equals(pdfMagic)) {
throw new Error("File is not a valid PDF");
}
return true;
}
// Use in handler
async function createDocumentHandler(input: unknown) {
const validated = DocumentInputSchema.parse(input);
return client.documents.createV0(validated);
}typescript
import { z } from "zod";
// Validate recipient input
const RecipientInputSchema = z.object({
email: z
.string()
.email("Invalid email format")
.transform((e) => e.toLowerCase().trim()),
name: z
.string()
.min(1, "Name is required")
.max(100, "Name too long")
.transform((n) => n.trim()),
role: z.enum(["SIGNER", "APPROVER", "VIEWER", "CC"]),
});
// Validate document title
const DocumentInputSchema = z.object({
title: z
.string()
.min(1, "Title is required")
.max(255, "Title too long")
.refine(
(t) => !/<script/i.test(t),
"Title contains invalid characters"
),
});
// Validate file upload
async function validatePdfUpload(
file: Buffer,
maxSizeMb = 10
): Promise<boolean> {
// Check file size
const maxBytes = maxSizeMb * 1024 * 1024;
if (file.length > maxBytes) {
throw new Error(`File exceeds ${maxSizeMb}MB limit`);
}
// Verify PDF magic bytes
const pdfMagic = Buffer.from([0x25, 0x50, 0x44, 0x46]); // %PDF
if (!file.slice(0, 4).equals(pdfMagic)) {
throw new Error("File is not a valid PDF");
}
return true;
}
// Use in handler
async function createDocumentHandler(input: unknown) {
const validated = DocumentInputSchema.parse(input);
return client.documents.createV0(validated);
}Step 7: Audit Logging
步骤7:审计日志
typescript
interface AuditEntry {
timestamp: Date;
userId: string;
action: string;
resourceType: "document" | "template" | "recipient";
resourceId: string;
ipAddress?: string;
userAgent?: string;
success: boolean;
error?: string;
}
class AuditLogger {
private entries: AuditEntry[] = [];
log(entry: Omit<AuditEntry, "timestamp">): void {
const fullEntry: AuditEntry = {
...entry,
timestamp: new Date(),
};
this.entries.push(fullEntry);
// Log to console (in production, send to logging service)
console.log(
`[AUDIT] ${entry.action} ${entry.resourceType}:${entry.resourceId} ` +
`by ${entry.userId} - ${entry.success ? "SUCCESS" : "FAILED"}`
);
// Alert on suspicious activity
if (!entry.success && entry.action === "delete") {
console.warn(`[SECURITY] Failed delete attempt by ${entry.userId}`);
}
}
getRecentEntries(limit = 100): AuditEntry[] {
return this.entries.slice(-limit);
}
}
const auditLogger = new AuditLogger();
// Wrap operations with audit logging
async function auditedDeleteDocument(
userId: string,
documentId: string
): Promise<boolean> {
try {
await client.documents.deleteV0({ documentId });
auditLogger.log({
userId,
action: "delete",
resourceType: "document",
resourceId: documentId,
success: true,
});
return true;
} catch (error: any) {
auditLogger.log({
userId,
action: "delete",
resourceType: "document",
resourceId: documentId,
success: false,
error: error.message,
});
throw error;
}
}typescript
interface AuditEntry {
timestamp: Date;
userId: string;
action: string;
resourceType: "document" | "template" | "recipient";
resourceId: string;
ipAddress?: string;
userAgent?: string;
success: boolean;
error?: string;
}
class AuditLogger {
private entries: AuditEntry[] = [];
log(entry: Omit<AuditEntry, "timestamp">): void {
const fullEntry: AuditEntry = {
...entry,
timestamp: new Date(),
};
this.entries.push(fullEntry);
// Log to console (in production, send to logging service)
console.log(
`[AUDIT] ${entry.action} ${entry.resourceType}:${entry.resourceId} ` +
`by ${entry.userId} - ${entry.success ? "SUCCESS" : "FAILED"}`
);
// Alert on suspicious activity
if (!entry.success && entry.action === "delete") {
console.warn(`[SECURITY] Failed delete attempt by ${entry.userId}`);
}
}
getRecentEntries(limit = 100): AuditEntry[] {
return this.entries.slice(-limit);
}
}
const auditLogger = new AuditLogger();
// Wrap operations with audit logging
async function auditedDeleteDocument(
userId: string,
documentId: string
): Promise<boolean> {
try {
await client.documents.deleteV0({ documentId });
auditLogger.log({
userId,
action: "delete",
resourceType: "document",
resourceId: documentId,
success: true,
});
return true;
} catch (error: any) {
auditLogger.log({
userId,
action: "delete",
resourceType: "document",
resourceId: documentId,
success: false,
error: error.message,
});
throw error;
}
}Security Checklist
安全检查清单
- API keys stored in environment variables only
- API keys never logged or exposed in responses
- Webhook endpoints validate X-Documenso-Secret
- Webhook secret stored securely
- Signing URLs treated as sensitive data
- Input validation on all user-provided data
- File uploads validated (type and size)
- Audit logging for sensitive operations
- Access control checks before operations
- Rate limiting to prevent abuse
- API密钥仅存储在环境变量中
- API密钥从不记录或在响应中暴露
- Webhook端点验证X-Documenso-Secret
- Webhook密钥安全存储
- 签名URL被视为敏感数据
- 对所有用户提供的数据进行输入验证
- 文件上传经过验证(类型和大小)
- 敏感操作启用审计日志
- 操作前进行访问控制检查
- 启用速率限制防止滥用
Output
输出结果
- Secure API key management
- Validated webhook endpoints
- Input sanitization
- Audit trail for compliance
- 安全的API密钥管理
- 经过验证的Webhook端点
- 输入数据清理
- 符合合规要求的审计追踪
Error Handling
错误处理
| Security Issue | Indicator | Response |
|---|---|---|
| Invalid API key | 401 errors | Rotate key |
| Webhook spoofing | Invalid secret | Reject and alert |
| Unauthorized access | 403 errors | Check permissions |
| Brute force | Many 401s | Rate limit IP |
| 安全问题 | 标识 | 响应措施 |
|---|---|---|
| 无效API密钥 | 401错误 | 轮换密钥 |
| Webhook伪造 | 无效密钥 | 拒绝请求并发出警报 |
| 未授权访问 | 403错误 | 检查权限设置 |
| 暴力破解 | 大量401错误 | 对IP进行速率限制 |
Resources
参考资源
Next Steps
后续步骤
For production deployment, see .
documenso-prod-checklist如需生产环境部署,请参考。
documenso-prod-checklist