From 5d42894ad8e81f80f20d6306b58efdca0d803b7d Mon Sep 17 00:00:00 2001 From: Roman Malenko Date: Tue, 8 Sep 2026 16:22:30 +0300 Subject: [PATCH 1/3] fix: reject raw SQL filters and require list permission in get_filtered_ids --- custom/BulkActionButton.vue | 11 ++++++++--- index.ts | 20 +++++++++++++++++++- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/custom/BulkActionButton.vue b/custom/BulkActionButton.vue index 583e612..5092403 100644 --- a/custom/BulkActionButton.vue +++ b/custom/BulkActionButton.vue @@ -134,9 +134,13 @@ async function runTranslation() { isLoading.value = true; - let listOfIds = []; + let listOfIds = []; if (props.checkboxes.length === 0) { listOfIds = await getListOfIds(); + if (listOfIds === null) { + isLoading.value = false; + return; + } } else { listOfIds = props.checkboxes; } @@ -160,7 +164,7 @@ jobInfoStore.openJobInfoPopup(jobId); } } else { - adminforth.alert({ message: res.errorMessage || t('Failed to translate selected items. Please, try again.'), variant: 'danger' }); + adminforth.alert({ message: res.error || t('Failed to translate selected items. Please, try again.'), variant: 'danger' }); } } catch (e) { console.error('Failed to translate selected items:', e); @@ -185,7 +189,8 @@ } if (!res?.ok || !res?.recordIds) { console.error('Failed to get records for filtered selector, response error:', res); - return []; + adminforth.alert({ message: res?.error || t('Failed to translate selected items. Please, try again.'), variant: 'danger' }); + return null; } return res.recordIds; } diff --git a/index.ts b/index.ts index f435611..27f9ffd 100644 --- a/index.ts +++ b/index.ts @@ -1,4 +1,4 @@ -import AdminForth, { AdminForthPlugin, Filters, suggestIfTypo, AdminForthDataTypes, RAMLock, filtersTools, AdminForthFilterOperators } from "adminforth"; +import AdminForth, { AdminForthPlugin, Filters, suggestIfTypo, AdminForthDataTypes, RAMLock, filtersTools, AdminForthFilterOperators, rejectApiRawFilters, interpretResource, ActionCheckSource, AllowedActionsEnum } from "adminforth"; import type { IAdminForth, IHttpServer, AdminForthComponentDeclaration, AdminForthResourceColumn, AdminForthResource, BeforeLoginConfirmationFunction, AdminForthConfigMenuItem, AdminUser } from "adminforth"; import type { PluginOptions, SupportedLanguage } from './types.js'; import { z } from "zod"; @@ -1393,6 +1393,24 @@ export default class I18nPlugin extends AdminForthPlugin { handler: async ({ body, adminUser, headers, query, cookies, requestUrl, response }) => { const resource = this.resourceConfig; + // before the permission rules and the hooks: they may add raw SQL server-side, the client must not + const rawFilterError = rejectApiRawFilters(body.filters); + if (rawFilterError) { + return rawFilterError; + } + + const { allowedActions } = await interpretResource( + adminUser, + resource, + { requestBody: body, pk: undefined }, + ActionCheckSource.ListRequest, + this.adminforth, + ); + const listAllowed = allowedActions[AllowedActionsEnum.list] as boolean | string | undefined; + if (listAllowed !== true) { + return { error: typeof listAllowed === 'string' ? listAllowed : 'You are not allowed to list records in this resource' }; + } + for (const hook of resource.hooks?.list?.beforeDatasourceRequest || []) { const filterTools = filtersTools.get(body); body.filtersTools = filterTools; From 40678b5821d8ff28b2b11b2028ad49598265a5a4 Mon Sep 17 00:00:00 2001 From: Roman Malenko Date: Tue, 8 Sep 2026 18:19:34 +0300 Subject: [PATCH 2/3] fix: require edit permission and limit update-field to translation columns --- index.ts | 67 +++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 59 insertions(+), 8 deletions(-) diff --git a/index.ts b/index.ts index 27f9ffd..1f19568 100644 --- a/index.ts +++ b/index.ts @@ -134,6 +134,13 @@ class AiTranslateError extends Error { this.name = 'AiTranslateError'; } } +class TranslateAccessError extends Error { + constructor(message: string) { + super(message); + this.name = 'TranslateAccessError'; + } +} + export default class I18nPlugin extends AdminForthPlugin { options: PluginOptions; emailField: AdminForthResourceColumn; @@ -864,6 +871,25 @@ export default class I18nPlugin extends AdminForthPlugin { > = {}; const translations = await this.adminforth.resource(this.resourceConfig.resourceId).list(Filters.IN(this.primaryKeyFieldName, selectedIds)); + const assertEditAllowed = async (translation?: any) => { + const { allowedActions } = await interpretResource( + adminUser, + this.resourceConfig, + { requestBody: { selectedIds, selectedLanguages }, newRecord: {}, oldRecord: translation, pk: translation?.[this.primaryKeyFieldName] }, + ActionCheckSource.EditRequest, + this.adminforth, + ); + const editAllowed = allowedActions[AllowedActionsEnum.edit] as boolean | string | undefined; + if (editAllowed !== true) { + throw new TranslateAccessError(typeof editAllowed === 'string' ? editAllowed : 'You are not allowed to edit records in this resource'); + } + }; + if (typeof this.resourceConfig.options?.allowedActions?.edit === 'function') { + const limit = pLimit(10); + await Promise.all(translations.map((translation) => limit(() => assertEditAllowed(translation)))); + } else { + await assertEditAllowed(); + } const languagesToProcess = selectedLanguages || this.options.supportedLanguages; for (const lang of languagesToProcess) { if (lang === 'en') { @@ -1311,6 +1337,9 @@ export default class I18nPlugin extends AdminForthPlugin { if (recordId === undefined || recordId === null) { return { error: 'No recordId provided' }; } + if (!Object.values(this.trFieldNames).includes(field)) { + return { error: `Field "${field}" is not a translation field` }; + } const resource = this.adminforth.config.resources.find(r => r.resourceId === resourceId); // Create update object with just the single field const updateRecord = { [field]: value }; @@ -1340,6 +1369,19 @@ export default class I18nPlugin extends AdminForthPlugin { updateRecord[this.options.reviewedCheckboxesFieldName] = { ...oldValue }; } + const { allowedActions } = await interpretResource( + adminUser, + resource, + { requestBody: body, newRecord: updateRecord, oldRecord, pk: recordId }, + ActionCheckSource.EditRequest, + this.adminforth, + ); + const editAllowed = allowedActions[AllowedActionsEnum.edit] as boolean | string | undefined; + if (editAllowed !== true) { + result = { error: typeof editAllowed === 'string' ? editAllowed : 'You are not allowed to edit records in this resource' }; + return; + } + result = await this.adminforth.updateResourceRecord({ resource, recordId, @@ -1354,8 +1396,9 @@ export default class I18nPlugin extends AdminForthPlugin { } const updatedRecord = await connector.getRecordByPrimaryKey(resource, recordId as string); + const visibleFields = [this.primaryKeyFieldName, ...Object.values(this.trFieldNames), this.options.reviewedCheckboxesFieldName].filter(Boolean); - return { record: updatedRecord }; + return { record: Object.fromEntries(visibleFields.map((name) => [name, updatedRecord?.[name]])) }; } }); @@ -1373,14 +1416,22 @@ export default class I18nPlugin extends AdminForthPlugin { return { ok: false, error: 'No records selected' }; } - const jobId = await this.bulkTranslate({ - selectedIds: selectedIds as string[], - selectedLanguages: selectedLanguages as SupportedLanguage[] | undefined, - adminUser, - }); + let jobId: string; + try { + jobId = await this.bulkTranslate({ + selectedIds: selectedIds as string[], + selectedLanguages: selectedLanguages as SupportedLanguage[] | undefined, + adminUser, + }); + } catch (e) { + if (e instanceof TranslateAccessError) { + return { ok: false, error: e.message }; + } + throw e; + } - return { - ok: true, + return { + ok: true, jobId: jobId, }; } From bc1435ca13ce74c7deb4bf92859d45c43bd485ae Mon Sep 17 00:00:00 2001 From: Roman Malenko Date: Wed, 9 Sep 2026 13:51:54 +0300 Subject: [PATCH 3/3] fix: drop backendOnly and undeclared columns from the update-field response --- index.ts | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/index.ts b/index.ts index 1f19568..b0567ac 100644 --- a/index.ts +++ b/index.ts @@ -1396,7 +1396,26 @@ export default class I18nPlugin extends AdminForthPlugin { } const updatedRecord = await connector.getRecordByPrimaryKey(resource, recordId as string); - const visibleFields = [this.primaryKeyFieldName, ...Object.values(this.trFieldNames), this.options.reviewedCheckboxesFieldName].filter(Boolean); + // core deletes backendOnly and undeclared columns from every record it returns; do the same here + const columnCtx = { + adminUser, + resource, + meta: { requestBody: body, pk: recordId }, + source: ActionCheckSource.EditLoadRequest, + adminforth: this.adminforth, + }; + const candidates = [this.primaryKeyFieldName, ...Object.values(this.trFieldNames), this.options.reviewedCheckboxesFieldName].filter(Boolean); + const visibleFields: string[] = []; + for (const name of candidates) { + const column = resource.columns.find((c) => c.name === name); + if (!column) { + continue; + } + const backendOnly = typeof column.backendOnly === 'function' ? await column.backendOnly(columnCtx) : column.backendOnly; + if (!backendOnly) { + visibleFields.push(name as string); + } + } return { record: Object.fromEntries(visibleFields.map((name) => [name, updatedRecord?.[name]])) }; }