Skip to content

Commit 822d645

Browse files
committed
merge latest master
2 parents 4dd3f2c + a28e254 commit 822d645

23 files changed

Lines changed: 1037 additions & 418 deletions

File tree

‎packages/backend-core/src/middleware/passport/sso/sso.ts‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,24 @@ export async function authenticate(
6969
}
7070

7171
let pendingInvite: InviteWithCode | undefined
72+
let blockedInvite: InviteWithCode | undefined
7273
if (!dbUser) {
7374
const invites = await cache.invite.getExistingInvites([details.email])
74-
pendingInvite = invites[0]
75+
if (details.emailVerified) {
76+
pendingInvite = invites[0]
77+
} else if (allowUnverifiedEmailLinking) {
78+
pendingInvite = invites.find(invite => !invite.info.admin?.global)
79+
}
80+
if (!pendingInvite) {
81+
blockedInvite = invites[0]
82+
}
83+
}
84+
85+
if (blockedInvite) {
86+
return authError(
87+
done,
88+
"Email verification is required to accept this invite."
89+
)
7590
}
7691

7792
const emailLookupWasSkipped =

‎packages/backend-core/src/middleware/passport/sso/tests/sso.spec.ts‎

Lines changed: 63 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ const getErrorMessage = () => {
5151
describe("sso", () => {
5252
describe("authenticate", () => {
5353
beforeEach(() => {
54-
jest.clearAllMocks()
54+
jest.resetAllMocks()
5555
testEnv.singleTenant()
5656
nock.cleanAll()
5757
mockInvite.getExistingInvites.mockResolvedValue([])
@@ -466,15 +466,25 @@ describe("sso", () => {
466466
mockInvite.deleteCode.mockResolvedValueOnce(undefined)
467467
})
468468

469-
it("reconciles the invite without requiring a verified email, deletes it, and fires the accepted event", async () => {
469+
it("rejects the login rather than creating an account when the email is unverified", async () => {
470+
await sso.authenticate(details, false, mockDone, mockSaveUser)
471+
472+
expect(mockSaveUser).not.toHaveBeenCalled()
473+
expect(mockInvite.deleteCode).not.toHaveBeenCalled()
474+
expect(events.user.inviteAccepted).not.toHaveBeenCalled()
475+
expect(mockDone.mock.calls.length).toBe(1)
476+
expect(getErrorMessage()).toContain(
477+
"Email verification is required to accept this invite."
478+
)
479+
})
480+
481+
it("reconciles the invite when the email is verified, deletes it, and fires the accepted event", async () => {
482+
details.emailVerified = true
470483
const ssoUser = structures.users.ssoUser({ details })
471484
mockSaveUser.mockReturnValueOnce(ssoUser)
472485

473486
await sso.authenticate(details, false, mockDone, mockSaveUser)
474487

475-
// the invite is matched purely on email - no account-linking lookup happens
476-
expect(users.getGlobalUserByEmail).not.toHaveBeenCalled()
477-
478488
expect(mockSaveUser).toHaveBeenCalledWith(
479489
expect.objectContaining({
480490
_id: "us_" + details.userId,
@@ -493,16 +503,61 @@ describe("sso", () => {
493503
expect(mockDone).toHaveBeenCalledWith(null, ssoUser)
494504
})
495505

496-
it("reconciles the invite even when a local account would otherwise be required", async () => {
506+
it("reconciles a non-admin invite when unverified email linking is explicitly allowed", async () => {
497507
const ssoUser = structures.users.ssoUser({ details })
498508
mockSaveUser.mockReturnValueOnce(ssoUser)
499509

500-
await sso.authenticate(details, true, mockDone, mockSaveUser)
510+
await sso.authenticate(details, true, mockDone, mockSaveUser, true)
511+
512+
expect(mockDone).toHaveBeenCalledWith(null, ssoUser)
513+
})
514+
515+
it("reconciles an eligible invite when an admin invite for the same email appears first", async () => {
516+
const adminInvite: InviteWithCode = {
517+
code: structures.uuid(),
518+
email: details.email!,
519+
info: {
520+
tenantId: context.getTenantId(),
521+
admin: { global: true },
522+
},
523+
}
524+
mockInvite.getExistingInvites.mockReset()
525+
mockInvite.getExistingInvites.mockResolvedValueOnce([
526+
adminInvite,
527+
invite,
528+
])
529+
const ssoUser = structures.users.ssoUser({ details })
530+
mockSaveUser.mockReturnValueOnce(ssoUser)
531+
532+
await sso.authenticate(details, true, mockDone, mockSaveUser, true)
501533

534+
expect(mockInvite.getCode).toHaveBeenCalledWith(
535+
invite.code,
536+
invite.info.tenantId
537+
)
538+
expect(mockInvite.deleteCode).toHaveBeenCalledWith(
539+
invite.code,
540+
invite.info.tenantId
541+
)
502542
expect(mockDone).toHaveBeenCalledWith(null, ssoUser)
503543
})
504544

545+
it("rejects an unverified login for an admin invite even when unverified email linking is allowed", async () => {
546+
invite.info.admin = { global: true }
547+
548+
await sso.authenticate(details, false, mockDone, mockSaveUser, true)
549+
550+
expect(mockSaveUser).not.toHaveBeenCalled()
551+
expect(mockInvite.deleteCode).not.toHaveBeenCalled()
552+
expect(events.user.inviteAccepted).not.toHaveBeenCalled()
553+
expect(mockDone.mock.calls.length).toBe(1)
554+
expect(getErrorMessage()).toContain(
555+
"Email verification is required to accept this invite."
556+
)
557+
})
558+
505559
it("reuses the account when the same identity's own concurrent login already claimed the invite", async () => {
560+
details.emailVerified = true
506561
// simulates a second, racing request for this exact identity
507562
// (e.g. a double-submitted login) losing the lock race: the
508563
// winner already consumed the invite and saved the account for
@@ -524,8 +579,6 @@ describe("sso", () => {
524579

525580
await sso.authenticate(details, false, mockDone, mockSaveUser)
526581

527-
// reused via the deterministic id, never by email match
528-
expect(users.getGlobalUserByEmail).not.toHaveBeenCalled()
529582
// the winner's account is reused - no second document is created
530583
expect(mockSaveUser).toHaveBeenCalledWith(
531584
expect.objectContaining({ _id: existingUser._id }),
@@ -537,6 +590,7 @@ describe("sso", () => {
537590
})
538591

539592
it("fails closed when the invite can no longer be validated and no concurrent claim for this identity can be confirmed", async () => {
593+
details.emailVerified = true
540594
// covers expired/revoked invites and failed reads alike - none of
541595
// these are a positively identified concurrent claim, so this must
542596
// not fall back to linking by email or creating a fresh account
@@ -548,7 +602,6 @@ describe("sso", () => {
548602

549603
await sso.authenticate(details, false, mockDone, mockSaveUser)
550604

551-
expect(users.getGlobalUserByEmail).not.toHaveBeenCalled()
552605
expect(mockSaveUser).not.toHaveBeenCalled()
553606
expect(mockInvite.deleteCode).not.toHaveBeenCalled()
554607
expect(events.user.inviteAccepted).not.toHaveBeenCalled()

‎packages/backend-core/src/sql/sqlTable.ts‎

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,18 @@ import { helpers, utils } from "@budibase/shared-core"
1616
import SchemaBuilder = Knex.SchemaBuilder
1717
import CreateTableBuilder = Knex.CreateTableBuilder
1818

19+
const quoteMySqlIdentifier = (identifier: string) => {
20+
return `\`${identifier.replace(/`/g, "``")}\``
21+
}
22+
23+
const quoteSqlServerIdentifier = (identifier: string) => {
24+
return `[${identifier.replace(/]/g, "]]")}]`
25+
}
26+
27+
const quoteSqlServerUnicodeString = (value: string) => {
28+
return `N'${value.replace(/'/g, "''")}'`
29+
}
30+
1931
function isIgnoredType(type: FieldType) {
2032
const ignored = [FieldType.LINK, FieldType.FORMULA, FieldType.AI]
2133
return ignored.indexOf(type) !== -1
@@ -272,10 +284,14 @@ class SqlTableQueryBuilder {
272284
if (this.sqlClient === SqlClient.MY_SQL && json.meta?.renamed) {
273285
const updatedColumn = json.meta.renamed.updated
274286
const tableName = json?.schema
275-
? `\`${json.schema}\`.\`${json.table.name}\``
276-
: `\`${json.table.name}\``
287+
? `${quoteMySqlIdentifier(json.schema)}.${quoteMySqlIdentifier(
288+
json.table.name
289+
)}`
290+
: quoteMySqlIdentifier(json.table.name)
277291
return {
278-
sql: `alter table ${tableName} rename column \`${json.meta.renamed.old}\` to \`${updatedColumn}\`;`,
292+
sql: `alter table ${tableName} rename column ${quoteMySqlIdentifier(
293+
json.meta.renamed.old
294+
)} to ${quoteMySqlIdentifier(updatedColumn)};`,
279295
bindings: [],
280296
}
281297
}
@@ -293,14 +309,22 @@ class SqlTableQueryBuilder {
293309
if (this.sqlClient === SqlClient.MS_SQL && json.meta?.renamed) {
294310
const oldColumn = json.meta.renamed.old
295311
const updatedColumn = json.meta.renamed.updated
296-
const tableName = json?.schema
297-
? `${json.schema}.${json.table.name}`
298-
: `${json.table.name}`
312+
const oldColumnName = [
313+
...(json.schema ? [json.schema] : []),
314+
json.table.name,
315+
oldColumn,
316+
]
317+
.map(quoteSqlServerIdentifier)
318+
.join(".")
299319
const sql = getNativeSql(query)
300320
if (Array.isArray(sql)) {
301321
for (const query of sql) {
302322
if (query.sql.startsWith("exec sp_rename")) {
303-
query.sql = `exec sp_rename '${tableName}.${oldColumn}', '${updatedColumn}', 'COLUMN'`
323+
query.sql = `exec sp_rename ${quoteSqlServerUnicodeString(
324+
oldColumnName
325+
)}, ${quoteSqlServerUnicodeString(
326+
updatedColumn
327+
)}, ${quoteSqlServerUnicodeString("COLUMN")}`
304328
query.bindings = []
305329
}
306330
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import SqlTable from "../sqlTable"
2+
import {
3+
FieldType,
4+
Operation,
5+
SqlClient,
6+
TableSourceType,
7+
} from "@budibase/types"
8+
import type { EnrichedQueryJson, SqlQuery, Table } from "@budibase/types"
9+
10+
const buildTable = ({
11+
name,
12+
column,
13+
}: {
14+
name: string
15+
column: string
16+
}): Table => ({
17+
_id: "tbl",
18+
type: "table",
19+
name,
20+
sourceId: "datasource",
21+
sourceType: TableSourceType.EXTERNAL,
22+
schema: {
23+
[column]: {
24+
name: column,
25+
type: FieldType.STRING,
26+
},
27+
},
28+
})
29+
30+
const buildRenameQuery = ({
31+
tableName,
32+
oldColumn,
33+
updatedColumn,
34+
schema,
35+
}: {
36+
tableName: string
37+
oldColumn: string
38+
updatedColumn: string
39+
schema?: string
40+
}): EnrichedQueryJson => {
41+
const oldTable = buildTable({ name: tableName, column: oldColumn })
42+
const table = buildTable({ name: tableName, column: updatedColumn })
43+
44+
return {
45+
operation: Operation.UPDATE_TABLE,
46+
table,
47+
tables: { [tableName]: table },
48+
schema,
49+
meta: {
50+
oldTable,
51+
renamed: {
52+
old: oldColumn,
53+
updated: updatedColumn,
54+
},
55+
},
56+
}
57+
}
58+
59+
describe("SQL table identifier escaping", () => {
60+
it("escapes MySQL rename identifiers", () => {
61+
const query = buildRenameQuery({
62+
schema: "schema`; DROP TABLE audit; --",
63+
tableName: "table`; DROP TABLE users; --",
64+
oldColumn: "old`; SELECT SLEEP(10); --",
65+
updatedColumn: "new`name",
66+
})
67+
68+
const result = new SqlTable(SqlClient.MY_SQL)._tableQuery(query) as SqlQuery
69+
70+
expect(result).toEqual({
71+
sql: "alter table `schema``; DROP TABLE audit; --`.`table``; DROP TABLE users; --` rename column `old``; SELECT SLEEP(10); --` to `new``name`;",
72+
bindings: [],
73+
})
74+
})
75+
76+
it("escapes SQL Server rename identifiers and string literals", () => {
77+
const query = buildRenameQuery({
78+
schema: "schema漢]",
79+
tableName: "table字'",
80+
oldColumn: "old名]'; DROP TABLE users; --",
81+
updatedColumn: "new列'name",
82+
})
83+
84+
const result = new SqlTable(SqlClient.MS_SQL)._tableQuery(query)
85+
const queries = result as SqlQuery[]
86+
const renameQuery = queries.find(query =>
87+
query.sql.startsWith("exec sp_rename")
88+
)
89+
90+
expect(renameQuery).toEqual({
91+
sql: "exec sp_rename N'[schema漢]]].[table字''].[old名]]''; DROP TABLE users; --]', N'new列''name', N'COLUMN'",
92+
bindings: [],
93+
})
94+
})
95+
})

‎packages/server/__mocks__/chat.ts‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ const mockWebhookState: Record<MockProvider, MockPostEphemeralResult> = {
2929
teams: defaultPostEphemeralResult(),
3030
}
3131
const mockChatOptions: ChatOptions[] = []
32+
const subscribedThreads = new Set<string>()
33+
let subscribeError: Error | undefined
3234

3335
const toMessageText = (value: unknown) =>
3436
typeof value === "string" ? value : JSON.stringify(value)
@@ -165,6 +167,12 @@ export const resetMockChatState = () => {
165167
mockWebhookState.slack = defaultPostEphemeralResult()
166168
mockWebhookState.teams = defaultPostEphemeralResult()
167169
mockChatOptions.length = 0
170+
subscribedThreads.clear()
171+
subscribeError = undefined
172+
}
173+
174+
export const setMockSubscribeError = (error?: Error) => {
175+
subscribeError = error
168176
}
169177

170178
export const setMockPostEphemeralResult = (
@@ -322,7 +330,12 @@ export class Chat {
322330
id: `slack:${channelId}:${threadTs}`,
323331
channelId,
324332
...createMessageCollector("slack", messages),
325-
subscribe: async () => {},
333+
subscribe: async () => {
334+
if (subscribeError) {
335+
throw subscribeError
336+
}
337+
subscribedThreads.add(`slack:${channelId}:${threadTs}`)
338+
},
326339
channel,
327340
}
328341
const isMention = isSlackMentionMessage(event)
@@ -342,7 +355,10 @@ export class Chat {
342355
} else {
343356
if (isMention) {
344357
await invokeHandlers(this.mentionHandlers, thread, message)
345-
} else if (event.thread_ts) {
358+
} else if (
359+
event.thread_ts &&
360+
subscribedThreads.has(`slack:${channelId}:${threadTs}`)
361+
) {
346362
await invokeHandlers(this.subscribedHandlers, thread, message)
347363
}
348364
await invokeHandlers(this.newMessageHandlers, thread, message)

‎packages/server/src/api/controllers/static/index.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,17 +82,30 @@ const MAX_PWA_ZIP_FILE_COUNT = 100
8282
const MAX_PWA_ZIP_ENTRY_SIZE = 10 * 1024 * 1024 // 10MB per file
8383
const MAX_PWA_ZIP_TOTAL_SIZE = 50 * 1024 * 1024 // 50MB uncompressed total
8484
const MAX_PWA_ZIP_DEPTH = 10
85+
// used in attribute checks for security remediation, see
86+
// https://github.com/Budibase/budibase/pull/19564
87+
const ZIP_FILE_TYPE_MASK = 0o170000
88+
const ZIP_SYMLINK_FILE_TYPE = 0o120000
8589

8690
const validatePWAZipEntries = () => {
8791
let fileCount = 0
8892
let totalUncompressedSize = 0
8993

90-
return (entry: { fileName: string; uncompressedSize: number }) => {
94+
return (entry: {
95+
fileName: string
96+
uncompressedSize: number
97+
externalFileAttributes: number
98+
}) => {
9199
// extract-zip skips these itself, so don't count them against the limits.
92100
if (entry.fileName.startsWith("__MACOSX/")) {
93101
return
94102
}
95103

104+
const fileType = (entry.externalFileAttributes >>> 16) & ZIP_FILE_TYPE_MASK
105+
if (fileType === ZIP_SYMLINK_FILE_TYPE) {
106+
throw new BadRequestError(`Invalid zip`)
107+
}
108+
96109
const depth =
97110
entry.fileName.split("/").filter(Boolean).length -
98111
(entry.fileName.endsWith("/") ? 0 : 1)

0 commit comments

Comments
 (0)