Merge pull request #12202 from Budibase/bug/budi-7689-cdn-issues-attachment-filtering
Limit cloud uploads to a specific set of filetypes.
This commit is contained in:
commit
29d8cfd04c
|
@ -159,8 +159,10 @@
|
||||||
{#if selectedImage.size}
|
{#if selectedImage.size}
|
||||||
<div class="filesize">
|
<div class="filesize">
|
||||||
{#if selectedImage.size <= BYTES_IN_MB}
|
{#if selectedImage.size <= BYTES_IN_MB}
|
||||||
{`${selectedImage.size / BYTES_IN_KB} KB`}
|
{`${(selectedImage.size / BYTES_IN_KB).toFixed(1)} KB`}
|
||||||
{:else}{`${selectedImage.size / BYTES_IN_MB} MB`}{/if}
|
{:else}{`${(selectedImage.size / BYTES_IN_MB).toFixed(
|
||||||
|
1
|
||||||
|
)} MB`}{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if !disabled}
|
{#if !disabled}
|
||||||
|
@ -203,8 +205,8 @@
|
||||||
{#if file.size}
|
{#if file.size}
|
||||||
<div class="filesize">
|
<div class="filesize">
|
||||||
{#if file.size <= BYTES_IN_MB}
|
{#if file.size <= BYTES_IN_MB}
|
||||||
{`${file.size / BYTES_IN_KB} KB`}
|
{`${(file.size / BYTES_IN_KB).toFixed(1)} KB`}
|
||||||
{:else}{`${file.size / BYTES_IN_MB} MB`}{/if}
|
{:else}{`${(file.size / BYTES_IN_MB).toFixed(1)} MB`}{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if !disabled}
|
{#if !disabled}
|
||||||
|
|
|
@ -23,7 +23,7 @@
|
||||||
try {
|
try {
|
||||||
return await API.uploadBuilderAttachment(data)
|
return await API.uploadBuilderAttachment(data)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notifications.error("Failed to upload attachment")
|
notifications.error(error.message || "Failed to upload attachment")
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -55,7 +55,7 @@
|
||||||
try {
|
try {
|
||||||
return await API.uploadBuilderAttachment(data)
|
return await API.uploadBuilderAttachment(data)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
$notifications.error("Failed to upload attachment")
|
$notifications.error(error.message || "Failed to upload attachment")
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { ValidFileExtensions } from "@budibase/shared-core"
|
||||||
|
|
||||||
require("svelte/register")
|
require("svelte/register")
|
||||||
|
|
||||||
import { join } from "../../../utilities/centralPath"
|
import { join } from "../../../utilities/centralPath"
|
||||||
|
@ -11,34 +13,21 @@ import {
|
||||||
} from "../../../utilities/fileSystem"
|
} from "../../../utilities/fileSystem"
|
||||||
import env from "../../../environment"
|
import env from "../../../environment"
|
||||||
import { DocumentType } from "../../../db/utils"
|
import { DocumentType } from "../../../db/utils"
|
||||||
import { context, objectStore, utils, configs } from "@budibase/backend-core"
|
import {
|
||||||
|
context,
|
||||||
|
objectStore,
|
||||||
|
utils,
|
||||||
|
configs,
|
||||||
|
BadRequestError,
|
||||||
|
} from "@budibase/backend-core"
|
||||||
import AWS from "aws-sdk"
|
import AWS from "aws-sdk"
|
||||||
import fs from "fs"
|
import fs from "fs"
|
||||||
import sdk from "../../../sdk"
|
import sdk from "../../../sdk"
|
||||||
import * as pro from "@budibase/pro"
|
import * as pro from "@budibase/pro"
|
||||||
import { App, Ctx } from "@budibase/types"
|
import { App, Ctx, ProcessAttachmentResponse, Upload } from "@budibase/types"
|
||||||
|
|
||||||
const send = require("koa-send")
|
const send = require("koa-send")
|
||||||
|
|
||||||
async function prepareUpload({ s3Key, bucket, metadata, file }: any) {
|
|
||||||
const response = await objectStore.upload({
|
|
||||||
bucket,
|
|
||||||
metadata,
|
|
||||||
filename: s3Key,
|
|
||||||
path: file.path,
|
|
||||||
type: file.type,
|
|
||||||
})
|
|
||||||
|
|
||||||
// don't store a URL, work this out on the way out as the URL could change
|
|
||||||
return {
|
|
||||||
size: file.size,
|
|
||||||
name: file.name,
|
|
||||||
url: objectStore.getAppFileUrl(s3Key),
|
|
||||||
extension: [...file.name.split(".")].pop(),
|
|
||||||
key: response.Key,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const toggleBetaUiFeature = async function (ctx: Ctx) {
|
export const toggleBetaUiFeature = async function (ctx: Ctx) {
|
||||||
const cookieName = `beta:${ctx.params.feature}`
|
const cookieName = `beta:${ctx.params.feature}`
|
||||||
|
|
||||||
|
@ -72,23 +61,58 @@ export const serveBuilder = async function (ctx: Ctx) {
|
||||||
await send(ctx, ctx.file, { root: builderPath })
|
await send(ctx, ctx.file, { root: builderPath })
|
||||||
}
|
}
|
||||||
|
|
||||||
export const uploadFile = async function (ctx: Ctx) {
|
export const uploadFile = async function (
|
||||||
|
ctx: Ctx<{}, ProcessAttachmentResponse>
|
||||||
|
) {
|
||||||
const file = ctx.request?.files?.file
|
const file = ctx.request?.files?.file
|
||||||
|
if (!file) {
|
||||||
|
throw new BadRequestError("No file provided")
|
||||||
|
}
|
||||||
|
|
||||||
let files = file && Array.isArray(file) ? Array.from(file) : [file]
|
let files = file && Array.isArray(file) ? Array.from(file) : [file]
|
||||||
|
|
||||||
const uploads = files.map(async (file: any) => {
|
ctx.body = await Promise.all(
|
||||||
const fileExtension = [...file.name.split(".")].pop()
|
files.map(async file => {
|
||||||
// filenames converted to UUIDs so they are unique
|
if (!file.name) {
|
||||||
const processedFileName = `${uuid.v4()}.${fileExtension}`
|
throw new BadRequestError(
|
||||||
|
"Attempted to upload a file without a filename"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return prepareUpload({
|
const extension = [...file.name.split(".")].pop()
|
||||||
file,
|
if (!extension) {
|
||||||
s3Key: `${context.getProdAppId()}/attachments/${processedFileName}`,
|
throw new BadRequestError(
|
||||||
bucket: ObjectStoreBuckets.APPS,
|
`File "${file.name}" has no extension, an extension is required to upload a file`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!env.SELF_HOSTED && !ValidFileExtensions.includes(extension)) {
|
||||||
|
throw new BadRequestError(
|
||||||
|
`File "${file.name}" has an invalid extension: "${extension}"`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// filenames converted to UUIDs so they are unique
|
||||||
|
const processedFileName = `${uuid.v4()}.${extension}`
|
||||||
|
|
||||||
|
const s3Key = `${context.getProdAppId()}/attachments/${processedFileName}`
|
||||||
|
|
||||||
|
const response = await objectStore.upload({
|
||||||
|
bucket: ObjectStoreBuckets.APPS,
|
||||||
|
filename: s3Key,
|
||||||
|
path: file.path,
|
||||||
|
type: file.type,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
size: file.size,
|
||||||
|
name: file.name,
|
||||||
|
url: objectStore.getAppFileUrl(s3Key),
|
||||||
|
extension,
|
||||||
|
key: response.Key,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
)
|
||||||
|
|
||||||
ctx.body = await Promise.all(uploads)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const deleteObjects = async function (ctx: Ctx) {
|
export const deleteObjects = async function (ctx: Ctx) {
|
||||||
|
|
|
@ -0,0 +1,49 @@
|
||||||
|
import * as setup from "./utilities"
|
||||||
|
import { APIError } from "@budibase/types"
|
||||||
|
|
||||||
|
describe("/api/applications/:appId/sync", () => {
|
||||||
|
let config = setup.getConfig()
|
||||||
|
|
||||||
|
afterAll(setup.afterAll)
|
||||||
|
beforeAll(async () => {
|
||||||
|
await config.init()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("/api/attachments/process", () => {
|
||||||
|
it("should accept an image file upload", async () => {
|
||||||
|
let resp = await config.api.attachment.process(
|
||||||
|
"1px.jpg",
|
||||||
|
Buffer.from([0])
|
||||||
|
)
|
||||||
|
expect(resp.length).toBe(1)
|
||||||
|
|
||||||
|
let upload = resp[0]
|
||||||
|
expect(upload.url.endsWith(".jpg")).toBe(true)
|
||||||
|
expect(upload.extension).toBe("jpg")
|
||||||
|
expect(upload.size).toBe(1)
|
||||||
|
expect(upload.name).toBe("1px.jpg")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should reject an upload with a malicious file extension", async () => {
|
||||||
|
await config.withEnv({ SELF_HOSTED: undefined }, async () => {
|
||||||
|
let resp = (await config.api.attachment.process(
|
||||||
|
"ohno.exe",
|
||||||
|
Buffer.from([0]),
|
||||||
|
{ expectStatus: 400 }
|
||||||
|
)) as unknown as APIError
|
||||||
|
expect(resp.message).toContain("invalid extension")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should reject an upload with no file", async () => {
|
||||||
|
let resp = (await config.api.attachment.process(
|
||||||
|
undefined as any,
|
||||||
|
undefined as any,
|
||||||
|
{
|
||||||
|
expectStatus: 400,
|
||||||
|
}
|
||||||
|
)) as unknown as APIError
|
||||||
|
expect(resp.message).toContain("No file provided")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -5,11 +5,15 @@ describe("/static", () => {
|
||||||
let request = setup.getRequest()
|
let request = setup.getRequest()
|
||||||
let config = setup.getConfig()
|
let config = setup.getConfig()
|
||||||
let app
|
let app
|
||||||
|
let cleanupEnv
|
||||||
|
|
||||||
afterAll(setup.afterAll)
|
afterAll(() => {
|
||||||
|
setup.afterAll()
|
||||||
|
cleanupEnv()
|
||||||
|
})
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
config.modeSelf()
|
cleanupEnv = config.setEnv({ SELF_HOSTED: "true" })
|
||||||
app = await config.init()
|
app = await config.init()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -8,11 +8,15 @@ describe("/webhooks", () => {
|
||||||
let request = setup.getRequest()
|
let request = setup.getRequest()
|
||||||
let config = setup.getConfig()
|
let config = setup.getConfig()
|
||||||
let webhook: Webhook
|
let webhook: Webhook
|
||||||
|
let cleanupEnv: () => void
|
||||||
|
|
||||||
afterAll(setup.afterAll)
|
afterAll(() => {
|
||||||
|
setup.afterAll()
|
||||||
|
cleanupEnv()
|
||||||
|
})
|
||||||
|
|
||||||
const setupTest = async () => {
|
const setupTest = async () => {
|
||||||
config.modeSelf()
|
cleanupEnv = config.setEnv({ SELF_HOSTED: "true" })
|
||||||
await config.init()
|
await config.init()
|
||||||
const autoConfig = basicAutomation()
|
const autoConfig = basicAutomation()
|
||||||
autoConfig.definition.trigger.schema = {
|
autoConfig.definition.trigger.schema = {
|
||||||
|
|
|
@ -35,13 +35,18 @@ import { FieldType, Table, TableSchema } from "@budibase/types"
|
||||||
describe("Google Sheets Integration", () => {
|
describe("Google Sheets Integration", () => {
|
||||||
let integration: any,
|
let integration: any,
|
||||||
config = new TestConfiguration()
|
config = new TestConfiguration()
|
||||||
|
let cleanupEnv: () => void
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
config.setGoogleAuth("test")
|
cleanupEnv = config.setEnv({
|
||||||
|
GOOGLE_CLIENT_ID: "test",
|
||||||
|
GOOGLE_CLIENT_SECRET: "test",
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await config.end()
|
cleanupEnv()
|
||||||
|
config.end()
|
||||||
})
|
})
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
|
|
@ -58,6 +58,7 @@ import {
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
|
|
||||||
import API from "./api"
|
import API from "./api"
|
||||||
|
import { cloneDeep } from "lodash"
|
||||||
|
|
||||||
type DefaultUserValues = {
|
type DefaultUserValues = {
|
||||||
globalUserId: string
|
globalUserId: string
|
||||||
|
@ -188,30 +189,38 @@ class TestConfiguration {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MODES
|
async withEnv(newEnvVars: Partial<typeof env>, f: () => Promise<void>) {
|
||||||
setMultiTenancy = (value: boolean) => {
|
let cleanup = this.setEnv(newEnvVars)
|
||||||
env._set("MULTI_TENANCY", value)
|
try {
|
||||||
coreEnv._set("MULTI_TENANCY", value)
|
await f()
|
||||||
|
} finally {
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setSelfHosted = (value: boolean) => {
|
/*
|
||||||
env._set("SELF_HOSTED", value)
|
* Sets the environment variables to the given values and returns a function
|
||||||
coreEnv._set("SELF_HOSTED", value)
|
* that can be called to reset the environment variables to their original values.
|
||||||
}
|
*/
|
||||||
|
setEnv(newEnvVars: Partial<typeof env>): () => void {
|
||||||
|
const oldEnv = cloneDeep(env)
|
||||||
|
const oldCoreEnv = cloneDeep(coreEnv)
|
||||||
|
|
||||||
setGoogleAuth = (value: string) => {
|
let key: keyof typeof newEnvVars
|
||||||
env._set("GOOGLE_CLIENT_ID", value)
|
for (key in newEnvVars) {
|
||||||
env._set("GOOGLE_CLIENT_SECRET", value)
|
env._set(key, newEnvVars[key])
|
||||||
coreEnv._set("GOOGLE_CLIENT_ID", value)
|
coreEnv._set(key, newEnvVars[key])
|
||||||
coreEnv._set("GOOGLE_CLIENT_SECRET", value)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
modeCloud = () => {
|
return () => {
|
||||||
this.setSelfHosted(false)
|
for (const [key, value] of Object.entries(oldEnv)) {
|
||||||
}
|
env._set(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
modeSelf = () => {
|
for (const [key, value] of Object.entries(oldCoreEnv)) {
|
||||||
this.setSelfHosted(true)
|
coreEnv._set(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// UTILS
|
// UTILS
|
||||||
|
|
|
@ -0,0 +1,35 @@
|
||||||
|
import {
|
||||||
|
APIError,
|
||||||
|
Datasource,
|
||||||
|
ProcessAttachmentResponse,
|
||||||
|
} from "@budibase/types"
|
||||||
|
import TestConfiguration from "../TestConfiguration"
|
||||||
|
import { TestAPI } from "./base"
|
||||||
|
import fs from "fs"
|
||||||
|
|
||||||
|
export class AttachmentAPI extends TestAPI {
|
||||||
|
constructor(config: TestConfiguration) {
|
||||||
|
super(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
process = async (
|
||||||
|
name: string,
|
||||||
|
file: Buffer | fs.ReadStream | string,
|
||||||
|
{ expectStatus } = { expectStatus: 200 }
|
||||||
|
): Promise<ProcessAttachmentResponse> => {
|
||||||
|
const result = await this.request
|
||||||
|
.post(`/api/attachments/process`)
|
||||||
|
.attach("file", file, name)
|
||||||
|
.set(this.config.defaultHeaders())
|
||||||
|
|
||||||
|
if (result.statusCode !== expectStatus) {
|
||||||
|
throw new Error(
|
||||||
|
`Expected status ${expectStatus} but got ${
|
||||||
|
result.statusCode
|
||||||
|
}, body: ${JSON.stringify(result.body)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.body
|
||||||
|
}
|
||||||
|
}
|
|
@ -7,6 +7,7 @@ import { DatasourceAPI } from "./datasource"
|
||||||
import { LegacyViewAPI } from "./legacyView"
|
import { LegacyViewAPI } from "./legacyView"
|
||||||
import { ScreenAPI } from "./screen"
|
import { ScreenAPI } from "./screen"
|
||||||
import { ApplicationAPI } from "./application"
|
import { ApplicationAPI } from "./application"
|
||||||
|
import { AttachmentAPI } from "./attachment"
|
||||||
|
|
||||||
export default class API {
|
export default class API {
|
||||||
table: TableAPI
|
table: TableAPI
|
||||||
|
@ -17,6 +18,7 @@ export default class API {
|
||||||
datasource: DatasourceAPI
|
datasource: DatasourceAPI
|
||||||
screen: ScreenAPI
|
screen: ScreenAPI
|
||||||
application: ApplicationAPI
|
application: ApplicationAPI
|
||||||
|
attachment: AttachmentAPI
|
||||||
|
|
||||||
constructor(config: TestConfiguration) {
|
constructor(config: TestConfiguration) {
|
||||||
this.table = new TableAPI(config)
|
this.table = new TableAPI(config)
|
||||||
|
@ -27,5 +29,6 @@ export default class API {
|
||||||
this.datasource = new DatasourceAPI(config)
|
this.datasource = new DatasourceAPI(config)
|
||||||
this.screen = new ScreenAPI(config)
|
this.screen = new ScreenAPI(config)
|
||||||
this.application = new ApplicationAPI(config)
|
this.application = new ApplicationAPI(config)
|
||||||
|
this.attachment = new AttachmentAPI(config)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -96,3 +96,45 @@ export enum BuilderSocketEvent {
|
||||||
export const SocketSessionTTL = 60
|
export const SocketSessionTTL = 60
|
||||||
export const ValidQueryNameRegex = /^[^()]*$/
|
export const ValidQueryNameRegex = /^[^()]*$/
|
||||||
export const ValidColumnNameRegex = /^[_a-zA-Z0-9\s]*$/g
|
export const ValidColumnNameRegex = /^[_a-zA-Z0-9\s]*$/g
|
||||||
|
export const ValidFileExtensions = [
|
||||||
|
"avif",
|
||||||
|
"css",
|
||||||
|
"csv",
|
||||||
|
"docx",
|
||||||
|
"drawio",
|
||||||
|
"editorconfig",
|
||||||
|
"edl",
|
||||||
|
"enc",
|
||||||
|
"export",
|
||||||
|
"geojson",
|
||||||
|
"gif",
|
||||||
|
"htm",
|
||||||
|
"html",
|
||||||
|
"ics",
|
||||||
|
"iqy",
|
||||||
|
"jfif",
|
||||||
|
"jpeg",
|
||||||
|
"jpg",
|
||||||
|
"json",
|
||||||
|
"log",
|
||||||
|
"md",
|
||||||
|
"mid",
|
||||||
|
"odt",
|
||||||
|
"pdf",
|
||||||
|
"png",
|
||||||
|
"ris",
|
||||||
|
"rtf",
|
||||||
|
"svg",
|
||||||
|
"tex",
|
||||||
|
"toml",
|
||||||
|
"twig",
|
||||||
|
"txt",
|
||||||
|
"url",
|
||||||
|
"wav",
|
||||||
|
"webp",
|
||||||
|
"xls",
|
||||||
|
"xlsx",
|
||||||
|
"xml",
|
||||||
|
"yaml",
|
||||||
|
"yml",
|
||||||
|
]
|
||||||
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
export interface Upload {
|
||||||
|
size: number
|
||||||
|
name: string
|
||||||
|
url: string
|
||||||
|
extension: string
|
||||||
|
key: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProcessAttachmentResponse = Upload[]
|
|
@ -5,3 +5,4 @@ export * from "./view"
|
||||||
export * from "./rows"
|
export * from "./rows"
|
||||||
export * from "./table"
|
export * from "./table"
|
||||||
export * from "./permission"
|
export * from "./permission"
|
||||||
|
export * from "./attachment"
|
||||||
|
|
Loading…
Reference in New Issue