Skip to content

Commit fc6d8e6

Browse files
edrplsclaude
andcommitted
fix: validate storage collection names by manifest rules, not SQL rules
The previous commit removed collection validation outright to unblock kebab-case names, which dropped the injection-shape rejection an existing unit test guards. sql.ref does neutralize such names — it doubles embedded quotes — but the defensive check is still worth keeping. Validate against the manifest key charset instead: letters, digits, underscores and hyphens, either case. form-submissions indexes, and quote/semicolon-bearing names are still rejected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017m3BaSx6e1DQcU77hBLTxU
1 parent 83f894b commit fc6d8e6

2 files changed

Lines changed: 49 additions & 4 deletions

File tree

packages/core/src/database/validate.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@ const GENERIC_IDENTIFIER_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]*$/;
2626
*/
2727
const PLUGIN_IDENTIFIER_PATTERN = /^[a-z][a-z0-9_-]*$/;
2828

29+
/**
30+
* Pattern for plugin storage collection names.
31+
* Manifests declare these as free-form keys, so both cases and hyphens are
32+
* allowed; the charset still excludes quotes, whitespace, and punctuation.
33+
*/
34+
const STORAGE_COLLECTION_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
35+
2936
/**
3037
* Maximum length for SQL identifiers.
3138
* SQLite has no formal limit, but we cap at 128 for sanity.
@@ -136,3 +143,35 @@ export function validatePluginIdentifier(value: string, label = "plugin identifi
136143
throw new IdentifierError(`${label} must match /^[a-z][a-z0-9_-]*$/ (got "${value}")`, value);
137144
}
138145
}
146+
147+
/**
148+
* Validate a plugin storage collection name.
149+
*
150+
* Collections are declared as free-form manifest keys and stored as opaque
151+
* text, so this is deliberately more permissive than `validateIdentifier`:
152+
* `form-submissions` and `formSubmissions` are legitimate. The charset still
153+
* rejects quotes and punctuation, keeping generated index names inert.
154+
*
155+
* @param value - The string to validate
156+
* @param label - Human-readable label for error messages
157+
* @throws {IdentifierError} If the value is not valid
158+
*/
159+
export function validateStorageCollectionName(value: string, label = "collection name"): void {
160+
if (!value || typeof value !== "string") {
161+
throw new IdentifierError(`${label} must be a non-empty string`, String(value));
162+
}
163+
164+
if (value.length > MAX_IDENTIFIER_LENGTH) {
165+
throw new IdentifierError(
166+
`${label} must be ${MAX_IDENTIFIER_LENGTH} characters or less, got ${value.length}`,
167+
value,
168+
);
169+
}
170+
171+
if (!STORAGE_COLLECTION_PATTERN.test(value)) {
172+
throw new IdentifierError(
173+
`${label} must match /^[a-zA-Z][a-zA-Z0-9_-]*$/ (got "${value}")`,
174+
value,
175+
);
176+
}
177+
}

packages/core/src/plugins/storage-indexes.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@ import { sql } from "kysely";
1111

1212
import { jsonExtractExpr, isPostgres } from "../database/dialect-helpers.js";
1313
import type { Database } from "../database/types.js";
14-
import { validateJsonFieldName, validatePluginIdentifier } from "../database/validate.js";
14+
import {
15+
validateJsonFieldName,
16+
validatePluginIdentifier,
17+
validateStorageCollectionName,
18+
} from "../database/validate.js";
1519

1620
/**
1721
* Generate a deterministic index name.
@@ -32,9 +36,10 @@ export function generateIndexName(
3236
/**
3337
* Generate a Kysely sql expression for creating an expression index.
3438
*
35-
* The plugin ID and field names are validated before interpolation; the
36-
* collection is an opaque text value (the manifest schema allows arbitrary
37-
* keys) and only appears inside the index name, which `sql.ref` quotes.
39+
* Validates all inputs before interpolation. The collection uses the
40+
* permissive manifest-key rules rather than SQL-identifier rules — it is
41+
* stored as opaque text and only reaches SQL inside the generated index
42+
* name — so kebab-case collections index like any other.
3843
*/
3944
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- accepts any Kysely instance
4045
export function generateCreateIndexSql(
@@ -45,6 +50,7 @@ export function generateCreateIndexSql(
4550
options?: { unique?: boolean },
4651
): RawBuilder<unknown> {
4752
validatePluginIdentifier(pluginId, "plugin ID");
53+
validateStorageCollectionName(collection, "collection name");
4854
for (const field of fields) {
4955
validateJsonFieldName(field, "index field name");
5056
}

0 commit comments

Comments
 (0)