blob: ae2c5c319166209b7583833b8d9ba5bb0db06390 [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { types, Client } from 'pg'
import {
setAGETypes,
queryCypher,
createGraph,
dropGraph,
validateGraphName,
validateLabelName
} from '../src'
const config = {
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'
// 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
beforeAll(async () => {
client = new Client(config)
await client.connect()
await setAGETypes(client, types, { createExtension: false })
await createGraph(client, testGraphName)
})
afterAll(async () => {
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()
})
})