This is an automated email from the ASF dual-hosted git repository.
mtaha pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/age.git
The following commit(s) were added to refs/heads/master by this push:
new 77a16ece Fix security vulnerabilities in Node.js driver (#2329)
77a16ece is described below
commit 77a16ece0bbd8f137ad40b4e203c9622c249352d
Author: John Gemignani <[email protected]>
AuthorDate: Fri Feb 13 09:31:35 2026 -0800
Fix security vulnerabilities in Node.js driver (#2329)
Fix security vulnerabilities in Node.js driver and harden input
validation and query construction.
Note: This PR was created with AI tools and a human.
- Add input validation for graph names, label names, and column names
to prevent SQL injection via string interpolation. Graph name rules
are based on Apache AGE's naming conventions and Neo4j/openCypher
compatibility (hyphens and dots permitted, min 3 chars, max 63 chars).
Label names follow AGE's stricter rules (no hyphens or dots).
- Add design documentation noting intentional ASCII-only restriction
in driver-side regex validation as a security hardening measure
(homoglyph/encoding attack surface reduction). AGE's server-side
validation (name_validation.h) uses full Unicode ID_Start/ID_Continue
and remains the authoritative check for Unicode names.
- Add safe query helpers: queryCypher(), createGraph(), dropGraph()
with graph name validation and dollar-quoting for Cypher strings
- Add runtime typeof check on dropGraph cascade parameter to prevent
injection from plain JavaScript consumers (TypeScript types are
erased at runtime)
- Use BigInt for integer values exceeding Number.MAX_SAFE_INTEGER to
prevent silent precision loss with 64-bit AGE graph IDs
- Make CREATE EXTENSION opt-in via SetAGETypesOptions.createExtension
instead of running DDL automatically without user consent
- Wrap LOAD/search_path setup in try/catch with actionable error
message mentioning CREATE EXTENSION and { createExtension: true }
- Improve agtype-not-found error message with installation guidance
- Tighten pg dependency from >=6.0.0 to >=8.0.0
- Add comprehensive test suites covering validation, SQL injection
prevention, cascade type safety, hyphenated graph name integration,
BigInt parsing, and setAGETypes error handling
- Add design note in tests documenting why createExtension: false is
the correct default (CI image has AGE pre-installed, auto-creating
extensions requires SUPERUSER and conflates concerns)
modified: drivers/nodejs/package.json
modified: drivers/nodejs/src/antlr4/CustomAgTypeListener.ts
modified: drivers/nodejs/src/index.ts
modified: drivers/nodejs/test/Agtype.test.ts
modified: drivers/nodejs/test/index.test.ts
---
drivers/nodejs/package.json | 2 +-
drivers/nodejs/src/antlr4/CustomAgTypeListener.ts | 6 +-
drivers/nodejs/src/index.ts | 301 +++++++++++++++++++++-
drivers/nodejs/test/Agtype.test.ts | 27 ++
drivers/nodejs/test/index.test.ts | 285 +++++++++++++++-----
5 files changed, 539 insertions(+), 82 deletions(-)
diff --git a/drivers/nodejs/package.json b/drivers/nodejs/package.json
index 6be11c78..15c2371f 100644
--- a/drivers/nodejs/package.json
+++ b/drivers/nodejs/package.json
@@ -30,7 +30,7 @@
"author": "Alex Kwak <[email protected]>",
"dependencies": {
"antlr4ts": "^0.5.0-alpha.4",
- "pg": ">=6.0.0"
+ "pg": ">=8.0.0"
},
"devDependencies": {
"@types/jest": "^29.5.14",
diff --git a/drivers/nodejs/src/antlr4/CustomAgTypeListener.ts
b/drivers/nodejs/src/antlr4/CustomAgTypeListener.ts
index fceabee2..6089ee99 100644
--- a/drivers/nodejs/src/antlr4/CustomAgTypeListener.ts
+++ b/drivers/nodejs/src/antlr4/CustomAgTypeListener.ts
@@ -92,7 +92,11 @@ class CustomAgTypeListener implements AgtypeListener,
ParseTreeListener {
}
exitIntegerValue (ctx: IntegerValueContext): void {
- const value = parseInt(ctx.text)
+ // Use BigInt for values that exceed Number.MAX_SAFE_INTEGER to
+ // prevent silent precision loss with large AGE graph IDs (64-bit).
+ const text = ctx.text
+ const num = Number(text)
+ const value = Number.isSafeInteger(num) ? num : BigInt(text)
if (!this.pushIfArray(value)) {
this.lastValue = value
}
diff --git a/drivers/nodejs/src/index.ts b/drivers/nodejs/src/index.ts
index 416cc3da..dad004a8 100644
--- a/drivers/nodejs/src/index.ts
+++ b/drivers/nodejs/src/index.ts
@@ -17,7 +17,7 @@
* under the License.
*/
-import { Client } from 'pg'
+import { Client, QueryResult, QueryResultRow } from 'pg'
import pgTypes from 'pg-types'
import { CharStreams, CommonTokenStream } from 'antlr4ts'
import { AgtypeLexer } from './antlr4/AgtypeLexer'
@@ -25,6 +25,122 @@ import { AgtypeParser } from './antlr4/AgtypeParser'
import CustomAgTypeListener from './antlr4/CustomAgTypeListener'
import { ParseTreeWalker } from 'antlr4ts/tree'
+/**
+ * Valid graph name pattern based on Apache AGE's naming conventions
+ * (Neo4j/openCypher compatible). Allows ASCII letters, digits,
+ * underscores, plus dots and hyphens in middle positions.
+ *
+ * DESIGN NOTE: AGE's server-side validation (name_validation.h) uses
+ * full Unicode ID_Start/ID_Continue character classes, accepting names
+ * like 'mydätabase' or 'mydঅtabase'. This driver intentionally restricts
+ * to ASCII-only as a security hardening measure — it reduces the attack
+ * surface for homoglyph and encoding-based injection vectors. Server-side
+ * validation remains the authoritative check for Unicode names.
+ *
+ * Start: ASCII letter or underscore
+ * Middle: ASCII letter, digit, underscore, dot, or hyphen
+ * End: ASCII letter, digit, or underscore
+ */
+const VALID_GRAPH_NAME = /^[A-Za-z_][A-Za-z0-9_.\-]*[A-Za-z0-9_]$/
+
+/**
+ * Valid label name pattern based on Apache AGE's naming rules.
+ * Labels follow stricter identifier rules than graph names — dots and
+ * hyphens are NOT permitted in label names.
+ *
+ * Note: ASCII-only restriction (see VALID_GRAPH_NAME design note above).
+ */
+const VALID_LABEL_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
+
+/**
+ * Valid SQL identifier pattern for column names and types in the
+ * query result AS clause. Standard PostgreSQL unquoted identifier rules.
+ *
+ * Note: ASCII-only restriction (see VALID_GRAPH_NAME design note above).
+ */
+const VALID_SQL_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/
+
+/**
+ * Validates that a graph name conforms to Apache AGE's naming conventions
+ * and is safe for use in SQL queries.
+ *
+ * Graph names must:
+ * - Be at least 3 characters and at most 63 characters
+ * - Start with an ASCII letter or underscore
+ * - Contain only ASCII letters, digits, underscores, dots, and hyphens
+ * - End with an ASCII letter, digit, or underscore
+ *
+ * Note: This is intentionally stricter than AGE's server-side validation
+ * (which accepts Unicode letters). See VALID_GRAPH_NAME design note.
+ *
+ * @param graphName - The graph name to validate
+ * @throws Error if the graph name is invalid
+ */
+function validateGraphName (graphName: string): void {
+ if (!graphName || typeof graphName !== 'string') {
+ throw new Error('Graph name must be a non-empty string')
+ }
+ if (graphName.length < 3) {
+ throw new Error(
+ `Invalid graph name: '${graphName}'. Graph names must be at least 3
characters.`
+ )
+ }
+ if (graphName.length > 63) {
+ throw new Error('Graph name must not exceed 63 characters (PostgreSQL name
limit)')
+ }
+ if (!VALID_GRAPH_NAME.test(graphName)) {
+ throw new Error(
+ `Invalid graph name: '${graphName}'. Graph names must start with a
letter ` +
+ 'or underscore, may contain letters, digits, underscores, dots, and
hyphens, ' +
+ 'and must end with a letter, digit, or underscore.'
+ )
+ }
+}
+
+/**
+ * Validates that a label name conforms to Apache AGE's naming rules.
+ * Label names are stricter than graph names — only letters, digits,
+ * and underscores are permitted (no dots or hyphens).
+ *
+ * @param labelName - The label name to validate
+ * @throws Error if the label name is invalid
+ */
+function validateLabelName (labelName: string): void {
+ if (!labelName || typeof labelName !== 'string') {
+ throw new Error('Label name must be a non-empty string')
+ }
+ if (labelName.length > 63) {
+ throw new Error('Label name must not exceed 63 characters (PostgreSQL name
limit)')
+ }
+ if (!VALID_LABEL_NAME.test(labelName)) {
+ throw new Error(
+ `Invalid label name: '${labelName}'. Label names must start with a
letter ` +
+ 'or underscore and contain only letters, digits, and underscores.'
+ )
+ }
+}
+
+/**
+ * Escapes a PostgreSQL dollar-quoted string literal by ensuring the
+ * cypher query does not contain the dollar-quote delimiter. If the
+ * default $$ delimiter conflicts, a unique tagged delimiter is used.
+ *
+ * @param cypher - The Cypher query string
+ * @returns An object with the delimiter and safely quoted string
+ */
+function cypherDollarQuote (cypher: string): { delimiter: string; quoted:
string } {
+ if (!cypher.includes('$$')) {
+ return { delimiter: '$$', quoted: `$$${cypher}$$` }
+ }
+ // Generate a unique tag that doesn't appear in the cypher query
+ let tag = 'cypher'
+ let counter = 0
+ while (cypher.includes(`$${tag}$`)) {
+ tag = `cypher${counter++}`
+ }
+ return { delimiter: `$${tag}$`, quoted: `$${tag}$${cypher}$${tag}$` }
+}
+
function AGTypeParse (input: string) {
const chars = CharStreams.fromString(input)
const lexer = new AgtypeLexer(chars)
@@ -36,21 +152,180 @@ function AGTypeParse (input: string) {
return printer.getResult()
}
-async function setAGETypes (client: Client, types: typeof pgTypes) {
- await client.query(`
- CREATE EXTENSION IF NOT EXISTS age;
- LOAD 'age';
- SET search_path = ag_catalog, "$user", public;
- `)
+/**
+ * Options for setAGETypes configuration.
+ */
+interface SetAGETypesOptions {
+ /**
+ * If true, will attempt to CREATE EXTENSION IF NOT EXISTS age.
+ * Defaults to false. Set to true only if the connected user has
+ * sufficient privileges.
+ */
+ createExtension?: boolean
+}
+
+/**
+ * Configures the pg client to properly parse AGE agtype results.
+ *
+ * This function:
+ * 1. Loads the AGE extension into the session
+ * 2. Sets the search_path to include ag_catalog
+ * 3. Registers the agtype type parser
+ *
+ * @param client - A connected pg Client instance
+ * @param types - The pg types module for registering type parsers
+ * @param options - Optional configuration settings
+ * @throws Error if AGE extension is not installed or agtype is not found
+ */
+async function setAGETypes (client: Client, types: typeof pgTypes, options?:
SetAGETypesOptions) {
+ const createExtension = options?.createExtension ?? false
+
+ if (createExtension) {
+ await client.query('CREATE EXTENSION IF NOT EXISTS age;')
+ }
+
+ try {
+ await client.query("LOAD 'age';")
+ await client.query('SET search_path = ag_catalog, "$user", public;')
+ } catch (err: unknown) {
+ const msg = err instanceof Error ? err.message : String(err)
+ throw new Error(
+ `Failed to load AGE extension: ${msg}. ` +
+ 'Ensure the AGE extension is installed in the database. ' +
+ 'You may need to run CREATE EXTENSION age; first, or pass ' +
+ '{ createExtension: true } to setAGETypes().'
+ )
+ }
- const oidResults = await client.query(`
- select typelem
- from pg_type
- where typname = '_agtype';`)
+ const oidResults = await client.query(
+ "SELECT typelem FROM pg_type WHERE typname = '_agtype';"
+ )
- if (oidResults.rows.length < 1) { throw new Error() }
+ if (oidResults.rows.length < 1) {
+ throw new Error(
+ 'AGE agtype type not found. Ensure the AGE extension is installed ' +
+ 'and loaded in the current database. Run CREATE EXTENSION age; first, ' +
+ 'or pass { createExtension: true } to setAGETypes().'
+ )
+ }
types.setTypeParser(oidResults.rows[0].typelem, AGTypeParse)
}
-export { setAGETypes, AGTypeParse }
+/**
+ * Column definition for Cypher query results.
+ */
+interface CypherColumn {
+ /** Column alias name */
+ name: string
+ /** Column type (defaults to 'agtype') */
+ type?: string
+}
+
+/**
+ * Executes a Cypher query safely against an AGE graph.
+ *
+ * This function validates the graph name to prevent SQL injection,
+ * properly quotes the Cypher query using dollar-quoting, and builds
+ * the required SQL wrapper.
+ *
+ * @param client - A connected pg Client instance (with AGE types set)
+ * @param graphName - The target graph name (must be a valid AGE graph name)
+ * @param cypher - The Cypher query string
+ * @param columns - Column definitions for the result set
+ * @returns The query result
+ * @throws Error if graphName is invalid or query fails
+ *
+ * @example
+ * ```typescript
+ * const result = await queryCypher(
+ * client,
+ * 'my_graph',
+ * 'MATCH (n:Person) WHERE n.name = $name RETURN n',
+ * [{ name: 'n' }]
+ * );
+ * ```
+ */
+async function queryCypher<T extends QueryResultRow = any> (
+ client: Client,
+ graphName: string,
+ cypher: string,
+ columns: CypherColumn[] = [{ name: 'result' }]
+): Promise<QueryResult<T>> {
+ // Validate graph name against injection
+ validateGraphName(graphName)
+
+ // Validate column definitions
+ if (!columns || columns.length === 0) {
+ throw new Error('At least one column definition is required')
+ }
+
+ for (const col of columns) {
+ if (!col.name || typeof col.name !== 'string') {
+ throw new Error('Column name must be a non-empty string')
+ }
+ // Column names must be valid SQL identifiers
+ if (!VALID_SQL_IDENTIFIER.test(col.name)) {
+ throw new Error(
+ `Invalid column name: '${col.name}'. Column names must be valid SQL
identifiers.`
+ )
+ }
+ if (col.type && !VALID_SQL_IDENTIFIER.test(col.type)) {
+ throw new Error(
+ `Invalid column type: '${col.type}'. Column types must be valid SQL
type identifiers.`
+ )
+ }
+ }
+
+ // Build column list safely
+ const columnList = columns
+ .map(col => `${col.name} ${col.type ?? 'agtype'}`)
+ .join(', ')
+
+ // Safely dollar-quote the cypher query
+ const { quoted } = cypherDollarQuote(cypher)
+
+ const sql = `SELECT * FROM cypher('${graphName}', ${quoted}) AS
(${columnList});`
+
+ return client.query<T>(sql)
+}
+
+/**
+ * Creates a new graph safely.
+ *
+ * @param client - A connected pg Client instance
+ * @param graphName - Name for the new graph (must be a valid AGE graph name)
+ * @throws Error if graphName is invalid
+ */
+async function createGraph (client: Client, graphName: string): Promise<void> {
+ validateGraphName(graphName)
+ await client.query(`SELECT * FROM ag_catalog.create_graph('${graphName}');`)
+}
+
+/**
+ * Drops an existing graph safely.
+ *
+ * @param client - A connected pg Client instance
+ * @param graphName - Name of the graph to drop (must be a valid AGE graph
name)
+ * @param cascade - If true, drop dependent objects (default: false)
+ * @throws Error if graphName is invalid
+ */
+async function dropGraph (client: Client, graphName: string, cascade: boolean
= false): Promise<void> {
+ validateGraphName(graphName)
+ if (typeof cascade !== 'boolean') {
+ throw new Error('cascade parameter must be a boolean')
+ }
+ await client.query(`SELECT * FROM ag_catalog.drop_graph('${graphName}',
${cascade});`)
+}
+
+export {
+ setAGETypes,
+ AGTypeParse,
+ queryCypher,
+ createGraph,
+ dropGraph,
+ validateGraphName,
+ validateLabelName,
+ SetAGETypesOptions,
+ CypherColumn
+}
diff --git a/drivers/nodejs/test/Agtype.test.ts
b/drivers/nodejs/test/Agtype.test.ts
index 5ae26b54..f1b1463a 100644
--- a/drivers/nodejs/test/Agtype.test.ts
+++ b/drivers/nodejs/test/Agtype.test.ts
@@ -101,4 +101,31 @@ describe('Parsing', () => {
}))
})))
})
+
+ it('Large integer uses BigInt when exceeding MAX_SAFE_INTEGER', () => {
+ // 2^53 = 9007199254740992 exceeds MAX_SAFE_INTEGER (2^53 - 1)
+ const result = AGTypeParse('{"id": 9007199254740993, "label": "test",
"properties": {}}::vertex')
+ const id = (result as Map<string, any>).get('id')
+ expect(typeof id).toBe('bigint')
+ expect(id).toBe(BigInt('9007199254740993'))
+ })
+
+ it('Safe integers remain as Number type', () => {
+ const result = AGTypeParse('{"id": 844424930131969, "label": "test",
"properties": {}}::vertex')
+ const id = (result as Map<string, any>).get('id')
+ expect(typeof id).toBe('number')
+ expect(id).toBe(844424930131969)
+ })
+
+ it('Small integers remain as Number type', () => {
+ const result = AGTypeParse('42')
+ expect(typeof result).toBe('number')
+ expect(result).toBe(42)
+ })
+
+ it('Negative integers parsed correctly', () => {
+ const result = AGTypeParse('-100')
+ expect(typeof result).toBe('number')
+ expect(result).toBe(-100)
+ })
})
diff --git a/drivers/nodejs/test/index.test.ts
b/drivers/nodejs/test/index.test.ts
index ea386dda..ae2c5c31 100644
--- a/drivers/nodejs/test/index.test.ts
+++ b/drivers/nodejs/test/index.test.ts
@@ -17,18 +17,33 @@
* under the License.
*/
-import { types, Client, QueryResultRow } from 'pg'
-import { setAGETypes } from '../src'
+import { types, Client } from 'pg'
+import {
+ setAGETypes,
+ queryCypher,
+ createGraph,
+ dropGraph,
+ validateGraphName,
+ validateLabelName
+} from '../src'
const config = {
- user: 'postgres',
- host: '127.0.0.1',
- database: 'postgres',
- password: 'agens',
- port: 5432
+ user: process.env.PGUSER || 'postgres',
+ password: process.env.PGPASSWORD || 'agens',
+ host: process.env.PGHOST || '127.0.0.1',
+ database: process.env.PGDATABASE || 'postgres',
+ port: parseInt(process.env.PGPORT || '5432', 10)
}
-const testGraphName = 'age-test'
+const testGraphName = 'age_test'
+
+// DESIGN NOTE: All test suites use { createExtension: false } intentionally.
+// The CI Docker image (apache/age:dev_snapshot_master) has the AGE extension
+// pre-installed, matching the GitHub Actions workflow. Using createExtension:
false
+// is the correct security default — auto-creating extensions requires
SUPERUSER
+// privileges and conflates extension lifecycle management with session setup.
+// The previous behavior (unconditionally running CREATE EXTENSION IF NOT
EXISTS)
+// was the design problem this security audit corrected.
describe('Pre-connected Connection', () => {
let client: Client | null
@@ -36,67 +51,203 @@ describe('Pre-connected Connection', () => {
beforeAll(async () => {
client = new Client(config)
await client.connect()
- await setAGETypes(client, types)
- await client.query(`SELECT create_graph('${testGraphName}');`)
+ await setAGETypes(client, types, { createExtension: false })
+ await createGraph(client, testGraphName)
})
afterAll(async () => {
- await client?.query(`SELECT drop_graph('${testGraphName}', true);`)
- await client?.end()
- })
- it('simple CREATE & MATCH', async () => {
- await client?.query(`
- SELECT *
- from cypher('${testGraphName}', $$ CREATE (a:Part {part_num:
'123'}),
- (b:Part {part_num: '345'}),
- (c:Part {part_num: '456'}),
- (d:Part {part_num: '789'})
- $$) as (a agtype);
- `)
- const results: QueryResultRow = await client?.query<QueryResultRow>(`
- SELECT *
- from cypher('${testGraphName}', $$
- MATCH (a) RETURN a
- $$) as (a agtype);
- `)!
- expect(results.rows).toStrictEqual(
- [
- {
- a : new Map(Object.entries({
- id: 844424930131969,
- label: 'Part',
- properties: new Map(Object.entries({
- part_num: '123'
- }))
- })),
- },
- {
- a : new Map(Object.entries({
- id: 844424930131970,
- label: 'Part',
- properties: new Map(Object.entries({
- part_num: '345'
- }))
- })),
- },
- {
- a : new Map(Object.entries({
- id: 844424930131971,
- label: 'Part',
- properties: new Map(Object.entries({
- part_num: '456'
- }))
- })),
- },
- {
- a : new Map(Object.entries({
- id: 844424930131972,
- label: 'Part',
- properties: new Map(Object.entries({
- part_num: '789'
- }))
- })),
- }
- ]
+ if (client) {
+ await dropGraph(client, testGraphName, true)
+ await client.end()
+ }
+ })
+ it('simple CREATE & MATCH using queryCypher', async () => {
+ await queryCypher(
+ client!,
+ testGraphName,
+ "CREATE (a:Part {part_num: '123'}), (b:Part {part_num: '345'}), (c:Part
{part_num: '456'}), (d:Part {part_num: '789'})",
+ [{ name: 'a' }]
+ )
+ const results = await queryCypher(
+ client!,
+ testGraphName,
+ 'MATCH (a) RETURN a',
+ [{ name: 'a' }]
)
+ expect(results.rows.length).toBe(4)
+ // Verify node properties are preserved
+ const partNums = results.rows.map((row: any) =>
row.a.get('properties').get('part_num'))
+ expect(partNums).toContain('123')
+ expect(partNums).toContain('345')
+ expect(partNums).toContain('456')
+ expect(partNums).toContain('789')
+ })
+})
+
+describe('Graph Name Validation', () => {
+ it('rejects empty graph name', () => {
+ expect(() => validateGraphName('')).toThrow('non-empty string')
+ })
+
+ it('rejects null/undefined graph name', () => {
+ expect(() => validateGraphName(null as any)).toThrow('non-empty string')
+ expect(() => validateGraphName(undefined as any)).toThrow('non-empty
string')
+ })
+
+ it('rejects graph names shorter than 3 characters', () => {
+ expect(() => validateGraphName('ab')).toThrow('at least 3 characters')
+ expect(() => validateGraphName('a')).toThrow('at least 3 characters')
+ })
+
+ it('rejects graph names exceeding 63 characters', () => {
+ const longName = 'a'.repeat(64)
+ expect(() => validateGraphName(longName)).toThrow('63 characters')
+ })
+
+ it('rejects graph names starting with digits', () => {
+ expect(() => validateGraphName('123graph')).toThrow('Invalid graph name')
+ })
+
+ it('rejects graph names with SQL injection attempts', () => {
+ expect(() => validateGraphName("'; DROP TABLE ag_graph;
--")).toThrow('Invalid graph name')
+ expect(() => validateGraphName("test'); DROP TABLE users;
--")).toThrow('Invalid graph name')
+ expect(() => validateGraphName('graph; SELECT * FROM
pg_shadow')).toThrow('Invalid graph name')
+ })
+
+ it('rejects graph names with disallowed characters', () => {
+ expect(() => validateGraphName('my graph')).toThrow('Invalid graph name')
+ expect(() => validateGraphName('my$graph')).toThrow('Invalid graph name')
+ expect(() => validateGraphName("my'graph")).toThrow('Invalid graph name')
+ expect(() => validateGraphName('my@graph')).toThrow('Invalid graph name')
+ })
+
+ it('rejects graph names ending with dot or hyphen', () => {
+ expect(() => validateGraphName('graph-')).toThrow('Invalid graph name')
+ expect(() => validateGraphName('graph.')).toThrow('Invalid graph name')
+ })
+
+ it('accepts graph names with hyphens (Neo4j/openCypher compatible)', () => {
+ expect(() => validateGraphName('my-graph')).not.toThrow()
+ expect(() => validateGraphName('age-test')).not.toThrow()
+ expect(() => validateGraphName('my-multi-part-name')).not.toThrow()
+ })
+
+ it('accepts graph names with dots', () => {
+ expect(() => validateGraphName('my.graph')).not.toThrow()
+ expect(() => validateGraphName('tenant.db')).not.toThrow()
+ })
+
+ it('accepts standard identifier graph names', () => {
+ expect(() => validateGraphName('my_graph')).not.toThrow()
+ expect(() => validateGraphName('MyGraph')).not.toThrow()
+ expect(() => validateGraphName('_private')).not.toThrow()
+ expect(() => validateGraphName('graph123')).not.toThrow()
+ expect(() => validateGraphName('abc')).not.toThrow()
+ })
+})
+
+describe('Label Name Validation', () => {
+ it('rejects SQL injection in label names', () => {
+ expect(() => validateLabelName("Person'; DROP TABLE--")).toThrow('Invalid
label name')
+ })
+
+ it('rejects label names with hyphens and dots (per AGE rules)', () => {
+ expect(() => validateLabelName('Has-Relationship')).toThrow('Invalid label
name')
+ expect(() => validateLabelName('label.name')).toThrow('Invalid label name')
+ })
+
+ it('accepts valid label names', () => {
+ expect(() => validateLabelName('Person')).not.toThrow()
+ expect(() => validateLabelName('KNOWS')).not.toThrow()
+ expect(() => validateLabelName('_internal')).not.toThrow()
+ expect(() => validateLabelName('Label123')).not.toThrow()
+ })
+})
+
+describe('SQL Injection Prevention', () => {
+ let client: Client
+
+ beforeAll(async () => {
+ client = new Client(config)
+ await client.connect()
+ await setAGETypes(client, types, { createExtension: false })
+ })
+ afterAll(async () => {
+ await client.end()
+ })
+
+ it('queryCypher rejects injected graph name', async () => {
+ await expect(
+ queryCypher(client, "test'); DROP TABLE ag_graph;--", 'MATCH (n) RETURN
n', [{ name: 'n' }])
+ ).rejects.toThrow('Invalid graph name')
+ })
+
+ it('queryCypher rejects injected column name', async () => {
+ await expect(
+ queryCypher(client, 'age_test', 'MATCH (n) RETURN n', [{ name: "n); DROP
TABLE ag_graph;--" }])
+ ).rejects.toThrow('Invalid column name')
+ })
+
+ it('createGraph rejects injected graph name', async () => {
+ await expect(
+ createGraph(client, "test'); DROP TABLE ag_graph;--")
+ ).rejects.toThrow('Invalid graph name')
+ })
+
+ it('dropGraph rejects injected graph name', async () => {
+ await expect(
+ dropGraph(client, "test'); DROP TABLE ag_graph;--")
+ ).rejects.toThrow('Invalid graph name')
+ })
+
+ it('dropGraph rejects non-boolean cascade from JS', async () => {
+ await expect(
+ dropGraph(client, 'age_test', 'true; DROP TABLE ag_graph;--' as any)
+ ).rejects.toThrow('cascade parameter must be a boolean')
+ })
+})
+
+describe('Hyphenated Graph Name (Neo4j/openCypher compatible)', () => {
+ const hyphenatedGraphName = 'age-test'
+ let client: Client | null
+
+ beforeAll(async () => {
+ client = new Client(config)
+ await client.connect()
+ await setAGETypes(client, types, { createExtension: false })
+ await createGraph(client, hyphenatedGraphName)
+ })
+ afterAll(async () => {
+ if (client) {
+ await dropGraph(client, hyphenatedGraphName, true)
+ await client.end()
+ }
+ })
+
+ it('creates and queries a graph with hyphens in the name', async () => {
+ await queryCypher(
+ client!,
+ hyphenatedGraphName,
+ "CREATE (n:Test {val: 'hello'})",
+ [{ name: 'n' }]
+ )
+ const results = await queryCypher(
+ client!,
+ hyphenatedGraphName,
+ 'MATCH (n:Test) RETURN n',
+ [{ name: 'n' }]
+ )
+ expect(results.rows.length).toBe(1)
+ })
+})
+
+describe('setAGETypes error handling', () => {
+ it('throws when client connection has been closed', async () => {
+ const tempClient = new Client(config)
+ await tempClient.connect()
+ await tempClient.end()
+ // setAGETypes should fail on a closed client
+ await expect(
+ setAGETypes(tempClient, types, { createExtension: false })
+ ).rejects.toThrow()
})
})