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": {
"@modelcontextprotocol/sdk": "^1.0.4",
"@sentry/node": "^9.47.1",
"@sentry/profiling-node": "^10.37.0",
"dotenv": "^17.2.3",
"pg": "^8.11.3",
"@sentry/node": "^9.19.1"
"pg": "^8.11.3"
},
"devDependencies": {
"@types/node": "^20.11.0",

View File

@@ -22,6 +22,33 @@ pool.on('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
*/
@@ -147,6 +174,13 @@ export async function getProjectKey(projectName?: string): Promise<string> {
if (!projectName) {
const detected = detectProjectFromCwd();
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
await execute(
`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
}
// 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 }>(
`SELECT key FROM projects WHERE name = $1 LIMIT 1`,
[projectName]
@@ -168,11 +214,33 @@ export async function getProjectKey(projectName?: string): Promise<string> {
return existing.key;
}
// Generate a key from the name (uppercase first letters)
let generated = projectName.replace(/[a-z]/g, '').slice(0, 4);
if (!generated) {
generated = projectName.slice(0, 3).toUpperCase();
// CF-567: Generate a key from the name (uppercase letters only, 2-5 chars)
// Extract uppercase letters or first letters of words
let generated = projectName
.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
await execute(

View File

@@ -11,7 +11,7 @@
*/
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 {
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"
),
integrations: [
nodeProfilingIntegration(),
new Sentry.Integrations.Http({ tracing: true }),
new Sentry.Integrations.Postgres({ recordStatementAsSpans: true }),
Sentry.httpIntegration(),
Sentry.postgresIntegration(),
],
beforeSend(event, hint) {
beforeSend(event: Sentry.ErrorEvent, hint: Sentry.EventHint) {
// MCP protocol: Don't send normal error responses (isError: true)
const originalException = hint.originalException as any;
if (originalException?.isError === true) {
@@ -94,20 +93,20 @@ export async function withSentryTransaction<T>(
toolName: string,
handler: () => Promise<T>
): Promise<T> {
return Sentry.startActiveSpan(
return Sentry.startSpan(
{ name: `tool_${toolName}`, op: "mcp.tool" },
async (span) => {
async (span: Sentry.Span) => {
try {
const result = await handler();
span.setStatus("ok");
span.setStatus({ code: 1, message: "ok" }); // SpanStatusCode.OK
return result;
} catch (error: any) {
} catch (error: unknown) {
// 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);
}
span.recordException(error);
span.setStatus("error");
span.setStatus({ code: 2, message: "error" }); // SpanStatusCode.ERROR
throw error;
}
}