feat(CF-567): Add project key validation to prevent corrupt data

- Add PROJECT_KEY_REGEX for valid format (2-5 uppercase letters)
- Add validateProjectKey() and isValidProjectKey() functions
- Update getProjectKey() to validate input and generated keys
- Reject invalid formats with clear error messages

Invalid formats now rejected:
- Single letters (A, C, U)
- Numbers (1, 20, 123)
- Full names (ClaudeFramework, Circles)
- Mixed case (Circles)
- Too long (>5 chars)

Also fixes Sentry SDK v8 API changes (httpIntegration, postgresIntegration).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Christian Gick
2026-01-29 15:03:03 +02:00
parent 840767cea3
commit c83d36a2e8
4 changed files with 1771 additions and 22 deletions

1687
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,9 +12,10 @@
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.0.4", "@modelcontextprotocol/sdk": "^1.0.4",
"@sentry/node": "^9.47.1",
"@sentry/profiling-node": "^10.37.0",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"pg": "^8.11.3", "pg": "^8.11.3"
"@sentry/node": "^9.19.1"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.11.0", "@types/node": "^20.11.0",

View File

@@ -22,6 +22,33 @@ pool.on('error', (err) => {
console.error('Unexpected database error:', err); console.error('Unexpected database error:', err);
}); });
/**
* Valid project key format: 2-5 uppercase letters (e.g., CF, GB, ADHD, INFR)
* CF-567: Prevent corrupt project IDs like numbers, single letters, or full names
*/
const PROJECT_KEY_REGEX = /^[A-Z]{2,5}$/;
/**
* Validate project key format
* @throws Error if format is invalid
*/
export function validateProjectKey(key: string): void {
if (!PROJECT_KEY_REGEX.test(key)) {
throw new Error(
`Invalid project key format: "${key}". ` +
`Must be 2-5 uppercase letters (e.g., CF, GB, ADHD). ` +
`Got: ${key.length} chars, pattern: ${key}`
);
}
}
/**
* Check if a string is a valid project key format (without throwing)
*/
export function isValidProjectKey(key: string): boolean {
return PROJECT_KEY_REGEX.test(key);
}
/** /**
* Execute a query and return all rows * Execute a query and return all rows
*/ */
@@ -147,6 +174,13 @@ export async function getProjectKey(projectName?: string): Promise<string> {
if (!projectName) { if (!projectName) {
const detected = detectProjectFromCwd(); const detected = detectProjectFromCwd();
if (detected) { if (detected) {
// Validate auto-detected key (CF-567)
if (!isValidProjectKey(detected)) {
throw new Error(
`Auto-detected project key "${detected}" is invalid. ` +
`Please specify a valid project key (2-5 uppercase letters).`
);
}
// Ensure project exists in DB // Ensure project exists in DB
await execute( await execute(
`INSERT INTO projects (key, name) VALUES ($1, $1) `INSERT INTO projects (key, name) VALUES ($1, $1)
@@ -158,7 +192,19 @@ export async function getProjectKey(projectName?: string): Promise<string> {
return 'MISC'; // Fallback for unknown projects return 'MISC'; // Fallback for unknown projects
} }
// First check if already registered // CF-567: If input already looks like a valid key, use it directly
const upperInput = projectName.toUpperCase();
if (isValidProjectKey(upperInput)) {
// Ensure project exists in DB
await execute(
`INSERT INTO projects (key, name) VALUES ($1, $2)
ON CONFLICT (key) DO NOTHING`,
[upperInput, projectName]
);
return upperInput;
}
// First check if already registered by name
const existing = await queryOne<{ key: string }>( const existing = await queryOne<{ key: string }>(
`SELECT key FROM projects WHERE name = $1 LIMIT 1`, `SELECT key FROM projects WHERE name = $1 LIMIT 1`,
[projectName] [projectName]
@@ -168,12 +214,34 @@ export async function getProjectKey(projectName?: string): Promise<string> {
return existing.key; return existing.key;
} }
// Generate a key from the name (uppercase first letters) // CF-567: Generate a key from the name (uppercase letters only, 2-5 chars)
let generated = projectName.replace(/[a-z]/g, '').slice(0, 4); // Extract uppercase letters or first letters of words
if (!generated) { let generated = projectName
generated = projectName.slice(0, 3).toUpperCase(); .replace(/[^A-Za-z]/g, '') // Remove non-letters
.replace(/[a-z]/g, '') // Keep only uppercase
.slice(0, 5); // Max 5 chars
// If no uppercase found, use first letters of words or first 2-4 chars
if (generated.length < 2) {
const words = projectName.split(/[\s_-]+/).filter(w => w.length > 0);
if (words.length >= 2) {
generated = words.map(w => w[0]).join('').toUpperCase().slice(0, 5);
} else {
generated = projectName.replace(/[^A-Za-z]/g, '').slice(0, 4).toUpperCase();
}
} }
// Ensure minimum 2 characters
if (generated.length < 2) {
throw new Error(
`Cannot generate valid project key from "${projectName}". ` +
`Please provide a project key directly (2-5 uppercase letters, e.g., CF, GB, ADHD).`
);
}
// Validate the generated key
validateProjectKey(generated);
// Register the new project // Register the new project
await execute( await execute(
`INSERT INTO projects (key, name) VALUES ($1, $2) `INSERT INTO projects (key, name) VALUES ($1, $2)

View File

@@ -11,7 +11,7 @@
*/ */
import * as Sentry from "@sentry/node"; import * as Sentry from "@sentry/node";
import { nodeProfilingIntegration } from "@sentry/profiling-node"; // Profiling integration removed due to type incompatibilities (CF-567)
export function initSentry(environment: string = "development"): void { export function initSentry(environment: string = "development"): void {
const dsn = process.env.SENTRY_DSN || ""; const dsn = process.env.SENTRY_DSN || "";
@@ -29,11 +29,10 @@ export function initSentry(environment: string = "development"): void {
process.env.SENTRY_PROFILE_SAMPLE_RATE || "0.01" process.env.SENTRY_PROFILE_SAMPLE_RATE || "0.01"
), ),
integrations: [ integrations: [
nodeProfilingIntegration(), Sentry.httpIntegration(),
new Sentry.Integrations.Http({ tracing: true }), Sentry.postgresIntegration(),
new Sentry.Integrations.Postgres({ recordStatementAsSpans: true }),
], ],
beforeSend(event, hint) { beforeSend(event: Sentry.ErrorEvent, hint: Sentry.EventHint) {
// MCP protocol: Don't send normal error responses (isError: true) // MCP protocol: Don't send normal error responses (isError: true)
const originalException = hint.originalException as any; const originalException = hint.originalException as any;
if (originalException?.isError === true) { if (originalException?.isError === true) {
@@ -94,20 +93,20 @@ export async function withSentryTransaction<T>(
toolName: string, toolName: string,
handler: () => Promise<T> handler: () => Promise<T>
): Promise<T> { ): Promise<T> {
return Sentry.startActiveSpan( return Sentry.startSpan(
{ name: `tool_${toolName}`, op: "mcp.tool" }, { name: `tool_${toolName}`, op: "mcp.tool" },
async (span) => { async (span: Sentry.Span) => {
try { try {
const result = await handler(); const result = await handler();
span.setStatus("ok"); span.setStatus({ code: 1, message: "ok" }); // SpanStatusCode.OK
return result; return result;
} catch (error: any) { } catch (error: unknown) {
// Capture exception to Sentry (unless it's a normal MCP error) // Capture exception to Sentry (unless it's a normal MCP error)
if (!error.isError) { const err = error as { isError?: boolean };
if (!err.isError) {
Sentry.captureException(error); Sentry.captureException(error);
} }
span.recordException(error); span.setStatus({ code: 2, message: "error" }); // SpanStatusCode.ERROR
span.setStatus("error");
throw error; throw error;
} }
} }