Skip to content
Open
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
6 changes: 6 additions & 0 deletions workspaces/orchestrator/.changeset/five-meals-cover.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@red-hat-developer-hub/backstage-plugin-orchestrator-backend': patch
---

- Update dependecy @urql/core to fix CVE-2026-3118
- Reworks the filter and query builder code to use query variables
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@
"@backstage/plugin-scaffolder-node": "^0.12.4",
"@red-hat-developer-hub/backstage-plugin-orchestrator-common": "workspace:^",
"@red-hat-developer-hub/backstage-plugin-orchestrator-node": "workspace:^",
"@urql/core": "^4.1.4",
"@urql/core": "^6.0.1",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Graphql filter injection risk 🐞 Bug ⛨ Security

Request-body filters values are inserted into GraphQL query strings without escaping, so
quotes/braces in filter values can break query parsing and allow query-shape injection against the
Data Index GraphQL endpoint. This becomes more user-visible with the @urql/core upgrade because
malformed queries will error instead of being tolerated downstream.
Agent Prompt
### Issue description
`filters` coming from `req.body` are embedded into GraphQL query strings without escaping, which can break query parsing and enables query-shape injection.

### Issue Context
- `router.ts` returns request filters directly from the body.
- `filterBuilder.ts` wraps string values in quotes without escaping.
- Queries are executed by passing constructed strings into `@urql/core`.

### Fix Focus Areas
- Sanitize/escape GraphQL string literal values (at minimum via `JSON.stringify(String(value))`) before embedding them into query strings.
- Prefer GraphQL variables wherever possible (for values like `definitionId`, `instanceId`, and filter values).
- Validate filter objects from the request (shape + value types) and reject invalid characters if variables cannot be used.

#### Files/lines
- workspaces/orchestrator/plugins/orchestrator-backend/src/service/router.ts[1098-1100]
- workspaces/orchestrator/plugins/orchestrator-backend/src/helpers/filterBuilder.ts[101-155]
- workspaces/orchestrator/plugins/orchestrator-backend/src/helpers/filterBuilder.ts[251-277]
- workspaces/orchestrator/plugins/orchestrator-backend/src/service/DataIndexService.ts[132-153]
- workspaces/orchestrator/plugins/orchestrator-backend/src/service/DataIndexService.ts[404-454]
- workspaces/orchestrator/plugins/orchestrator-backend/src/helpers/queryBuilder.ts[18-38]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

"ajv-formats": "^2.1.1",
"cloudevents": "^8.0.0",
"express": "^4.21.2",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ import {
TypeName,
} from '@red-hat-developer-hub/backstage-plugin-orchestrator-common';

import { randomBytes } from 'node:crypto';

import { FilterClause, FilterClauseVariable } from '../types/filterClause';

type ProcessType = 'ProcessDefinition' | 'ProcessInstance';

