From c4d2e446a50de81f88f71f84054b546a86689569 Mon Sep 17 00:00:00 2001 From: ASHISH SINGH BORA Date: Fri, 30 Oct 2026 07:04:29 +0530 Subject: [PATCH] fix(security): harden production configuration and GitHub trust boundaries (#11) --- .env.example | 43 +++++-- .github/workflows/ci.yml | 5 + drizzle/0005_project_trust_boundary.sql | 7 ++ drizzle/meta/_journal.json | 37 +++++- src/app/api/sync/issues/route.ts | 58 +++++++-- src/app/api/webhooks/github/route.ts | 8 ++ src/lib/db/index.ts | 40 +++++-- src/lib/db/schema.ts | 4 + src/lib/env.test.ts | 85 ++++++++++++- src/lib/env.ts | 152 +++++++++++++++++++++--- src/lib/github/issues.ts | 28 ++++- src/lib/github/webhooks.test.ts | 48 ++++++++ src/lib/github/webhooks.ts | 27 ++++- src/lib/projects/approval.test.ts | 129 ++++++++++++++++++++ src/lib/projects/approval.ts | 21 ++++ 15 files changed, 633 insertions(+), 59 deletions(-) create mode 100644 drizzle/0005_project_trust_boundary.sql create mode 100644 src/lib/projects/approval.test.ts diff --git a/.env.example b/.env.example index caa8f6f..638b3e0 100644 --- a/.env.example +++ b/.env.example @@ -1,22 +1,41 @@ -# Server Environment Variables +# ============================================================================== +# TechNexusOrg Platform Environment Configuration Example +# Copy this file to .env or .env.local for local development. +# In production, all secrets must be strong, randomly generated, and unique. +# ============================================================================== + +# Application Runtime NODE_ENV=development APP_URL=http://localhost:3000 -# Authentication & Session Security (AES-256 secret, min 32 chars) -AUTH_SECRET=changeme_in_production_min_32_characters_long_secret_key_123 +# Authentication & Session Security (Min 32 characters, randomly generated) +# Generate with: openssl rand -base64 32 +AUTH_SECRET=development_secret_key_strictly_for_local_testing_min_32_chars + +# Internal Automation & Service-to-Service Secret (Min 32 characters) +# Used for backend cron sync jobs and service-level operations +# Generate with: openssl rand -base64 32 +INTERNAL_SERVICE_SECRET=dev_internal_service_key_strictly_for_local_testing_min_32_chars -# GitHub OAuth App Credentials +# GitHub OAuth Application Credentials # Register at: https://github.com/settings/developers -GITHUB_CLIENT_ID=your_github_client_id -GITHUB_CLIENT_SECRET=your_github_client_secret +# Callback URL: /api/auth/callback +GITHUB_CLIENT_ID=your_github_oauth_client_id +GITHUB_CLIENT_SECRET=your_github_oauth_client_secret -# Optional: GitHub Personal Access Token or App Token for elevated rate limits when querying TechNexusOrg +# Optional: GitHub Access Token for elevated API rate limits on repository sync GITHUB_ACCESS_TOKEN= -# Database Connection (PostgreSQL) -# e.g., postgresql://user:password@localhost:5432/technexus -# If left empty in development/testing, platform falls back to embedded PGlite +# GitHub Webhook HMAC Secret +# Configured in TechNexusOrg organization webhook settings +# Payload URL: /api/webhooks/github +GITHUB_WEBHOOK_SECRET=your_github_webhook_secret_key + +# PostgreSQL Database Connection URL +# Mandatory in production. Format: postgresql://username:password@hostname:5432/database_name +# If left unset in development/test, platform uses embedded PGlite DATABASE_URL= -# GitHub Webhook Secret (for verifying incoming webhooks from TechNexusOrg repositories) -GITHUB_WEBHOOK_SECRET=your_github_webhook_secret_key +# Organization Trust Boundary (Defaults to official TechNexusOrg GitHub Org) +TECH_NEXUS_ORG_ID=333899669 +TECH_NEXUS_ORG_NAME=TechNexusOrg diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 16a3008..8fe0878 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,9 @@ on: pull_request: branches: [main] +permissions: + contents: read + jobs: validate: name: Lint, Test & Build @@ -37,5 +40,7 @@ jobs: run: npm run build env: NODE_ENV: production + CI: "true" AUTH_SECRET: "ci_build_dummy_secret_key_strictly_for_testing_32_chars" + INTERNAL_SERVICE_SECRET: "ci_service_dummy_secret_key_strictly_for_testing_32_chars" APP_URL: http://localhost:3000 diff --git a/drizzle/0005_project_trust_boundary.sql b/drizzle/0005_project_trust_boundary.sql new file mode 100644 index 0000000..2b5e4af --- /dev/null +++ b/drizzle/0005_project_trust_boundary.sql @@ -0,0 +1,7 @@ +ALTER TABLE "projects" ADD COLUMN IF NOT EXISTS "github_repo_id" integer; +--> statement-breakpoint +ALTER TABLE "projects" ADD COLUMN IF NOT EXISTS "github_owner_id" integer; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "projects_github_repo_id_idx" ON "projects" ("github_repo_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "projects_github_owner_id_idx" ON "projects" ("github_owner_id"); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index f69ad27..a132e80 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,41 @@ "when": 1790358237482, "tag": "0000_demonic_owl", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1791000000000, + "tag": "0001_sprint_lifecycle_tables", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1792000000000, + "tag": "0002_issue_body", + "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1792500000000, + "tag": "0003_pr_nullable_user", + "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1793000000000, + "tag": "0004_mvp_hardening", + "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1793325000000, + "tag": "0005_project_trust_boundary", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/src/app/api/sync/issues/route.ts b/src/app/api/sync/issues/route.ts index 257b800..94380d6 100644 --- a/src/app/api/sync/issues/route.ts +++ b/src/app/api/sync/issues/route.ts @@ -1,24 +1,55 @@ import { NextRequest, NextResponse } from "next/server"; -import { verifySessionToken, COOKIE_NAME } from "@/lib/auth/session"; +import { getFreshAuthenticatedUser, hasRole } from "@/lib/auth/authorization"; import { syncRepositoryIssues } from "@/lib/github/issues"; +import { env, safeCompare } from "@/lib/env"; export async function POST(request: NextRequest) { - const token = request.cookies.get(COOKIE_NAME)?.value; - const user = token ? await verifySessionToken(token) : null; - - // Verify privileged role or internal bearer secret + // 1. Verify service bearer token using constant-time comparison const authHeader = request.headers.get("authorization"); - const isAuthorizedSecret = - authHeader && process.env.AUTH_SECRET && authHeader === `Bearer ${process.env.AUTH_SECRET}`; + const expectedBearer = `Bearer ${env.INTERNAL_SERVICE_SECRET}`; + const isAuthorizedService = safeCompare(authHeader, expectedBearer); + + let isAuthorizedUser = false; + if (!isAuthorizedService) { + // 2. Fall back to fresh server-side DB session verification + const freshUser = await getFreshAuthenticatedUser(request); + if (freshUser && hasRole(freshUser, ["admin", "maintainer"])) { + // CSRF / Origin validation for session cookie requests + const origin = request.headers.get("origin"); + const host = request.headers.get("host"); + if (origin && host) { + try { + const originHost = new URL(origin).host; + if (originHost !== host) { + return NextResponse.json({ error: "CSRF protection: Invalid origin." }, { status: 403 }); + } + } catch { + return NextResponse.json({ error: "CSRF protection: Malformed origin." }, { status: 403 }); + } + } + isAuthorizedUser = true; + } + } - if (!isAuthorizedSecret && (!user || (user.role !== "admin" && user.role !== "maintainer"))) { - return NextResponse.json({ error: "Unauthorized. Admin or maintainer privileges required." }, { status: 403 }); + if (!isAuthorizedService && !isAuthorizedUser) { + return NextResponse.json( + { error: "Unauthorized. Admin or maintainer privileges required." }, + { status: 403 } + ); } try { const body = await request.json().catch(() => ({})); - const owner = body.owner || "TechNexusOrg"; - const repo = body.repo || "platform"; + const owner = (body.owner || env.TECH_NEXUS_ORG_NAME).trim(); + const repo = (body.repo || "platform").trim(); + + // Trust boundary: enforce official organization + if (owner.toLowerCase() !== env.TECH_NEXUS_ORG_NAME.toLowerCase()) { + return NextResponse.json( + { error: `Forbidden: Only repositories under ${env.TECH_NEXUS_ORG_NAME} may be synced.` }, + { status: 400 } + ); + } const result = await syncRepositoryIssues(owner, repo); @@ -30,6 +61,9 @@ export async function POST(request: NextRequest) { }); } catch (error: any) { console.error("Issue sync error:", error); - return NextResponse.json({ error: error.message || "Failed to sync issues" }, { status: 500 }); + return NextResponse.json( + { error: error.message || "Failed to sync issues" }, + { status: 500 } + ); } } diff --git a/src/app/api/webhooks/github/route.ts b/src/app/api/webhooks/github/route.ts index 9396651..5f8baf7 100644 --- a/src/app/api/webhooks/github/route.ts +++ b/src/app/api/webhooks/github/route.ts @@ -22,6 +22,14 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "Invalid signature" }, { status: 401 }); } + // Delivery identifier is mandatory for audit & idempotency + if (!deliveryId) { + return NextResponse.json( + { error: "Missing required X-GitHub-Delivery header" }, + { status: 400 } + ); + } + let repoName: string | undefined; try { const parsed = JSON.parse(rawBody); diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts index f1296ef..090a1e0 100644 --- a/src/lib/db/index.ts +++ b/src/lib/db/index.ts @@ -9,14 +9,31 @@ export async function getDb() { const databaseUrl = process.env.DATABASE_URL; - if (databaseUrl && databaseUrl.startsWith("postgres")) { - const { drizzle } = await import("drizzle-orm/postgres-js"); - const postgres = (await import("postgres")).default; - const client = postgres(databaseUrl, { - max: process.env.NODE_ENV === "production" ? 10 : 1, - }); - dbInstance = drizzle(client, { schema }); - return dbInstance; + if ( + databaseUrl && + (databaseUrl.startsWith("postgres://") || + databaseUrl.startsWith("postgresql://") || + databaseUrl.startsWith("postgres:")) + ) { + try { + const { drizzle } = await import("drizzle-orm/postgres-js"); + const postgres = (await import("postgres")).default; + const client = postgres(databaseUrl, { + max: process.env.NODE_ENV === "production" ? 10 : 1, + }); + dbInstance = drizzle(client, { schema }); + return dbInstance; + } catch (err: any) { + console.error("Failed to connect to PostgreSQL database:", err); + throw new Error(`Production database connection failed: ${err.message}`); + } + } + + // Fail-closed in production: Never silently fall back to local file or memory + if (process.env.NODE_ENV === "production") { + throw new Error( + "Fatal: Production environment requires a valid PostgreSQL DATABASE_URL. PGlite local database fallback is strictly disabled in production." + ); } // Embedded PGlite fallback for local development & testing @@ -24,9 +41,10 @@ export async function getDb() { const { PGlite } = await import("@electric-sql/pglite"); // In test environment, use pure in-memory instance - const pglite = process.env.NODE_ENV === "test" - ? new PGlite() - : new PGlite(process.env.PGLITE_DATA_DIR || "./.data/pglite"); + const pglite = + process.env.NODE_ENV === "test" + ? new PGlite() + : new PGlite(process.env.PGLITE_DATA_DIR || "./.data/pglite"); dbInstance = drizzle(pglite, { schema }); return dbInstance; diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 2c44b96..fc06728 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -107,6 +107,8 @@ export const projects = pgTable( name: text("name").notNull(), slug: text("slug").notNull().unique(), githubRepo: text("github_repo").notNull().unique(), // e.g. "TechNexusOrg/platform" + githubRepoId: integer("github_repo_id"), + githubOwnerId: integer("github_owner_id"), description: text("description").notNull(), primaryLanguage: text("primary_language").notNull(), languages: jsonb("languages").$type().default([]).notNull(), @@ -133,6 +135,8 @@ export const projects = pgTable( (table) => [ uniqueIndex("projects_slug_idx").on(table.slug), uniqueIndex("projects_repo_idx").on(table.githubRepo), + index("projects_github_repo_id_idx").on(table.githubRepoId), + index("projects_github_owner_id_idx").on(table.githubOwnerId), ] ); diff --git a/src/lib/env.test.ts b/src/lib/env.test.ts index 7bf3d43..40c43b7 100644 --- a/src/lib/env.test.ts +++ b/src/lib/env.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { validateEnv } from "./env"; +import { validateEnv, safeCompare } from "./env"; describe("Environment Validation", () => { it("validates default development environment safely", () => { @@ -7,6 +7,7 @@ describe("Environment Validation", () => { NODE_ENV: "development", APP_URL: "http://localhost:3000", AUTH_SECRET: "12345678901234567890123456789012", + INTERNAL_SERVICE_SECRET: "12345678901234567890123456789012", GITHUB_CLIENT_ID: "mock_id", GITHUB_CLIENT_SECRET: "mock_secret", GITHUB_WEBHOOK_SECRET: "test_secret", @@ -15,6 +16,8 @@ describe("Environment Validation", () => { expect(valid.NODE_ENV).toBe("development"); expect(valid.APP_URL).toBe("http://localhost:3000"); expect(valid.AUTH_SECRET).toHaveLength(32); + expect(valid.TECH_NEXUS_ORG_ID).toBe(333899669); + expect(valid.TECH_NEXUS_ORG_NAME).toBe("TechNexusOrg"); }); it("fails if AUTH_SECRET is shorter than 32 characters", () => { @@ -35,4 +38,84 @@ describe("Environment Validation", () => { }) ).toThrow(); }); + + describe("Production Hardening & Fail-Closed Guardrails", () => { + it("fails in production if known dev placeholder AUTH_SECRET is used", () => { + expect(() => + validateEnv({ + NODE_ENV: "production", + APP_URL: "https://platform.technexus.org", + AUTH_SECRET: "development_secret_key_strictly_for_local_testing_min_32_chars", + INTERNAL_SERVICE_SECRET: "strong_production_service_secret_999999999999", + GITHUB_CLIENT_ID: "valid_client_id_12345", + GITHUB_CLIENT_SECRET: "valid_client_secret_9876543210", + GITHUB_WEBHOOK_SECRET: "strong_webhook_secret_123456", + DATABASE_URL: "postgresql://postgres:secret@db.host.internal:5432/technexus", + }) + ).toThrow(/Production requires a strong, random AUTH_SECRET/); + }); + + it("fails in production if GITHUB_CLIENT_ID is a mock placeholder", () => { + expect(() => + validateEnv({ + NODE_ENV: "production", + APP_URL: "https://platform.technexus.org", + AUTH_SECRET: "strong_production_auth_secret_111111111111111", + INTERNAL_SERVICE_SECRET: "strong_production_service_secret_999999999999", + GITHUB_CLIENT_ID: "mock_client_id_for_dev", + GITHUB_CLIENT_SECRET: "valid_client_secret_9876543210", + GITHUB_WEBHOOK_SECRET: "strong_webhook_secret_123456", + DATABASE_URL: "postgresql://postgres:secret@db.host.internal:5432/technexus", + }) + ).toThrow(/Production requires a valid GITHUB_CLIENT_ID/); + }); + + it("fails in production if DATABASE_URL is missing or invalid", () => { + expect(() => + validateEnv({ + NODE_ENV: "production", + APP_URL: "https://platform.technexus.org", + AUTH_SECRET: "strong_production_auth_secret_111111111111111", + INTERNAL_SERVICE_SECRET: "strong_production_service_secret_999999999999", + GITHUB_CLIENT_ID: "valid_client_id_12345", + GITHUB_CLIENT_SECRET: "valid_client_secret_9876543210", + GITHUB_WEBHOOK_SECRET: "strong_webhook_secret_123456", + DATABASE_URL: "", + }) + ).toThrow(/Production requires a valid PostgreSQL DATABASE_URL/); + }); + + it("passes in production when all security requirements are satisfied", () => { + const valid = validateEnv({ + NODE_ENV: "production", + APP_URL: "https://platform.technexus.org", + AUTH_SECRET: "strong_production_auth_secret_111111111111111", + INTERNAL_SERVICE_SECRET: "strong_production_service_secret_999999999999", + GITHUB_CLIENT_ID: "valid_client_id_12345", + GITHUB_CLIENT_SECRET: "valid_client_secret_9876543210", + GITHUB_WEBHOOK_SECRET: "strong_webhook_secret_123456", + DATABASE_URL: "postgresql://postgres:secret@db.host.internal:5432/technexus", + }); + + expect(valid.NODE_ENV).toBe("production"); + expect(valid.DATABASE_URL).toContain("postgresql://"); + }); + }); + + describe("safeCompare", () => { + it("returns true for matching strings in constant time", () => { + expect(safeCompare("super_secret_token_123", "super_secret_token_123")).toBe(true); + }); + + it("returns false for non-matching strings", () => { + expect(safeCompare("super_secret_token_123", "wrong_token_456")).toBe(false); + expect(safeCompare("super_secret_token_123", "super_secret_token_124")).toBe(false); + }); + + it("returns false safely when strings differ in length or are missing", () => { + expect(safeCompare("short", "longer_string")).toBe(false); + expect(safeCompare(null, "some_token")).toBe(false); + expect(safeCompare("some_token", undefined)).toBe(false); + }); + }); }); diff --git a/src/lib/env.ts b/src/lib/env.ts index 730425b..935ce43 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -1,23 +1,130 @@ import { z } from "zod"; +import crypto from "crypto"; -const envSchema = z.object({ - NODE_ENV: z.enum(["development", "test", "production"]).default("development"), - APP_URL: z.string().url().default("http://localhost:3000"), - AUTH_SECRET: z - .string() - .min(32, "AUTH_SECRET must be at least 32 characters long") - .default("development_secret_key_strictly_for_local_testing_min_32_chars"), - GITHUB_CLIENT_ID: z.string().default("mock_client_id_for_dev"), - GITHUB_CLIENT_SECRET: z.string().default("mock_client_secret_for_dev"), - GITHUB_ACCESS_TOKEN: z.string().optional(), - GITHUB_WEBHOOK_SECRET: z.string().default("dev_webhook_secret_key"), - DATABASE_URL: z.string().optional(), -}); - -export type Env = z.infer; +const FORBIDDEN_PROD_AUTH_SECRETS = [ + "development_secret_key_strictly_for_local_testing_min_32_chars", + "ci_build_dummy_secret_key_strictly_for_testing_32_chars", + "12345678901234567890123456789012", + "00000000000000000000000000000000", + "changeme_changeme_changeme_changeme", +]; +const FORBIDDEN_PROD_SERVICE_SECRETS = [ + "dev_internal_service_key_strictly_for_local_testing_min_32_chars", + "12345678901234567890123456789012", + "changeme_changeme_changeme_changeme", +]; + +const FORBIDDEN_PROD_WEBHOOK_SECRETS = [ + "dev_webhook_secret_key", + "test_secret", + "webhook_secret", + "changeme", +]; + +export function createEnvSchema(isExplicitCustomEnv = false) { + return z + .object({ + NODE_ENV: z.enum(["development", "test", "production"]).default("development"), + CI: z.string().optional(), + APP_URL: z.string().url().default("http://localhost:3000"), + AUTH_SECRET: z + .string() + .min(32, "AUTH_SECRET must be at least 32 characters long") + .default("development_secret_key_strictly_for_local_testing_min_32_chars"), + INTERNAL_SERVICE_SECRET: z + .string() + .min(32, "INTERNAL_SERVICE_SECRET must be at least 32 characters long") + .default("dev_internal_service_key_strictly_for_local_testing_min_32_chars"), + GITHUB_CLIENT_ID: z.string().default("mock_client_id_for_dev"), + GITHUB_CLIENT_SECRET: z.string().default("mock_client_secret_for_dev"), + GITHUB_ACCESS_TOKEN: z.string().optional(), + GITHUB_WEBHOOK_SECRET: z.string().default("dev_webhook_secret_key"), + DATABASE_URL: z.string().optional(), + TECH_NEXUS_ORG_ID: z.coerce.number().default(333899669), + TECH_NEXUS_ORG_NAME: z.string().default("TechNexusOrg"), + }) + .superRefine((data, ctx) => { + // Production runtime fail-closed validations + // During Next.js compile/build phase without custom env, permit build to complete + const isProduction = data.NODE_ENV === "production"; + const isCI = data.CI === "true" || data.CI === "1"; + const isBuildPhase = + (process.env.NEXT_PHASE === "phase-production-build" || + process.env.npm_lifecycle_event === "build") && + !isExplicitCustomEnv; + + if (isProduction && !isCI && !isBuildPhase) { + // 1. Production JWT Auth Secret must not be a known development placeholder + if (FORBIDDEN_PROD_AUTH_SECRETS.includes(data.AUTH_SECRET)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["AUTH_SECRET"], + message: "Production requires a strong, random AUTH_SECRET. Known dev placeholders are rejected.", + }); + } + + // 2. Production Internal Service Secret must not be a known placeholder + if (FORBIDDEN_PROD_SERVICE_SECRETS.includes(data.INTERNAL_SERVICE_SECRET)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["INTERNAL_SERVICE_SECRET"], + message: + "Production requires a strong, random INTERNAL_SERVICE_SECRET. Known dev placeholders are rejected.", + }); + } + + // 3. GitHub OAuth credentials must be real credentials, not development mocks + if (data.GITHUB_CLIENT_ID.startsWith("mock_") || data.GITHUB_CLIENT_ID.length < 5) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["GITHUB_CLIENT_ID"], + message: "Production requires a valid GITHUB_CLIENT_ID. Mock placeholders are rejected.", + }); + } + + if (data.GITHUB_CLIENT_SECRET.startsWith("mock_") || data.GITHUB_CLIENT_SECRET.length < 10) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["GITHUB_CLIENT_SECRET"], + message: "Production requires a valid GITHUB_CLIENT_SECRET. Mock placeholders are rejected.", + }); + } + + // 4. Webhook secret must not be a placeholder + if (FORBIDDEN_PROD_WEBHOOK_SECRETS.includes(data.GITHUB_WEBHOOK_SECRET)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["GITHUB_WEBHOOK_SECRET"], + message: "Production requires a secure GITHUB_WEBHOOK_SECRET. Default dev secret is rejected.", + }); + } + + // 5. Database URL must be mandatory PostgreSQL in production + if ( + !data.DATABASE_URL || + (!data.DATABASE_URL.startsWith("postgres://") && + !data.DATABASE_URL.startsWith("postgresql://") && + !data.DATABASE_URL.startsWith("postgres:")) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["DATABASE_URL"], + message: "Production requires a valid PostgreSQL DATABASE_URL. Local PGlite is disabled in production.", + }); + } + } + }); +} + +export type Env = z.infer>; + +/** + * Validates the runtime environment against strict production invariants. + */ export function validateEnv(customEnv?: Record): Env { - const result = envSchema.safeParse(customEnv || process.env); + const schema = createEnvSchema(Boolean(customEnv)); + const result = schema.safeParse(customEnv || process.env); if (!result.success) { const errors = result.error.errors.map((e) => `${e.path.join(".")}: ${e.message}`).join(", "); throw new Error(`Environment validation failed: ${errors}`); @@ -25,4 +132,17 @@ export function validateEnv(customEnv?: Record): Env return result.data; } +/** + * Constant-time string comparison to prevent timing attacks against secret-based authentication. + */ +export function safeCompare(a: string | null | undefined, b: string | null | undefined): boolean { + if (!a || !b) return false; + const bufA = Buffer.from(a); + const bufB = Buffer.from(b); + if (bufA.length !== bufB.length) { + return false; + } + return crypto.timingSafeEqual(bufA, bufB); +} + export const env = validateEnv(); diff --git a/src/lib/github/issues.ts b/src/lib/github/issues.ts index 3e1e160..e976e68 100644 --- a/src/lib/github/issues.ts +++ b/src/lib/github/issues.ts @@ -1,5 +1,6 @@ import { getGitHubClient, checkRateLimit } from "./client"; import { getDb, schema } from "@/lib/db"; +import { env } from "@/lib/env"; import { eq } from "drizzle-orm"; export interface ParsedIssueMetadata { @@ -89,6 +90,13 @@ export async function syncRepositoryIssues( repo: string, userToken?: string ): Promise<{ syncedCount: number; rateLimitRemaining?: number }> { + // Trust boundary enforcement: reject external owners + if (owner.toLowerCase() !== env.TECH_NEXUS_ORG_NAME.toLowerCase()) { + throw new Error( + `Security violation: Repository owner "${owner}" is outside official ${env.TECH_NEXUS_ORG_NAME} trust boundary.` + ); + } + const octokit = getGitHubClient(userToken); const rateLimit = await checkRateLimit(octokit); @@ -104,6 +112,18 @@ export async function syncRepositoryIssues( repo, }); + // Verify canonical organization ID and owner login against live GitHub API + const repoOwnerId = (repoData.owner as any)?.id; + const repoOwnerLogin = (repoData.owner as any)?.login; + if ( + repoOwnerId !== env.TECH_NEXUS_ORG_ID || + repoOwnerLogin?.toLowerCase() !== env.TECH_NEXUS_ORG_NAME.toLowerCase() + ) { + throw new Error( + `Security violation: Repository ${repoData.full_name} is not owned by verified ${env.TECH_NEXUS_ORG_NAME} organization.` + ); + } + const db = await getDb(); const repoFullName = repoData.full_name; @@ -121,6 +141,8 @@ export async function syncRepositoryIssues( .update(schema.projects) .set({ name: repoData.name, + githubRepoId: repoData.id, + githubOwnerId: repoOwnerId, description: repoData.description || "Official TechNexusOrg repository", primaryLanguage: repoData.language || "TypeScript", starsCount: repoData.stargazers_count, @@ -136,10 +158,14 @@ export async function syncRepositoryIssues( name: repoData.name, slug: repoData.name.toLowerCase(), githubRepo: repoFullName, + githubRepoId: repoData.id, + githubOwnerId: repoOwnerId, description: repoData.description || "Official TechNexusOrg repository", primaryLanguage: repoData.language || "TypeScript", languages: repoData.language ? [repoData.language] : [], - isOfficial: true, + isOfficial: false, // Default to unapproved until maintainer review + contributionEnabled: false, + firstPrEnabled: false, starsCount: repoData.stargazers_count, forksCount: repoData.forks_count, openIssuesCount: repoData.open_issues_count, diff --git a/src/lib/github/webhooks.test.ts b/src/lib/github/webhooks.test.ts index c366686..239ee9b 100644 --- a/src/lib/github/webhooks.test.ts +++ b/src/lib/github/webhooks.test.ts @@ -560,5 +560,53 @@ describe("GitHub Webhooks & PR Lifecycle Engine", () => { expect(result.reason).toMatch(/unapproved or contributions are disabled/i); expect(result.contributionId).toBeUndefined(); }); + + it("rejects PR events where repository owner ID does not match TechNexusOrg ID", async () => { + const db = await getDb(); + const spoofedOrgEvent: WebhookPullRequestEvent = { + action: "opened", + pull_request: { + id: 99111, + number: 1, + title: "Spoofed Org PR", + body: "Test", + html_url: "https://github.com/TechNexusOrg/spoofed-repo/pull/1", + state: "open", + draft: false, + merged: false, + merged_at: null, + user: { + id: 998877, + login: "contributor_cathy", + avatar_url: "https://avatars.githubusercontent.com/u/998877", + }, + base: { + repo: { + id: 88111, + name: "spoofed-repo", + full_name: "TechNexusOrg/spoofed-repo", + html_url: "https://github.com/TechNexusOrg/spoofed-repo", + }, + }, + }, + repository: { + id: 88111, + name: "spoofed-repo", + full_name: "TechNexusOrg/spoofed-repo", + owner: { + login: "TechNexusOrg", + id: 111222333, // Not 333899669 + } as any, + }, + sender: { + id: 998877, + login: "contributor_cathy", + }, + }; + + const result = await handlePullRequestWebhook(spoofedOrgEvent, db); + expect(result.handled).toBe(false); + expect(result.reason).toMatch(/outside official organization trust boundary/i); + }); }); }); diff --git a/src/lib/github/webhooks.ts b/src/lib/github/webhooks.ts index 88da407..7ab47c8 100644 --- a/src/lib/github/webhooks.ts +++ b/src/lib/github/webhooks.ts @@ -107,8 +107,9 @@ export interface WebhookPullRequestEvent { id: number; name: string; full_name: string; - owner: { + owner?: { login: string; + id?: number; }; }; sender: { @@ -346,8 +347,14 @@ export async function handlePullRequestWebhook( let isContributionEligible = false; if (matchingProjects.length === 0) { - const isTechNexusOrg = repoFullName.toLowerCase().startsWith("technexusorg/"); - if (!isTechNexusOrg) { + const orgName = env.TECH_NEXUS_ORG_NAME.toLowerCase(); + const repoOwner = (payload.repository.owner?.login || repoFullName.split("/")[0]).toLowerCase(); + const repoOwnerId = payload.repository.owner?.id; + + const isTechNexusOrg = repoOwner === orgName; + const isVerifiedOrgId = !repoOwnerId || repoOwnerId === env.TECH_NEXUS_ORG_ID; + + if (!isTechNexusOrg || !isVerifiedOrgId) { return { handled: false, reason: "Repository outside official organization trust boundary.", @@ -362,6 +369,8 @@ export async function handlePullRequestWebhook( name: payload.repository.name, slug: payload.repository.name.toLowerCase(), githubRepo: repoFullName, + githubRepoId: payload.repository.id, + githubOwnerId: repoOwnerId || env.TECH_NEXUS_ORG_ID, description: `Discovered repository: ${repoFullName}`, primaryLanguage: "TypeScript", languages: [], @@ -374,9 +383,17 @@ export async function handlePullRequestWebhook( } else { const project = matchingProjects[0]; projectId = project.id; - // Strictly require official, approved, and contribution enabled + + // Trust boundary verification against stored or payload owner ID + const isOwnerTrusted = + !project.githubOwnerId || project.githubOwnerId === env.TECH_NEXUS_ORG_ID; + + // Strictly require official, approved, contribution enabled, and trusted owner isContributionEligible = - project.isOfficial && project.contributionEnabled && !!project.approvedAt; + project.isOfficial && + project.contributionEnabled && + !!project.approvedAt && + isOwnerTrusted; } if (!isContributionEligible) { diff --git a/src/lib/projects/approval.test.ts b/src/lib/projects/approval.test.ts new file mode 100644 index 0000000..4c198b6 --- /dev/null +++ b/src/lib/projects/approval.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, beforeAll, beforeEach } from "vitest"; +import { getDb, schema } from "@/lib/db"; +import { runMigrations } from "@/lib/db/migrate"; +import { approveProject, rejectProject } from "./approval"; +import { eq } from "drizzle-orm"; + +describe("Project Approval Trust Boundary", () => { + let db: any; + const adminId = "user_admin_test_1"; + + beforeAll(async () => { + (process.env as Record).NODE_ENV = "test"; + await runMigrations(); + }); + + beforeEach(async () => { + db = await getDb(); + + // Clean tables + await db.delete(schema.auditLogs); + await db.delete(schema.projects); + await db.delete(schema.users); + + // Insert admin user + await db.insert(schema.users).values({ + id: adminId, + githubId: 999999, + githubUsername: "testadmin", + role: "admin", + level: "project_lead", + }); + }); + + it("successfully approves a valid TechNexusOrg repository with matching owner ID", async () => { + const projectId = "proj_official_platform"; + await db.insert(schema.projects).values({ + id: projectId, + name: "platform", + slug: "platform", + githubRepo: "TechNexusOrg/platform", + githubRepoId: 1387995449, + githubOwnerId: 333899669, + description: "Official platform repository", + primaryLanguage: "TypeScript", + isOfficial: false, + contributionEnabled: false, + firstPrEnabled: false, + }); + + const approved = await approveProject(projectId, adminId, { + contributionEnabled: true, + firstPrEnabled: true, + }, db); + + expect(approved.isOfficial).toBe(true); + expect(approved.contributionEnabled).toBe(true); + expect(approved.approvedAt).toBeDefined(); + + // Verify audit log + const logs = await db.select().from(schema.auditLogs).where(eq(schema.auditLogs.action, "project.approve")); + expect(logs).toHaveLength(1); + expect(logs[0].targetId).toBe(projectId); + }); + + it("rejects approval of external repository outside TechNexusOrg trust boundary", async () => { + const projectId = "proj_external_malicious"; + await db.insert(schema.projects).values({ + id: projectId, + name: "hacked-repo", + slug: "hacked-repo", + githubRepo: "external-org/hacked-repo", + githubRepoId: 9999, + githubOwnerId: 8888, + description: "External unauthorized repo", + primaryLanguage: "JavaScript", + isOfficial: false, + }); + + await expect( + approveProject(projectId, adminId, {}, db) + ).rejects.toThrow(/does not belong to the official TechNexusOrg organization/); + + const [unapproved] = await db.select().from(schema.projects).where(eq(schema.projects.id, projectId)); + expect(unapproved.isOfficial).toBe(false); + }); + + it("rejects approval if repository owner ID does not match TechNexusOrg ID", async () => { + const projectId = "proj_spoofed_org"; + await db.insert(schema.projects).values({ + id: projectId, + name: "spoofed", + slug: "spoofed", + githubRepo: "TechNexusOrg/spoofed", + githubRepoId: 12345, + githubOwnerId: 999999999, // Mismatched ID + description: "Spoofed repo name", + primaryLanguage: "Python", + isOfficial: false, + }); + + await expect( + approveProject(projectId, adminId, {}, db) + ).rejects.toThrow(/does not match verified organization ID/); + }); + + it("rejectProject sets isOfficial to false and logs audit trail", async () => { + const projectId = "proj_to_reject"; + await db.insert(schema.projects).values({ + id: projectId, + name: "old-repo", + slug: "old-repo", + githubRepo: "TechNexusOrg/old-repo", + githubRepoId: 5555, + githubOwnerId: 333899669, + description: "Old project", + primaryLanguage: "TypeScript", + isOfficial: true, + contributionEnabled: true, + }); + + const rejected = await rejectProject(projectId, adminId, db); + expect(rejected.isOfficial).toBe(false); + expect(rejected.contributionEnabled).toBe(false); + expect(rejected.approvedAt).toBeNull(); + + const logs = await db.select().from(schema.auditLogs).where(eq(schema.auditLogs.action, "project.reject")); + expect(logs).toHaveLength(1); + }); +}); diff --git a/src/lib/projects/approval.ts b/src/lib/projects/approval.ts index 5e98cce..2763694 100644 --- a/src/lib/projects/approval.ts +++ b/src/lib/projects/approval.ts @@ -1,4 +1,5 @@ import { getDb, schema } from "@/lib/db"; +import { env } from "@/lib/env"; import { eq } from "drizzle-orm"; export async function approveProject( @@ -23,6 +24,23 @@ export async function approveProject( throw new Error("Project not found."); } + // Enforce repository trust boundary + const isOfficialOrg = project.githubRepo + .toLowerCase() + .startsWith(`${env.TECH_NEXUS_ORG_NAME.toLowerCase()}/`); + + if (!isOfficialOrg) { + throw new Error( + `Security violation: Repository "${project.githubRepo}" does not belong to the official ${env.TECH_NEXUS_ORG_NAME} organization.` + ); + } + + if (project.githubOwnerId && project.githubOwnerId !== env.TECH_NEXUS_ORG_ID) { + throw new Error( + `Security violation: Repository owner ID ${project.githubOwnerId} does not match verified organization ID ${env.TECH_NEXUS_ORG_ID}.` + ); + } + const [updated] = await db .update(schema.projects) .set({ @@ -45,6 +63,8 @@ export async function approveProject( targetId: projectId, metadata: { githubRepo: project.githubRepo, + githubRepoId: project.githubRepoId, + githubOwnerId: project.githubOwnerId, contributionEnabled: updated.contributionEnabled, firstPrEnabled: updated.firstPrEnabled, }, @@ -93,6 +113,7 @@ export async function rejectProject( targetId: projectId, metadata: { githubRepo: project.githubRepo, + githubRepoId: project.githubRepoId, }, createdAt: now, });