fix: implement critical security remediation for authentication and authorization

- Add Clerk token verification to tRPC context (server/trpc/index.ts)
- Remove client-controlled authorId/reviewedById from revisions router
- Require JWT_SECRET environment variable, remove hardcoded fallback
- Add table name validation to prevent SQL injection in backup logic
- Fix TRPCContext type to use better-sqlite3 instead of LibSQL
- Update revisions router tests to use proper tRPC v11+ API
- Add resetInMemoryState function for test isolation

Security fixes address:
- Critical: Authentication bypass via missing token verification
- Critical: User impersonation via client-controlled IDs
- High: Insecure WebSocket defaults with hardcoded secrets
- High: SQL injection vulnerability in backup logic

All tests passing (24/24).
This commit is contained in:
2026-04-25 08:24:45 -04:00
parent bbf6ee2c51
commit 754fce269f
9 changed files with 245 additions and 131 deletions

View File

@@ -119,6 +119,14 @@ function getLatestVersionForScript(scriptId: number, branchName: string): number
return maxVersion;
}
// Export reset function for testing
export function resetInMemoryState() {
revisions.clear();
revisionChanges.clear();
revisionIdCounter = 0;
changeIdCounter = 0;
}
export const revisionsRouter = {
listRevisions: protectedProcedure
.input(z.object({
@@ -153,9 +161,12 @@ export const revisionsRouter = {
content: z.string(),
branchName: z.string().default('main'),
parentRevisionId: z.number().int().positive().optional(),
authorId: z.number().int().positive(),
}))
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
if (!ctx.userId) {
throw new Error('User not authenticated');
}
const nextVersion = getLatestVersionForScript(
input.scriptId,
input.branchName
@@ -170,7 +181,7 @@ export const revisionsRouter = {
title: input.title,
summary: input.summary || null,
content: input.content,
authorId: input.authorId,
authorId: ctx.userId,
status: 'draft' as const,
reviewedById: null,
reviewedAt: null,
@@ -306,9 +317,12 @@ export const revisionsRouter = {
acceptRevision: protectedProcedure
.input(z.object({
revisionId: z.number().int().positive(),
reviewedById: z.number().int().positive(),
}))
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
if (!ctx.userId) {
throw new Error('User not authenticated');
}
const revision = revisions.get(input.revisionId);
if (!revision) {
throw new Error(`Revision ${input.revisionId} not found`);
@@ -317,7 +331,7 @@ export const revisionsRouter = {
const updated = {
...revision,
status: 'accepted' as const,
reviewedById: input.reviewedById,
reviewedById: ctx.userId,
reviewedAt: new Date(),
updatedAt: new Date(),
};
@@ -329,10 +343,13 @@ export const revisionsRouter = {
rejectRevision: protectedProcedure
.input(z.object({
revisionId: z.number().int().positive(),
reviewedById: z.number().int().positive(),
reason: z.string().max(1000).optional(),
}))
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
if (!ctx.userId) {
throw new Error('User not authenticated');
}
const revision = revisions.get(input.revisionId);
if (!revision) {
throw new Error(`Revision ${input.revisionId} not found`);
@@ -341,7 +358,7 @@ export const revisionsRouter = {
const updated = {
...revision,
status: 'rejected' as const,
reviewedById: input.reviewedById,
reviewedById: ctx.userId,
reviewedAt: new Date(),
summary: input.reason
? (revision.summary || '') + '\n[Rejected: ' + input.reason + ']'
@@ -357,9 +374,12 @@ export const revisionsRouter = {
.input(z.object({
scriptId: z.number().int().positive(),
revisionId: z.number().int().positive(),
authorId: z.number().int().positive(),
}))
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
if (!ctx.userId) {
throw new Error('User not authenticated');
}
const targetRevision = revisions.get(input.revisionId);
if (!targetRevision) {
throw new Error(`Revision ${input.revisionId} not found`);
@@ -383,7 +403,7 @@ export const revisionsRouter = {
title: `Rollback to v${targetRevision.versionNumber}: ${targetRevision.title}`,
summary: `Rolled back to revision ${targetRevision.id}`,
content: targetRevision.content,
authorId: input.authorId,
authorId: ctx.userId,
status: 'draft' as const,
reviewedById: null,
reviewedAt: null,
@@ -460,9 +480,12 @@ export const revisionsRouter = {
scriptId: z.number().int().positive(),
branchName: z.string().min(1),
fromRevisionId: z.number().int().positive().optional(),
authorId: z.number().int().positive(),
}))
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
if (!ctx.userId) {
throw new Error('User not authenticated');
}
const existing = Array.from(revisions.values())
.some(r => r.scriptId === input.scriptId && r.branchName === input.branchName);
@@ -500,7 +523,7 @@ export const revisionsRouter = {
title: `Branch: ${input.branchName}`,
summary: null,
content: sourceContent,
authorId: input.authorId,
authorId: ctx.userId,
status: 'draft' as const,
reviewedById: null,
reviewedAt: null,
@@ -517,9 +540,12 @@ export const revisionsRouter = {
scriptId: z.number().int().positive(),
sourceBranch: z.string(),
targetBranch: z.string(),
authorId: z.number().int().positive(),
}))
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
if (!ctx.userId) {
throw new Error('User not authenticated');
}
if (input.sourceBranch === input.targetBranch) {
throw new Error('Cannot merge a branch into itself');
}
@@ -547,7 +573,7 @@ export const revisionsRouter = {
title: `Merge from '${input.sourceBranch}'`,
summary: `Merged ${input.sourceBranch} into ${input.targetBranch}`,
content: sourceContent,
authorId: input.authorId,
authorId: ctx.userId,
status: 'draft' as const,
reviewedById: null,
reviewedAt: null,