Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
0a212ae
Harden Project dependency graph and packages
andz-bb Aug 24, 2026
93d7a4a
Fix Project package dependency remapping
andz-bb Aug 25, 2026
6db414d
Keep Project package snapshots consistent
andz-bb Aug 25, 2026
d06c081
Tighten datasource environment binding detection
andz-bb Aug 25, 2026
8ac179f
Preserve entity-only Project datasource exports
andz-bb Aug 25, 2026
feea34b
Tighten Project resource reference matching
andz-bb Aug 25, 2026
5d48268
Remove uploaded Project archives after import
andz-bb Aug 25, 2026
1fb463c
Tighten environment secret redaction
andz-bb Aug 27, 2026
b820e79
Keep Project assignments on owning resources
andz-bb Aug 27, 2026
160d7ce
Keep Project ownership rules consistent
andz-bb Aug 27, 2026
6ecc62c
Validate Project package dependency indexes
andz-bb Aug 27, 2026
c3eaabc
fix environment reference detection
andz-bb Aug 27, 2026
bd23ec1
fix project package view and email portability
andz-bb Sep 5, 2026
959383a
simplify dependency discovery and include datasource references
andz-bb Sep 5, 2026
9776f91
preserve safely remapped email OAuth connections
andz-bb Sep 5, 2026
4aa6ec1
restrict project imports to development workspaces
andz-bb Sep 5, 2026
dba300f
trim redundant project deletion fixtures
andz-bb Sep 5, 2026
71cbf62
simplify project export resource collection
andz-bb Sep 5, 2026
8331047
trim redundant project test fixtures
andz-bb Sep 5, 2026
f9d9344
fix encryption test fixture types
andz-bb Sep 21, 2026
809edc3
fix project bindings and import cleanup
andz-bb Sep 21, 2026
e53de6c
fix email duplication test types
andz-bb Sep 21, 2026
3662ab4
cover project guard and bounded decryption cleanup
andz-bb Sep 22, 2026
6b5d790
test: strengthen project packaging assertions
andz-bb Sep 23, 2026
5aebbfd
preserve project fixes from earlier merge resolutions
andz-bb Sep 23, 2026
33003c7
standardise project helper spelling and reset lock test mocks
andz-bb Sep 23, 2026
422076f
simplify project packaging and clarify review tests
andz-bb Sep 24, 2026
ef67416
simplify project membership test fixtures
andz-bb Sep 24, 2026
28dc2bd
remove artificial project test fixtures and clarify import coverage
andz-bb Sep 24, 2026
a187ec1
fix project package limits and dependency references
andz-bb Oct 1, 2026
72f684f
preserve comments when remapping project bindings
andz-bb Oct 1, 2026
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
58 changes: 27 additions & 31 deletions packages/backend-core/src/security/encryption.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import crypto from "crypto"
import fs from "fs"
import { join } from "path"
import { Transform } from "stream"
import { pipeline } from "stream/promises"
import zlib from "zlib"
import env from "../environment"

Expand Down Expand Up @@ -149,10 +151,17 @@ async function getSaltAndIV(path: string) {
return { salt, iv }
}

export class DecryptionSizeLimitError extends Error {
constructor() {
super("Decrypted file exceeds the size limit.")
}
}