const supportedOperators = [
Expand Down Expand Up @@ -73,44 +77,88 @@ function handleLogicalFilter(
introspection: IntrospectionField[],
type: ProcessType,
filter: LogicalFilter,
): string {
if (!filter.operator) return '';
): FilterClause {
if (!filter.operator) return {} as FilterClause;

const subClauses = filter.filters.map(f =>
buildFilterCondition(introspection, type, f),
);

return `${filter.operator.toLowerCase()}: {${subClauses.join(', ')}}`;
const filterClause: FilterClause = {
clause: `${filter.operator.toLowerCase()}: {${subClauses.map(cl => cl.clause).join(', ')}}`,
clauseVariable: subClauses.flatMap(cl => cl.clauseVariable),
};
return filterClause;
}

function handleNestedFilter(
introspection: IntrospectionField[],
type: ProcessType,
filter: NestedFilter,
): string {
): FilterClause {
const subClauses = buildFilterCondition(
introspection,
type,
filter.nested,
true,
);

return `${filter.field}: {${subClauses}}`;
const filterClause: FilterClause = {
clauseVariable: subClauses.clauseVariable,
clause: `${filter.field}: {${subClauses.clause}}`,
};

return filterClause;
}

function handleBetweenOperator(filter: FieldFilter): string {
function handleBetweenOperator(filter: FieldFilter): FilterClause {
if (!Array.isArray(filter.value) || filter.value.length !== 2) {
throw new Error('Between operator requires an array of two elements');
}
return `${filter.field}: {${getGraphQLOperator(
const filterClauseVariableArray: FilterClauseVariable[] = [];
const clauseVariableName1 = `clauseVariable${nonSecureRandomAlphaNumeric()}`;
const filterClauseVariable1: FilterClauseVariable = {
clauseVariableName: clauseVariableName1,
formattedValue: filter.value[0],
clauseVariableType: 'String',
};

const clauseVariableName2 = `clauseVariable${nonSecureRandomAlphaNumeric()}`;
const filterClauseVariable2: FilterClauseVariable = {
clauseVariableName: clauseVariableName2,
formattedValue: filter.value[1],
clauseVariableType: 'String',
};

const clause = `${filter.field}: {${getGraphQLOperator(
FieldFilterOperatorEnum.Between,
)}: {from: "${filter.value[0]}", to: "${filter.value[1]}"}}`;
)}: {from: $${clauseVariableName1}, to: $${clauseVariableName2}}}`;
filterClauseVariableArray.push(filterClauseVariable1, filterClauseVariable2);
const filterClause: FilterClause = {
clause: clause,
clauseVariable: filterClauseVariableArray,
};

return filterClause;
}

function handleIsNullOperator(filter: FieldFilter): string {
return `${filter.field}: {${getGraphQLOperator(
FieldFilterOperatorEnum.IsNull,
)}: ${convertToBoolean(filter.value)}}`;
function handleIsNullOperator(filter: FieldFilter): FilterClause {
const clauseVariableName = `clauseVariable${nonSecureRandomAlphaNumeric()}`;
const clause = `${filter.field}: {${getGraphQLOperator(FieldFilterOperatorEnum.IsNull)}: $${clauseVariableName}}`;

const filterClauseVariable: FilterClauseVariable = {
clauseVariableName: clauseVariableName,
formattedValue: convertToBoolean(filter.value),
clauseVariableType: 'Boolean',
};
const filterClauseVariableArray: FilterClauseVariable[] = [];
filterClauseVariableArray.push(filterClauseVariable);
const clauseObject: FilterClause = {
clauseVariable: filterClauseVariableArray,
clause,
};

return clauseObject;
}

function isEnumFilter(
Expand All @@ -136,32 +184,58 @@ function handleBinaryOperator(
binaryFilter: FieldFilter,
fieldDef: IntrospectionField | undefined,
type: 'ProcessDefinition' | 'ProcessInstance',
): string {
): FilterClause {
if (isEnumFilter(binaryFilter.field, type)) {
if (!isValidEnumOperator(binaryFilter.operator)) {
throw new Error(
`Invalid operator ${binaryFilter.operator} for enum field ${binaryFilter.field} filter`,
);
}
}
const formattedValue = Array.isArray(binaryFilter.value)
? `[${binaryFilter.value
.map(v => formatValue(binaryFilter.field, v, fieldDef, type))
.join(', ')}]`
: formatValue(binaryFilter.field, binaryFilter.value, fieldDef, type);
return `${binaryFilter.field}: {${getGraphQLOperator(
binaryFilter.operator,
)}: ${formattedValue}}`;
let formattedValue: any;
let paramType: string;
if (Array.isArray(binaryFilter.value)) {
formattedValue = binaryFilter.value.map(v =>
formatValue(binaryFilter.field, v, fieldDef, type),
);
paramType = isEnumFilter(binaryFilter.field, type)
? '[ProcessInstanceState!]'
: '[String!]';
} else {
formattedValue = formatValue(
binaryFilter.field,
binaryFilter.value,
fieldDef,
type,
);
paramType = 'String';
}

const clauseVariableName = `clauseVariable${nonSecureRandomAlphaNumeric()}`;
const clause = `${binaryFilter.field}: {${getGraphQLOperator(binaryFilter.operator)}: $${clauseVariableName}}`;
const filterClauseVariable: FilterClauseVariable = {
clauseVariableName: clauseVariableName,
formattedValue: formattedValue,
clauseVariableType: paramType,
};
const filterClauseVariableArray: FilterClauseVariable[] = [];
filterClauseVariableArray.push(filterClauseVariable);
const clauseObject: FilterClause = {
clauseVariable: filterClauseVariableArray,
clause,
};

return clauseObject;
}

export function buildFilterCondition(
introspection: IntrospectionField[],
type: ProcessType,
filters?: Filter,
isNested?: boolean,
): string {
): FilterClause {
if (!filters) {
return '';
return {} as FilterClause;
}

if (isNestedFilter(filters)) {
Expand Down Expand Up @@ -255,7 +329,7 @@ function formatValue(
type: ProcessType,
): string {
if (!fieldDef) {
return `"${fieldValue}"`;
return `${fieldValue}`;
}

if (!isFieldFilterSupported) {
Expand All @@ -270,7 +344,7 @@ function formatValue(
fieldDef.type.name === TypeName.Id ||
fieldDef.type.name === TypeName.Date
) {
return `"${fieldValue}"`;
return `${fieldValue}`;
}
throw new Error(
`Failed to format value for ${fieldName} ${fieldValue} with type ${fieldDef.type.name}`,
Expand Down Expand Up @@ -301,3 +375,9 @@ function getGraphQLOperator(operator: FieldFilterOperatorEnum): string {
throw new Error(`Operation "${operator}" not supported`);
}
}

// Function for getting 4 random digits to append to the clause variable name.
// Not used for any secrets or anything
function nonSecureRandomAlphaNumeric() {
return randomBytes(8).toString('hex');
}
Loading
Loading