Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions example_config.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
"enabled": true,
"max": 1000,
"timeWindow": "15m"
},
"magicVariables": {
"defaultOrg": "rocket-oss"
}
},
"docs": {
Expand Down Expand Up @@ -226,6 +229,11 @@
"type": "query",
"name": "operations",
"value": "min"
},
{
"type": "query",
"name": "orgId",
"value": "[defaultOrg]"
}
]
},
Expand Down
1 change: 1 addition & 0 deletions src/interfaces/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ export interface ApplicationConfig {
logLevel: LogLevel;
rateLimit?: RateLimitConfig;
dangerouslyOverrideDefaultVariant?: string;
magicVariables?: Record<string, string | number | boolean>;
}

export interface WebhookConfig {
Expand Down
22 changes: 18 additions & 4 deletions src/plugin/ssp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,24 @@ export default fp(
const record = val as Record<string, unknown>;
serverSideParams.forEach(sp => {
if (sp.type === type) {
if (sp.value === '[userId]') {
record[sp.name] = request.user?.id;
} else if (sp.value === '[userEmail]') {
record[sp.name] = request.user?.email;
if (
typeof sp.value === 'string' &&
sp.value.startsWith('[') &&
sp.value.endsWith(']') &&
sp.value.length > 2
) {
const varName = sp.value.slice(1, -1);
const customVal =
fastify.appConfig.application.magicVariables?.[varName];
if (customVal !== undefined) {
record[sp.name] = customVal;
} else if (varName === 'userId') {
record[sp.name] = request.user?.id;
} else if (varName === 'userEmail') {
record[sp.name] = request.user?.email;
} else {
record[sp.name] = sp.value;
}
} else {
record[sp.name] = sp.value;
}
Expand Down
61 changes: 26 additions & 35 deletions src/routes/custom-endpoints/handlers/sql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ import {mapDataTypeToJsonSchema} from '@/routes/schema-helpers';

import {DataType} from '@/interfaces/config';

import {parseQueryPlaceholders} from '@/utils/query-placeholders';

type ParamSource = {
body?: Record<string, unknown>;
params?: Record<string, unknown>;
query?: Record<string, unknown>;
headers?: Record<string, unknown>;
};

const cast = (value: unknown, type: DataType): unknown => {
Expand All @@ -34,22 +37,26 @@ const cast = (value: unknown, type: DataType): unknown => {
case 'decimal':
return Number(value);
case 'json':
return value;
return typeof value === 'string' ? JSON.parse(value) : value;
case 'enum':
return String(value);
case 'uuid':
return String(value);
case 'ulid':
return String(value);
default:
return String(value);
}
};

function interpolateQuery(
queryTemplate: string,
{body = {}, params = {}, query = {}}: ParamSource,
{body = {}, params = {}, query = {}, headers = {}}: ParamSource,
): {sql: string; values: unknown[]} {
const values: unknown[] = [];
let paramIndex = 1;

const regex = /(\$\$|@@|&&)([a-zA-Z0-9_-]+):([a-zA-Z0-9_-]+)\1/g;
const regex = /(\$\$|@@|&&|\^\^)([a-zA-Z0-9_-]+):([a-zA-Z0-9_-]+)\1/g;

const sql = queryTemplate.replace(regex, (_, typeSymbol, name, type) => {
let val: unknown;
Expand All @@ -60,9 +67,12 @@ function interpolateQuery(
} else if (typeSymbol === '@@') {
val = body[name];
if (val === undefined) throw new Error(`Missing body param: "${name}"`);
} else {
} else if (typeSymbol === '&&') {
val = query[name];
if (val === undefined) throw new Error(`Missing query param: "${name}"`);
} else {
val = headers[name];
if (val === undefined) throw new Error(`Missing header param: "${name}"`);
}

values.push(cast(val, type as DataType));
Expand All @@ -86,42 +96,22 @@ export function buildSqlEndpoint(
const queryProperties: Record<string, object> = {};
const bodyProperties: Record<string, object> = {};

const delims = ['@@', '$$', '&&'];
const foundDelims: {pos: number; type: string}[] = [];

delims.forEach(d => {
let pos = sql.indexOf(d);
while (pos !== -1) {
foundDelims.push({pos, type: d});
pos = sql.indexOf(d, pos + 2);
}
});

foundDelims.sort((a, b) => a.pos - b.pos);

for (let i = 0; i < foundDelims.length; i += 2) {
const start = foundDelims[i];
const end = foundDelims[i + 1];

if (!end || start.type !== end.type) continue;

const varString = sql.substring(start.pos + 2, end.pos);
const parts = varString.split(':');
const placeholders = parseQueryPlaceholders(sql);

const varName = parts[0];
const varTypeStr = parts[1];
for (const {delimiter, name, type} of placeholders) {
if (delimiter === '^^') continue;

const jsonSchema = {
...mapDataTypeToJsonSchema(varTypeStr as DataType),
description: `Custom ${start.type === '@@' ? 'body' : start.type === '&&' ? 'query' : 'path'} parameter`,
...mapDataTypeToJsonSchema(type as DataType),
description: `Custom ${delimiter === '@@' ? 'body' : delimiter === '&&' ? 'query' : 'path'} parameter`,
};

if (start.type === '$$') {
paramsProperties[varName] = jsonSchema;
} else if (start.type === '&&') {
queryProperties[varName] = jsonSchema;
if (delimiter === '$$') {
paramsProperties[name] = jsonSchema;
} else if (delimiter === '&&') {
queryProperties[name] = jsonSchema;
} else {
bodyProperties[varName] = jsonSchema;
bodyProperties[name] = jsonSchema;
}
}

Expand Down Expand Up @@ -207,8 +197,9 @@ export async function handleSql(
const params = request.params as Record<string, unknown>;
const query = request.query as Record<string, unknown>;
const body = (request.body as Record<string, unknown>) || {};
const headers = request.headers as Record<string, unknown>;

const interpolated = interpolateQuery(sql, {params, query, body});
const interpolated = interpolateQuery(sql, {params, query, body, headers});

const res = await app.db.query(interpolated.sql, interpolated.values);

Expand Down
39 changes: 39 additions & 0 deletions src/utils/query-placeholders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
export type PlaceholderDelimiter = '@@' | '$$' | '&&' | '^^';

export type PlaceholderToken = {
delimiter: PlaceholderDelimiter;
name: string;
type: string;
};

export function parseQueryPlaceholders(sql: string): PlaceholderToken[] {
const delims = ['@@', '$$', '&&', '^^'];
const positions: {pos: number; type: string}[] = [];

delims.forEach(d => {
let pos = sql.indexOf(d);
while (pos !== -1) {
positions.push({pos, type: d});
pos = sql.indexOf(d, pos + 2);
}
});

positions.sort((a, b) => a.pos - b.pos);

const result: PlaceholderToken[] = [];
for (let i = 0; i < positions.length; i += 2) {
const start = positions[i];
const end = positions[i + 1];

if (!end || start.type !== end.type) continue;

const varString = sql.substring(start.pos + 2, end.pos);
const parts = varString.split(':');
result.push({
delimiter: start.type as PlaceholderDelimiter,
name: parts[0],
type: parts[1] ?? '',
});
}
return result;
}
7 changes: 7 additions & 0 deletions src/validators/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@ const applicationSchema = {
maxLength: 25,
pattern: '^[a-zA-Z0-9_-]+$',
},
magicVariables: {
type: 'object',
additionalProperties: {
anyOf: [{type: 'string'}, {type: 'number'}, {type: 'boolean'}],
},
minProperties: 1,
},
},
};

Expand Down
103 changes: 57 additions & 46 deletions src/validators/config/validate-custom-apis.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import {AppConfig} from '@/interfaces/config';

import {parseQueryPlaceholders} from '@/utils/query-placeholders';

import {ajv} from './schema';

function validateCustomAPIs(config: AppConfig): string[] {
Expand Down Expand Up @@ -59,82 +61,91 @@ function validateCustomAPIs(config: AppConfig): string[] {
}
}

// Magic variables validation
const delims = ['@@', '$$', '&&'];
const foundDelims: {pos: number; type: string}[] = [];

delims.forEach(d => {
let pos = endpoint.handler.sql.indexOf(d);
while (pos !== -1) {
foundDelims.push({pos, type: d});
pos = endpoint.handler.sql.indexOf(d, pos + 2);
}
});

foundDelims.sort((a, b) => a.pos - b.pos);
// Query placeholders validation
// Structural check: unclosed or mismatched delimiters
const delimRegex = /(@@|\$\$|&&|\^\^)/g;
const delims: {pos: number; type: string}[] = [];
let match: RegExpExecArray | null;
while ((match = delimRegex.exec(endpoint.handler.sql)) !== null) {
delims.push({pos: match.index, type: match[1]});
}

for (let i = 0; i < foundDelims.length; i += 2) {
const start = foundDelims[i];
const end = foundDelims[i + 1];
for (let i = 0; i < delims.length; i += 2) {
const start = delims[i];
const end = delims[i + 1];

if (!end) {
errors.push(
`${path}/handler/sql: unclosed magic variable delimiter "${start.type}"`,
`${path}/handler/sql: unclosed query placeholder delimiter "${start.type}"`,
);
break;
}

if (start.type !== end.type) {
errors.push(
`${path}/handler/sql: mixed magic variable delimiters "${start.type}" and "${end.type}"`,
`${path}/handler/sql: mixed query placeholder delimiters "${start.type}" and "${end.type}"`,
);
continue;
}
}

const varString = endpoint.handler.sql.substring(
start.pos + 2,
end.pos,
// Check for multiple type declarations in query placeholders
const multiTypeRegex =
/(@@|\$\$|&&|\^\^)([a-zA-Z0-9_-]+):([a-zA-Z0-9_-]+):([a-zA-Z0-9_-]+)\1/g;
let mtMatch: RegExpExecArray | null;
while ((mtMatch = multiTypeRegex.exec(endpoint.handler.sql)) !== null) {
const varString = mtMatch[0].slice(
mtMatch[1].length,
-mtMatch[1].length,
);
const parts = varString.split(':');
const varName = parts[0];
const varType = parts[1];
errors.push(
`${path}/handler/sql: invalid query placeholder format "${varString}", multiple types provided`,
);
}

// Per-variable validation
const placeholders = parseQueryPlaceholders(endpoint.handler.sql);
const validTypes = [
'integer',
'string',
'boolean',
'text',
'datetime',
'decimal',
'date',
'json',
'enum',
'uuid',
'ulid',
];
for (const {delimiter, name: varName, type: varType} of placeholders) {
const typeName =
start.type === '@@'
delimiter === '@@'
? 'body (@@)'
: start.type === '$$'
: delimiter === '$$'
? 'path ($$)'
: 'query (&&)';
: delimiter === '&&'
? 'query (&&)'
: 'header (^^)';

// 1. Validation for variable name patterns (alphanumeric, underscores, hyphens)
if (!/^[a-zA-Z0-9_-]+$/.test(varName)) {
errors.push(
`${path}/handler/sql: invalid magic variable name "${varName}" for ${typeName} parameter`,
`${path}/handler/sql: invalid query placeholder name "${varName}" for ${typeName} parameter`,
);
}

// 2. Validate datatype
if (parts.length > 2) {
errors.push(
`${path}/handler/sql: invalid magic variable format "${varString}", multiple types provided`,
);
} else if (!varType) {
if (!varType) {
errors.push(
`${path}/handler/sql: missing data type for magic variable "${varName}" in ${typeName} parameter`,
`${path}/handler/sql: missing data type for query placeholder "${varName}" in ${typeName} parameter`,
);
} else if (
!['integer', 'string', 'boolean', 'text', 'datetime'].includes(
varType,
)
) {
} else if (!validTypes.includes(varType)) {
errors.push(
`${path}/handler/sql: invalid magic variable type "${varType}" for ${typeName} parameter`,
`${path}/handler/sql: invalid query placeholder type "${varType}" for ${typeName} parameter`,
);
}

// 3. GET method should not have body magic variables (@@)
if (endpoint.method === 'GET' && start.type === '@@') {
if (endpoint.method === 'GET' && delimiter === '@@') {
errors.push(
`${path}/handler/sql: body magic variables (@@) are not allowed for GET method`,
`${path}/handler/sql: body query placeholders (@@) are not allowed for GET method`,
);
}
}
Expand Down
Loading
Loading