Skip to content

Commit 159ba35

Browse files
committed
feat: enhance Cedar policy generation with string escaping for table and column names
1 parent c46c037 commit 159ba35

2 files changed

Lines changed: 74 additions & 11 deletions

File tree

backend/src/entities/cedar-authorization/cedar-policy-generator.ts

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,25 @@ export interface IPublicTablePermission {
77
readableColumns?: Array<string>;
88
}
99

10+
function escapeCedarString(value: string): string {
11+
return value
12+
.replace(/\\/g, '\\\\')
13+
.replace(/"/g, '\\"')
14+
.replace(/\n/g, '\\n')
15+
.replace(/\r/g, '\\r')
16+
.replace(/\t/g, '\\t');
17+
}
18+
19+
function cedarEntityRef(entityType: string, id: string): string {
20+
return `${entityType}::"${escapeCedarString(id)}"`;
21+
}
22+
1023
export function generatePublicCedarPolicy(connectionId: string, tables: Array<IPublicTablePermission>): string {
1124
const policies: Array<string> = [];
1225

1326
for (const table of tables) {
1427
if (!table.tableName) continue;
15-
const tableRef = `RocketAdmin::Table::"${connectionId}/${table.tableName}"`;
28+
const tableRef = cedarEntityRef('RocketAdmin::Table', `${connectionId}/${table.tableName}`);
1629

1730
policies.push(
1831
`permit(\n principal,\n action == RocketAdmin::Action::"table:query",\n resource == ${tableRef}\n);`,
@@ -21,7 +34,7 @@ export function generatePublicCedarPolicy(connectionId: string, tables: Array<IP
2134
const readableColumns = table.readableColumns;
2235
if (readableColumns && readableColumns.length > 0) {
2336
for (const columnName of readableColumns) {
24-
const columnRef = `RocketAdmin::Column::"${connectionId}/${table.tableName}/${columnName}"`;
37+
const columnRef = cedarEntityRef('RocketAdmin::Column', `${connectionId}/${table.tableName}/${columnName}`);
2538
policies.push(
2639
`permit(\n principal,\n action == RocketAdmin::Action::"column:read",\n resource == ${columnRef}\n);`,
2740
);
@@ -42,7 +55,7 @@ export function generateCedarPolicyForGroup(
4255
permissions: IComplexPermission,
4356
): string {
4457
const policies: Array<string> = [];
45-
const connectionRef = `RocketAdmin::Connection::"${connectionId}"`;
58+
const connectionRef = cedarEntityRef('RocketAdmin::Connection', connectionId);
4659

4760
if (isMain) {
4861
policies.push(`permit(\n principal,\n action,\n resource\n);`);
@@ -69,7 +82,7 @@ export function generateCedarPolicyForGroup(
6982

7083
// Group permissions
7184
const groupAccess = permissions.group.accessLevel;
72-
const groupResourceRef = `RocketAdmin::Group::"${permissions.group.groupId}"`;
85+
const groupResourceRef = cedarEntityRef('RocketAdmin::Group', permissions.group.groupId);
7386
if (groupAccess === AccessLevelEnum.edit) {
7487
policies.push(
7588
`permit(\n principal,\n action == RocketAdmin::Action::"group:read",\n resource == ${groupResourceRef}\n);`,
@@ -87,7 +100,7 @@ export function generateCedarPolicyForGroup(
87100
let hasCreatePermission = false;
88101
let hasReadPermission = false;
89102
for (const dashboard of permissions.dashboards) {
90-
const dashboardRef = `RocketAdmin::Dashboard::"${connectionId}/${dashboard.dashboardId}"`;
103+
const dashboardRef = cedarEntityRef('RocketAdmin::Dashboard', `${connectionId}/${dashboard.dashboardId}`);
91104
const access = dashboard.accessLevel;
92105

93106
if (access.read) {
@@ -110,7 +123,7 @@ export function generateCedarPolicyForGroup(
110123
);
111124
}
112125
}
113-
const newDashboardRef = `RocketAdmin::Dashboard::"${connectionId}/__new__"`;
126+
const newDashboardRef = cedarEntityRef('RocketAdmin::Dashboard', `${connectionId}/__new__`);
114127
if (hasReadPermission) {
115128
policies.push(
116129
`permit(\n principal,\n action == RocketAdmin::Action::"dashboard:read",\n resource == ${newDashboardRef}\n);`,
@@ -127,7 +140,7 @@ export function generateCedarPolicyForGroup(
127140
let hasPanelCreatePermission = false;
128141
let hasPanelReadPermission = false;
129142
for (const panel of permissions.panels) {
130-
const panelRef = `RocketAdmin::Panel::"${connectionId}/${panel.panelId}"`;
143+
const panelRef = cedarEntityRef('RocketAdmin::Panel', `${connectionId}/${panel.panelId}`);
131144
const access = panel.accessLevel;
132145

133146
if (access.read) {
@@ -150,7 +163,7 @@ export function generateCedarPolicyForGroup(
150163
);
151164
}
152165
}
153-
const newPanelRef = `RocketAdmin::Panel::"${connectionId}/__new__"`;
166+
const newPanelRef = cedarEntityRef('RocketAdmin::Panel', `${connectionId}/__new__`);
154167
if (hasPanelReadPermission) {
155168
policies.push(
156169
`permit(\n principal,\n action == RocketAdmin::Action::"panel:read",\n resource == ${newPanelRef}\n);`,
@@ -164,7 +177,7 @@ export function generateCedarPolicyForGroup(
164177
}
165178

166179
for (const table of permissions.tables) {
167-
const tableRef = `RocketAdmin::Table::"${connectionId}/${table.tableName}"`;
180+
const tableRef = cedarEntityRef('RocketAdmin::Table', `${connectionId}/${table.tableName}`);
168181
const access = table.accessLevel;
169182

170183
// triggerCustomAction is intentionally excluded from hasAnyAccess: triggering custom
@@ -181,7 +194,7 @@ export function generateCedarPolicyForGroup(
181194
const readableColumns = table.readableColumns;
182195
if (readableColumns && readableColumns.length > 0) {
183196
for (const columnName of readableColumns) {
184-
const columnRef = `RocketAdmin::Column::"${connectionId}/${table.tableName}/${columnName}"`;
197+
const columnRef = cedarEntityRef('RocketAdmin::Column', `${connectionId}/${table.tableName}/${columnName}`);
185198
policies.push(
186199
`permit(\n principal,\n action == RocketAdmin::Action::"column:read",\n resource == ${columnRef}\n);`,
187200
);
@@ -223,7 +236,10 @@ export function generateCedarPolicyForGroup(
223236
if (permissions.actionEvents) {
224237
for (const event of permissions.actionEvents) {
225238
if (!event.accessLevel?.trigger) continue;
226-
const eventRef = `RocketAdmin::ActionEvent::"${connectionId}/${event.tableName}/${event.eventId}"`;
239+
const eventRef = cedarEntityRef(
240+
'RocketAdmin::ActionEvent',
241+
`${connectionId}/${event.tableName}/${event.eventId}`,
242+
);
227243
policies.push(
228244
`permit(\n principal,\n action == RocketAdmin::Action::"actionEvent:trigger",\n resource == ${eventRef}\n);`,
229245
);

backend/test/ava-tests/non-saas-tests/non-saas-cedar-policy-generator.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,3 +374,50 @@ test('generatePublicCedarPolicy: only ever emits table:query and column:read act
374374
t.true(action === 'table:query' || action === 'column:read', `unexpected action ${action}`);
375375
}
376376
});
377+
378+
test('generatePublicCedarPolicy escapes quotes/backslashes/newlines in table names (no policy injection)', (t) => {
379+
const malicious = 'evil") ;\n\npermit(principal, action, resource);\n\n//';
380+
const result = generatePublicCedarPolicy(connectionId, [{ tableName: malicious }]);
381+
t.is(result.split('\n\n').length, 2);
382+
t.true(result.includes('\\"'));
383+
t.true(result.includes('\\n'));
384+
const actions = [...result.matchAll(/action\s*==\s*RocketAdmin::Action::"([^"]+)"/g)].map((m) => m[1]);
385+
t.deepEqual(new Set(actions), new Set(['table:query', 'column:read']));
386+
});
387+
388+
test('generatePublicCedarPolicy escapes quotes/backslashes/newlines in column names (no policy injection)', (t) => {
389+
const maliciousColumn = 'c") ;\npermit(principal, action, resource); //';
390+
const result = generatePublicCedarPolicy(connectionId, [{ tableName: 'users', readableColumns: [maliciousColumn] }]);
391+
t.is(result.split('\n\n').length, 2);
392+
t.true(result.includes('\\"'));
393+
const actions = [...result.matchAll(/action\s*==\s*RocketAdmin::Action::"([^"]+)"/g)].map((m) => m[1]);
394+
t.deepEqual(new Set(actions), new Set(['table:query', 'column:read']));
395+
});
396+
397+
test('generateCedarPolicyForGroup escapes malicious table names (no policy injection)', (t) => {
398+
const malicious = 'evil") ;\n\npermit(principal, action, resource);\n\n//';
399+
const result = generateCedarPolicyForGroup(
400+
connectionId,
401+
false,
402+
makePermissions({
403+
tables: [
404+
{
405+
tableName: malicious,
406+
accessLevel: {
407+
visibility: true,
408+
readonly: true,
409+
add: false,
410+
delete: false,
411+
edit: false,
412+
aiRequest: false,
413+
triggerCustomAction: false,
414+
},
415+
},
416+
],
417+
}),
418+
);
419+
t.is(result.split('\n\n').length, 2);
420+
t.true(result.includes('\\"'));
421+
const actions = [...result.matchAll(/action\s*==\s*RocketAdmin::Action::"([^"]+)"/g)].map((m) => m[1]);
422+
t.deepEqual(new Set(actions), new Set(['table:query', 'column:read']));
423+
});

0 commit comments

Comments
 (0)