export async function decryptFile(
inputPath: string,
outputPath: string,
secret: string
secret: string,
{ maxOutputBytes = Infinity }: { maxOutputBytes?: number } = {}
) {
if (fs.lstatSync(inputPath).isDirectory()) {
throw new Error("Unable to decrypt directory")
Expand All @@ -171,36 +180,23 @@ export async function decryptFile(
new Uint8Array(iv)
)

const unzip = zlib.createGunzip()

inputFile.pipe(decipher).pipe(unzip).pipe(outputFile)

return new Promise<void>((res, rej) => {
outputFile.on("finish", () => {
outputFile.close()
res()
})

inputFile.on("error", e => {
outputFile.close()
rej(e)
})

decipher.on("error", e => {
outputFile.close()
rej(e)
})

unzip.on("error", e => {
outputFile.close()
rej(e)
})

outputFile.on("error", e => {
outputFile.close()
rej(e)
})
})
let outputBytes = 0
await pipeline(
inputFile,
decipher,
zlib.createGunzip(),
new Transform({
transform(chunk: Buffer, _encoding, callback) {
outputBytes += chunk.length
if (outputBytes > maxOutputBytes) {
callback(new DecryptionSizeLimitError())
return
}
callback(null, chunk)
},
}),
outputFile
)
}

function readBytes(stream: fs.ReadStream, length: number) {
Expand Down
53 changes: 52 additions & 1 deletion packages/backend-core/src/security/tests/encryption.spec.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import {
compare,
decrypt,
decryptFile,
encrypt,
encryptFile,
getSecret,
SecretOption,
} from "../encryption"
import env, { withEnv } from "../../environment"
import fsp from "fs/promises"
import { tmpdir } from "os"
import { join } from "path"

describe("encryption", () => {
it("should throw an error if API encryption key is not set", () => {
it("uses the JWT secret as the default API encryption key", () => {
const jwt = getSecret(SecretOption.API)
expect(jwt).toBe(env.JWT_SECRET?.export().toString())
})
Expand Down Expand Up @@ -46,3 +51,49 @@ describe("encryption", () => {
})
})
})

describe("file decryption", () => {
let dir: string
const content = "a".repeat(128 * 1024)
const password = "example-password"

beforeEach(async () => {
dir = await fsp.mkdtemp(join(tmpdir(), "file-decryption-"))
await fsp.writeFile(join(dir, "source"), content)
await encryptFile({ dir, filename: "source" }, password)
})

afterEach(async () => {
await fsp.rm(dir, { recursive: true, force: true })
})

it.each([undefined, content.length])(
"decrypts a file within its output budget (%s)",
async maxOutputBytes => {
const output = join(dir, "output")
await decryptFile(join(dir, "source.enc"), output, password, {
maxOutputBytes,
})

expect(await fsp.readFile(output, "utf8")).toEqual(content)
}
)

it("stops decompression before writing beyond the output budget", async () => {
const output = join(dir, "output")
const maxOutputBytes = 32 * 1024

await expect(
decryptFile(join(dir, "source.enc"), output, password, {
maxOutputBytes,
})
).rejects.toThrow("Decrypted file exceeds the size limit")

const outputFile = await fsp.stat(output).catch(error => {
if (error.code !== "ENOENT") {
throw error
}
})
expect(outputFile?.size ?? 0).toBeLessThanOrEqual(maxOutputBytes)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,10 @@
notifications,
Banner,
Divider,
Label,
Switcher,
} from "@budibase/bbui"
import {
BodyType,
FeatureFlag,
type Query,
type Datasource,
type ImportEndpoint,
Expand Down Expand Up @@ -87,14 +85,13 @@
import ExpandablePanel from "@/components/common/ExpandablePanel.svelte"
import ConnectionSelect from "./rest/ConnectionSelect.svelte"
import AccessLevelSelect from "@/components/integration/AccessLevelSelect.svelte"
import ProjectSelect from "@/components/common/ProjectSelect.svelte"
import { getErrorMessage } from "@/helpers/errors"
import { confirm } from "@/helpers"
import {
urlParamHighlightPlugin,
urlParamHighlightTheme,
} from "../common/CodeEditor/urlParamHighlight"
import { environment, featureFlags } from "@/stores/portal"
import { environment } from "@/stores/portal"
import { workspaceConnections } from "@/stores/builder/workspaceConnection"
import { onDestroy, onMount, createEventDispatcher } from "svelte"

Expand All @@ -109,11 +106,9 @@
export let connectionPopoverPortalTarget: string | undefined = undefined
export let connectionPopoverZIndex: number | undefined = undefined
export let openAddConnectionOnMount: boolean = false
export let initialProjectIds: string[] = []

$beforeUrlChange
$: goto = $gotoStore
$: projectsEnabled = $featureFlags[FeatureFlag.PROJECTS]

type EndpointWithIcon = ImportEndpoint & {
icon?: {
Expand Down Expand Up @@ -141,8 +136,6 @@
let response: PreviewQueryResponse
let panelMode: "response" | "request" = "response"
let editableQuery: Query | undefined
let projectIds: string[] = []
let originalProjectIds: string[] = []
let datasource: Datasource | UIInternalDatasource | undefined
let enabledHeaders: Record<string, boolean> = {}
let globalDynamicRequestBindings: EnrichedBinding[] = []
Expand Down Expand Up @@ -221,12 +214,6 @@

$: if (querySourceKey !== lastQuerySourceKey) {
editableQuery = structuredClone(storeQuery)
projectIds =
editableQuery?.projectIds ||
(!editableQuery?._id && initialProjectIds.length
? [...initialProjectIds]
: [])
originalProjectIds = [...projectIds]
lastQuerySourceKey = querySourceKey
queryParams = undefined
originalBuiltQuery = undefined
Expand Down Expand Up @@ -342,7 +329,6 @@
buildQuery(
{
...editableQuery,
projectIds: getQueryProjectIds(),
datasourceId: selectedDatasourceId || editableQuery.datasourceId,
fields: { ...editableQuery.fields, path: requestUrl },
},
Expand Down Expand Up @@ -452,13 +438,6 @@
ds: Datasource | UIInternalDatasource | undefined
): string | undefined => (ds as Datasource)?.config?.url as string | undefined

const getQueryProjectIds = () => {
if (projectIds.length) {
return projectIds
}
return !isNewQuery && originalProjectIds.length ? [] : undefined
}

const resolveStoreQuery = (
list: Query[] | undefined,
qId: string | undefined,
Expand Down Expand Up @@ -708,8 +687,6 @@
}

editableQuery = structuredClone(updatedQuery)
projectIds = updatedQuery.projectIds || []
originalProjectIds = [...projectIds]
originalBuiltQuery = undefined
localDynamicVariables = undefined

Expand Down Expand Up @@ -1079,12 +1056,6 @@
<div class="access">
<AccessLevelSelect query={editableQuery} label="Access" />
</div>
{#if projectsEnabled}
<div class="project">
<Label>Projects</Label>
<ProjectSelect bind:value={projectIds} label="" autoWidth />
</div>
{/if}
{/if}
{#if endpointDocs}
<ActionButton
Expand Down Expand Up @@ -1646,11 +1617,6 @@
align-items: center;
gap: var(--spacing-m);
}
.project {
display: flex;
align-items: center;
gap: var(--spacing-m);
}
.pagination {
display: grid;
grid-template-columns: 1fr 1fr;
Expand Down
9 changes: 4 additions & 5 deletions packages/server/src/ai/tools/budibase/automations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ const TRIGGER_AUTOMATION_BASE_DESCRIPTION =
const DEFAULT_FIELDS_DESCRIPTION =
"Fields map: key/value pairs. Values must be string, number, boolean, or array (no nested objects)."

export const getAutomationTriggerToolName = (automationId: string) =>
`${automationId.replace(/[^A-Za-z0-9_-]/g, "_")}_trigger`.substring(0, 64)

type AutomationFieldValue = string | number | boolean | unknown[]

const getAutomationFieldsSummary = (automation: Automation) => {
Expand Down Expand Up @@ -161,11 +164,7 @@ const createAutomationTools = (
)
.map((automation): BudibaseToolDefinition => {
const automationName = automation.name || automation._id!
const sanitizedAutomationId = automation._id!.replace(
/[^A-Za-z0-9_-]/g,
"_"
)
const toolName = `${sanitizedAutomationId}_trigger`.substring(0, 64)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Why is this changed in this PR?

const toolName = getAutomationTriggerToolName(automation._id!)
const fieldsSummary = getAutomationFieldsSummary(automation)
const fieldsDescription = fieldsSummary
? `${DEFAULT_FIELDS_DESCRIPTION} Available fields: ${fieldsSummary}.`
Expand Down
40 changes: 26 additions & 14 deletions packages/server/src/ai/tools/budibase/rows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -374,14 +374,36 @@ const getRequesterRedactedDescription = (action: string) =>
`${formatActionLabel(action)} on the configured resource. Resource metadata is restricted. Do not infer its schema or substitute another resource if this tool is denied.`

const buildCollisionSafeToolName = (tableId: string, action: string) => {
const sanitizedTableId = tableId.replace(/[^A-Za-z0-9_-]/g, "_")
const sanitisedTableId = tableId.replace(/[^A-Za-z0-9_-]/g, "_")
const tableIdHash = createHash("sha256")
.update(tableId)
.digest("hex")
.substring(0, TOOL_NAME_HASH_LENGTH)
const suffix = `_${tableIdHash}_${action}`
const tableIdLength = MAX_TOOL_NAME_LENGTH - suffix.length
return `${sanitizedTableId.substring(0, tableIdLength)}${suffix}`
return `${sanitisedTableId.substring(0, tableIdLength)}${suffix}`
}

export const getRowToolNames = (tableId: string): Record<string, string> => {
const sanitisedTableId = tableId.replace(/[^A-Za-z0-9_-]/g, "_")
const truncatedToolNames = Object.fromEntries(
Object.keys(ROW_TOOL).map(action => [
action,
`${sanitisedTableId}_${action}`.substring(0, MAX_TOOL_NAME_LENGTH),
])
)
const hasToolNameCollision =
new Set(Object.values(truncatedToolNames)).size !==
Object.keys(truncatedToolNames).length
if (!hasToolNameCollision) {
return truncatedToolNames
}
return Object.fromEntries(
Object.keys(ROW_TOOL).map(action => [
action,
buildCollisionSafeToolName(tableId, action),
])
)
}

export const createRowTools = ({
Expand Down Expand Up @@ -410,23 +432,13 @@ export const createRowTools = ({
const schemaSummary = buildSchemaSummary(writableFields)
const dataSchema = buildRowDataSchema(writableFields, schemaSummary)
const searchInputSchema = buildSearchInputSchema(schemaSummary)
const toolNames = getRowToolNames(tableId)
const fields = getAgentTableFields(tableSchema)
const sanitizedTableId = tableId.replace(/[^A-Za-z0-9_-]/g, "_")
const truncatedToolNames = new Map(
ROW_TOOL_ACTIONS.map(action => [
action,
`${sanitizedTableId}_${action}`.substring(0, MAX_TOOL_NAME_LENGTH),
])
)
const hasToolNameCollision =
new Set(truncatedToolNames.values()).size !== truncatedToolNames.size

return ROW_TOOL_ACTIONS.map(action => {
const def = ROW_TOOL[action]
const description = `${formatActionLabel(action)} in "${tableName}". ${def.description}`
const toolName = hasToolNameCollision
? buildCollisionSafeToolName(tableId, action)
: truncatedToolNames.get(action)!
const toolName = toolNames[action]
const isWrite =
action === ToolAction.CREATE_ROW || action === ToolAction.UPDATE_ROW
let inputSchema = def.inputSchema
Expand Down
Loading
Loading