Skip to content

Commit 34fc88c

Browse files
committed
double check and fix review
1 parent c481a11 commit 34fc88c

15 files changed

Lines changed: 299 additions & 52 deletions

File tree

graphile/graphile-misc-plugins/__tests__/PublicKeySignature.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,12 @@ describe('PublicKeySignature config validation', () => {
6565
);
6666
});
6767

68+
it('throws on invalid crypto_network value', () => {
69+
expect(() => PublicKeySignature({ ...defaultConfig, crypto_network: 'btc mainnet' })).toThrow(
70+
/invalid crypto_network/,
71+
);
72+
});
73+
6874
it('throws on function name with uppercase letters', () => {
6975
expect(() => PublicKeySignature({ ...defaultConfig, sign_in_request_challenge: 'BadName' })).toThrow(
7076
/invalid sign_in_request_challenge/,

graphile/graphile-misc-plugins/src/PublicKeySignature.ts

Lines changed: 19 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -16,21 +16,31 @@ export interface PublicKeyChallengeConfig {
1616
}
1717

1818
const SAFE_IDENTIFIER = /^[a-z_][a-z0-9_]*$/;
19+
const SAFE_CRYPTO_NETWORK = /^[a-z0-9_-]{1,64}$/i;
1920

2021
function validateIdentifier(name: string, label: string): void {
2122
if (!SAFE_IDENTIFIER.test(name)) {
2223
throw new Error(`PublicKeySignature: invalid ${label} "${name}" — must match /^[a-z_][a-z0-9_]*$/`);
2324
}
2425
}
2526

27+
function validateCryptoNetwork(name: string): void {
28+
if (!SAFE_CRYPTO_NETWORK.test(name)) {
29+
throw new Error(
30+
'PublicKeySignature: invalid crypto_network — must match /^[a-z0-9_-]{1,64}$/i',
31+
);
32+
}
33+
}
34+
2635
const MAX_PUBLIC_KEY_LENGTH = 256;
2736
const MAX_MESSAGE_LENGTH = 4096;
2837
const MAX_SIGNATURE_LENGTH = 1024;
38+
const ENABLE_SIGNATURE_VERIFICATION = process.env.ENABLE_SIGNATURE_VERIFICATION === 'true';
2939

3040
export const PublicKeySignature = (pubkey_challenge: PublicKeyChallengeConfig): GraphileConfig.Plugin => {
3141
const {
3242
schema,
33-
crypto_network: _crypto_network,
43+
crypto_network,
3444
sign_up_with_key,
3545
sign_in_request_challenge,
3646
sign_in_record_failure,
@@ -42,6 +52,7 @@ export const PublicKeySignature = (pubkey_challenge: PublicKeyChallengeConfig):
4252
validateIdentifier(sign_in_request_challenge, 'sign_in_request_challenge');
4353
validateIdentifier(sign_in_record_failure, 'sign_in_record_failure');
4454
validateIdentifier(sign_in_with_challenge, 'sign_in_with_challenge');
55+
validateCryptoNetwork(crypto_network);
4556

4657
return extendSchema(() => ({
4758
typeDefs: gql`
@@ -157,11 +168,8 @@ export const PublicKeySignature = (pubkey_challenge: PublicKeyChallengeConfig):
157168
});
158169
},
159170

160-
// NOTE: Crypto verification is currently stubbed (always returns false).
161-
// The verifyMessage call from @pyramation/crypto-keys needs to be
162-
// re-implemented, e.g. using interchainJS, before this mutation can
163-
// succeed. Until then, verifyMessageForSigning will always record a
164-
// failure and throw BAD_SIGNIN.
171+
// NOTE: Verification remains behind a feature flag until crypto
172+
// verification is re-implemented.
165173
verifyMessageForSigning(_$mutation: any, fieldArgs: any) {
166174
const $input = fieldArgs.getRaw('input');
167175
const $withPgClient = (grafastContext() as any).get('withPgClient');
@@ -180,24 +188,13 @@ export const PublicKeySignature = (pubkey_challenge: PublicKeyChallengeConfig):
180188
throw new Error('INVALID_SIGNATURE');
181189
}
182190

183-
// TODO: Re-implement crypto verification (e.g. using interchainJS).
184-
// const network = Networks[crypto_network];
185-
// const result = verifyMessage(message, publicKey, signature, network);
186-
const result = false;
191+
if (!ENABLE_SIGNATURE_VERIFICATION) {
192+
// Fail closed without mutating lockout counters while verification
193+
// is disabled.
194+
throw new Error('FEATURE_DISABLED');
195+
}
187196

188197
return withPgClient(null, async (pgClient: any) => {
189-
if (!result) {
190-
// Record failure outside transaction — must persist for rate limiting/lockout
191-
await pgQueryWithContext({
192-
client: pgClient,
193-
context: { role: 'anonymous' },
194-
query: `SELECT * FROM "${schema}"."${sign_in_record_failure}"($1)`,
195-
variables: [publicKey],
196-
skipTransaction: true
197-
});
198-
throw new Error('BAD_SIGNIN');
199-
}
200-
201198
// Only the success path needs a transaction (multi-step)
202199
await pgClient.query('BEGIN');
203200
try {

graphile/graphile-plugin-connection-filter-postgis/src/plugin.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,22 @@ import type { SQL } from 'pg-sql2';
55
import { CONCRETE_SUBTYPES } from 'graphile-postgis';
66
import type { PostgisExtensionInfo } from 'graphile-postgis';
77

8+
const ALLOWED_SQL_OPERATORS = new Set([
9+
'=',
10+
'&&',
11+
'&&&',
12+
'&<',
13+
'&<|',
14+
'&>',
15+
'|&>',
16+
'<<',
17+
'<<|',
18+
'>>',
19+
'|>>',
20+
'~',
21+
'~=',
22+
]);
23+
824
/**
925
* PgConnectionArgFilterPostgisOperatorsPlugin
1026
*
@@ -257,6 +273,10 @@ export const PgConnectionArgFilterPostgisOperatorsPlugin: GraphileConfig.Plugin
257273

258274
// Process SQL operator-based operators
259275
for (const [op, baseTypes, operatorName, description] of operatorSpecs) {
276+
if (!ALLOWED_SQL_OPERATORS.has(op)) {
277+
throw new Error(`Unexpected SQL operator: ${op}`);
278+
}
279+
260280
for (const baseType of baseTypes) {
261281
allSpecs.push({
262282
typeNames: gqlTypeNamesByBase[baseType],

graphile/graphile-settings/__tests__/upload-resolver.test.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ describe('uploadResolver MIME validation', () => {
9595
{},
9696
{ uploadPlugin: { tags: {}, type: 'image' } },
9797
),
98-
).rejects.toThrow('UPLOAD_MIMETYPE image/jpg,image/jpeg,image/png,image/svg+xml');
98+
).rejects.toThrow('UPLOAD_MIMETYPE image/jpeg,image/png,image/svg+xml');
9999

100100
expect(mockDetectContentType).toHaveBeenCalledTimes(1);
101101
expect(mockUploadWithContentType).not.toHaveBeenCalled();
@@ -141,4 +141,3 @@ describe('uploadResolver MIME validation', () => {
141141
);
142142
});
143143
});
144-

graphile/graphile-settings/src/upload-resolver.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import Streamer from '@constructive-io/s3-streamer';
2020
import uploadNames from '@constructive-io/upload-names';
2121
import { getEnvOptions } from '@constructive-io/graphql-env';
2222
import { Logger } from '@pgpmjs/logger';
23+
import { randomBytes } from 'crypto';
2324
import type { Readable } from 'stream';
2425
import type {
2526
FileUpload,
@@ -28,6 +29,7 @@ import type {
2829
} from 'graphile-upload-plugin';
2930

3031
const log = new Logger('upload-resolver');
32+
const DEFAULT_IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/svg+xml'];
3133

3234
let streamer: Streamer | null = null;
3335
let bucketName: string;
@@ -66,9 +68,7 @@ function getStreamer(): Streamer {
6668
* Format: {random10chars}-{sanitized-filename}
6769
*/
6870
function generateKey(filename: string): string {
69-
const rand =
70-
Math.random().toString(36).substring(2, 7) +
71-
Math.random().toString(36).substring(2, 7);
71+
const rand = randomBytes(12).toString('hex');
7272
return `${rand}-${uploadNames(filename)}`;
7373
}
7474

@@ -124,9 +124,9 @@ async function uploadResolver(
124124
.trim()
125125
.split(',')
126126
.map((a: string) => a.trim())
127-
: typ === 'image'
128-
? ['image/jpg', 'image/jpeg', 'image/png', 'image/svg+xml']
129-
: [];
127+
: typ === 'image'
128+
? DEFAULT_IMAGE_MIME_TYPES
129+
: [];
130130

131131
const detected = await s3.detectContentType({
132132
readStream: upload.createReadStream(),

graphile/graphile-sql-expression-validator/__tests__/plugin.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,40 @@ describe('GraphQLObjectType_fields_field hook behavior', () => {
280280
).rejects.toThrow('Invalid SQL expression in formula');
281281
});
282282

283+
it('should validate deeply nested text input in sideEffect callback', async () => {
284+
const plugin = createSqlExpressionValidatorPlugin();
285+
const hook = plugin.schema!.hooks!
286+
.GraphQLObjectType_fields_field as Function;
287+
288+
const field = {
289+
type: 'CreateWidgetPayload',
290+
plan: jest.fn().mockReturnValue('step')
291+
};
292+
const build = makeMockBuild();
293+
const context = makeMockContext({
294+
isRootMutation: true,
295+
pgCodec: {
296+
attributes: {
297+
formula: {
298+
extensions: { tags: { sqlExpression: true } }
299+
}
300+
}
301+
}
302+
});
303+
304+
const result = hook(field, build, context);
305+
const mockFieldArgs = {
306+
get: jest.fn().mockReturnValue('inputStep')
307+
};
308+
result.plan('parent', mockFieldArgs);
309+
310+
const validationCallback = mockSideEffect.mock.calls[0][1];
311+
312+
await expect(
313+
validationCallback({ level1: { level2: { formula: 'username' } } })
314+
).rejects.toThrow('Invalid SQL expression in formula');
315+
});
316+
283317
it('should skip null/undefined values in sideEffect callback', async () => {
284318
const plugin = createSqlExpressionValidatorPlugin();
285319
const hook = plugin.schema!.hooks!

graphile/graphile-sql-expression-validator/__tests__/validator.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,18 @@ describe('parseAndValidateSqlExpression', () => {
9696
expect(result.valid).toBe(false);
9797
expect(result.error).toContain('semicolons');
9898
});
99+
100+
it('should reject line comments', async () => {
101+
const result = await parseAndValidateSqlExpression('now() -- trailing');
102+
expect(result.valid).toBe(false);
103+
expect(result.error).toContain('SQL comments');
104+
});
105+
106+
it('should reject block comments', async () => {
107+
const result = await parseAndValidateSqlExpression('now() /* comment */');
108+
expect(result.valid).toBe(false);
109+
expect(result.error).toContain('SQL comments');
110+
});
99111
});
100112

101113
// ─── Valid expressions ───────────────────────────────────────────
@@ -367,6 +379,14 @@ describe('parseAndValidateSqlExpression', () => {
367379
expect(resultA.valid).toBe(true);
368380
expect(resultB.valid).toBe(true);
369381
});
382+
383+
it('should match allowed schema names case-insensitively', async () => {
384+
const result = await parseAndValidateSqlExpression(
385+
'app_public.my_func()',
386+
{ allowedSchemas: ['APP_PUBLIC'], allowedFunctions: ['my_func'] }
387+
);
388+
expect(result.valid).toBe(true);
389+
});
370390
});
371391

372392
// ─── Allowlist rejection (disallowed node types) ────────────────

graphile/graphile-sql-expression-validator/src/plugin.ts

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,16 +37,39 @@ import type { SqlExpressionValidatorOptions } from './validator';
3737

3838
/**
3939
* Find a value in a potentially nested mutation input object.
40-
* PostGraphile v5 mutation input is structured as `{ tablePatch: { field: value } }`
41-
* or `{ table: { field: value } }` — at most one level of nesting.
40+
* Input is typically 1 level deep, but we traverse defensively to avoid
41+
* silently skipping deeper/nested mutation payloads.
4242
*/
4343
function findValue(input: Record<string, unknown>, key: string): unknown {
44-
if (key in input) return input[key];
45-
for (const v of Object.values(input)) {
46-
if (v && typeof v === 'object' && key in (v as Record<string, unknown>)) {
47-
return (v as Record<string, unknown>)[key];
44+
const visited = new Set<object>();
45+
const stack: Array<{ node: Record<string, unknown>; depth: number }> = [
46+
{ node: input, depth: 0 }
47+
];
48+
const maxDepth = 32;
49+
50+
while (stack.length > 0) {
51+
const current = stack.pop()!;
52+
const { node, depth } = current;
53+
54+
if (key in node) {
55+
return node[key];
56+
}
57+
58+
if (visited.has(node) || depth >= maxDepth) {
59+
continue;
60+
}
61+
visited.add(node);
62+
63+
for (const value of Object.values(node)) {
64+
if (value && typeof value === 'object') {
65+
stack.push({
66+
node: value as Record<string, unknown>,
67+
depth: depth + 1
68+
});
69+
}
4870
}
4971
}
72+
5073
return undefined;
5174
}
5275

graphile/graphile-sql-expression-validator/src/validator.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ const FORBIDDEN_TYPE_CASTS = new Set([
149149
]);
150150

151151
const MAX_AST_DEPTH = 100;
152+
const SQL_COMMENT_TOKENS = /--|\/\*|\*\//;
152153

153154
// ─── Internal helpers ─────────────────────────────────────────────
154155

@@ -179,7 +180,7 @@ function extractStringVal(n: unknown): string {
179180
function validateAstNode(
180181
node: unknown,
181182
allowedFunctions: ReadonlySet<string>,
182-
allowedSchemas: readonly string[],
183+
allowedSchemas: ReadonlySet<string>,
183184
path: string[]
184185
): AstNodeValidationResult {
185186
if (path.length > MAX_AST_DEPTH) {
@@ -247,7 +248,7 @@ function validateAstNode(
247248
const functionName = names[names.length - 1];
248249

249250
if (schemaName) {
250-
if (!allowedSchemas.includes(schemaName)) {
251+
if (!allowedSchemas.has(schemaName.toLowerCase())) {
251252
return {
252253
valid: false,
253254
error: `Function schema "${schemaName}" is not in the allowed schemas list`
@@ -345,7 +346,15 @@ export async function parseAndValidateSqlExpression(
345346
};
346347
}
347348

349+
if (SQL_COMMENT_TOKENS.test(expression)) {
350+
return {
351+
valid: false,
352+
error: 'Expression cannot contain SQL comments'
353+
};
354+
}
355+
348356
const funcSet = new Set(allowedFunctions.map((f) => f.toLowerCase()));
357+
const schemaSet = new Set(allowedSchemas.map((s) => s.toLowerCase()));
349358

350359
try {
351360
const wrappedSql = `SELECT (${expression})`;
@@ -381,7 +390,7 @@ export async function parseAndValidateSqlExpression(
381390
const validationResult = validateAstNode(
382391
expressionAst,
383392
funcSet,
384-
allowedSchemas,
393+
schemaSet,
385394
[]
386395
);
387396

@@ -434,11 +443,12 @@ export async function validateAst(
434443
}
435444

436445
const funcSet = new Set(allowedFunctions.map((f) => f.toLowerCase()));
446+
const schemaSet = new Set(allowedSchemas.map((s) => s.toLowerCase()));
437447

438448
const validationResult = validateAstNode(
439449
ast,
440450
funcSet,
441-
allowedSchemas,
451+
schemaSet,
442452
[]
443453
);
444454

0 commit comments

Comments
 (0)