feat(xo-server/rest-api): validate params

This commit is contained in:
Julien Fontanet 2024-01-24 11:41:22 +01:00
parent 8a7abc2e54
commit 4f383635ef
3 changed files with 122 additions and 86 deletions

View File

@ -0,0 +1,95 @@
import Ajv from 'ajv'
const ajv = new Ajv({ allErrors: true, allowUnionTypes: true, useDefaults: true })
function makeIsType({ type }) {
if (typeof type === 'string') {
return t => t === type
}
const types = new Set(type)
return t => types.has(t)
}
/**
* Transform an XO JSON schema to a standard JSON schema
*
* Differences of XO JSON schemas:
* - objects:
* - properties are required by default
* - properties can be marked as `optional` in place of listing them with `required`
* - additional properties disabled by default
* - a wildcard `*` property can be used in place of `additionalProperties`
* - strings must be non emtpy by default
*/
function xoToJsonSchema(schema) {
if (schema.enum !== undefined) {
return schema
}
const is = makeIsType(schema)
if (is('array')) {
const { items } = schema
if (items !== undefined) {
if (Array.isArray(items)) {
for (let i = 0, n = items.length; i < n; ++i) {
items[i] = xoToJsonSchema(items[i])
}
} else {
schema.items = xoToJsonSchema(items)
}
}
}
if (is('object')) {
const { properties = {} } = schema
let keys = Object.keys(properties)
for (const key of keys) {
properties[key] = xoToJsonSchema(properties[key])
}
const { additionalProperties } = schema
if (additionalProperties === undefined) {
const wildCard = properties['*']
if (wildCard === undefined) {
// we want additional properties to be disabled by default unless no properties are defined
schema.additionalProperties = keys.length === 0
} else {
delete properties['*']
keys = Object.keys(properties)
schema.additionalProperties = wildCard
}
} else if (typeof additionalProperties === 'object') {
schema.additionalProperties = xoToJsonSchema(additionalProperties)
}
// we want properties to be required by default unless explicitly marked so
// we use property `optional` instead of object `required`
if (schema.required === undefined) {
const required = keys.filter(key => {
const value = properties[key]
const required = !value.optional
delete value.optional
return required
})
if (required.length !== 0) {
schema.required = required
}
}
}
if (is('string')) {
// we want strings to be not empty by default
if (schema.minLength === undefined && schema.format === undefined && schema.pattern === undefined) {
schema.minLength = 1
}
}
return schema
}
export function compileXoJsonSchema(schema) {
return ajv.compile(xoToJsonSchema(schema))
}

View File

@ -1,7 +1,6 @@
import emitAsync from '@xen-orchestra/emit-async' import emitAsync from '@xen-orchestra/emit-async'
import { createLogger } from '@xen-orchestra/log' import { createLogger } from '@xen-orchestra/log'
import Ajv from 'ajv'
import cloneDeep from 'lodash/cloneDeep.js' import cloneDeep from 'lodash/cloneDeep.js'
import forEach from 'lodash/forEach.js' import forEach from 'lodash/forEach.js'
import kindOf from 'kindof' import kindOf from 'kindof'
@ -15,6 +14,7 @@ import Connection from '../connection.mjs'
import { noop, serializeError } from '../utils.mjs' import { noop, serializeError } from '../utils.mjs'
import * as errors from 'xo-common/api-errors.js' import * as errors from 'xo-common/api-errors.js'
import { compileXoJsonSchema } from './_xoJsonSchema.mjs'
// =================================================================== // ===================================================================
@ -56,8 +56,6 @@ const XAPI_ERROR_TO_XO_ERROR = {
const hasPermission = (actual, expected) => PERMISSIONS[actual] >= PERMISSIONS[expected] const hasPermission = (actual, expected) => PERMISSIONS[actual] >= PERMISSIONS[expected]
const ajv = new Ajv({ allErrors: true, allowUnionTypes: true, useDefaults: true })
function checkParams(method, params) { function checkParams(method, params) {
// Parameters suffixed by `?` are marked as ignorable by the client and // Parameters suffixed by `?` are marked as ignorable by the client and
// ignored if unsupported by this version of the API // ignored if unsupported by this version of the API
@ -117,80 +115,6 @@ function checkPermission(method) {
} }
} }
function adaptJsonSchema(schema) {
if (schema.enum !== undefined) {
return schema
}
const is = (({ type }) => {
if (typeof type === 'string') {
return t => t === type
}
const types = new Set(type)
return t => types.has(t)
})(schema)
if (is('array')) {
const { items } = schema
if (items !== undefined) {
if (Array.isArray(items)) {
for (let i = 0, n = items.length; i < n; ++i) {
items[i] = adaptJsonSchema(items[i])
}
} else {
schema.items = adaptJsonSchema(items)
}
}
}
if (is('object')) {
const { properties = {} } = schema
let keys = Object.keys(properties)
for (const key of keys) {
properties[key] = adaptJsonSchema(properties[key])
}
const { additionalProperties } = schema
if (additionalProperties === undefined) {
const wildCard = properties['*']
if (wildCard === undefined) {
// we want additional properties to be disabled by default unless no properties are defined
schema.additionalProperties = keys.length === 0
} else {
delete properties['*']
keys = Object.keys(properties)
schema.additionalProperties = wildCard
}
} else if (typeof additionalProperties === 'object') {
schema.additionalProperties = adaptJsonSchema(additionalProperties)
}
// we want properties to be required by default unless explicitly marked so
// we use property `optional` instead of object `required`
if (schema.required === undefined) {
const required = keys.filter(key => {
const value = properties[key]
const required = !value.optional
delete value.optional
return required
})
if (required.length !== 0) {
schema.required = required
}
}
}
if (is('string')) {
// we want strings to be not empty by default
if (schema.minLength === undefined && schema.format === undefined && schema.pattern === undefined) {
schema.minLength = 1
}
}
return schema
}
async function resolveParams(method, params) { async function resolveParams(method, params) {
const resolve = method.resolve const resolve = method.resolve
if (!resolve) { if (!resolve) {
@ -298,15 +222,12 @@ export default class Api {
let validate let validate
if (params !== undefined) { if (params !== undefined) {
let schema = { type: 'object', properties: cloneDeep(params) }
try { try {
schema = adaptJsonSchema(schema) validate = compileXoJsonSchema({ type: 'object', properties: cloneDeep(params) })
validate = ajv.compile(schema)
} catch (error) { } catch (error) {
log.warn('failed to compile method params schema', { log.warn('failed to compile method params schema', {
error, error,
method: name, method: name,
schema,
}) })
throw error throw error
} }

View File

@ -5,12 +5,14 @@ import { ifDef } from '@xen-orchestra/defined'
import { featureUnauthorized, invalidCredentials, noSuchObject } from 'xo-common/api-errors.js' import { featureUnauthorized, invalidCredentials, noSuchObject } from 'xo-common/api-errors.js'
import { pipeline } from 'node:stream/promises' import { pipeline } from 'node:stream/promises'
import { json, Router } from 'express' import { json, Router } from 'express'
import cloneDeep from 'lodash/cloneDeep.js'
import path from 'node:path' import path from 'node:path'
import pick from 'lodash/pick.js' import pick from 'lodash/pick.js'
import * as CM from 'complex-matcher' import * as CM from 'complex-matcher'
import { VDI_FORMAT_RAW, VDI_FORMAT_VHD } from '@xen-orchestra/xapi' import { VDI_FORMAT_RAW, VDI_FORMAT_VHD } from '@xen-orchestra/xapi'
import { getUserPublicProperties } from '../utils.mjs' import { getUserPublicProperties } from '../utils.mjs'
import { compileXoJsonSchema } from './_xoJsonSchema.mjs'
const { join } = path.posix const { join } = path.posix
const noop = Function.prototype const noop = Function.prototype
@ -227,6 +229,11 @@ export default class RestApi {
}, },
} }
const withParams = (fn, paramsSchema) => {
fn.validateParams = compileXoJsonSchema({ type: 'object', properties: cloneDeep(paramsSchema) })
return fn
}
collections.pools.actions = { collections.pools.actions = {
__proto__: null, __proto__: null,
@ -248,10 +255,13 @@ export default class RestApi {
clean_shutdown: ({ xapiObject: vm }) => vm.$callAsync('clean_shutdown').then(noop), clean_shutdown: ({ xapiObject: vm }) => vm.$callAsync('clean_shutdown').then(noop),
hard_reboot: ({ xapiObject: vm }) => vm.$callAsync('hard_reboot').then(noop), hard_reboot: ({ xapiObject: vm }) => vm.$callAsync('hard_reboot').then(noop),
hard_shutdown: ({ xapiObject: vm }) => vm.$callAsync('hard_shutdown').then(noop), hard_shutdown: ({ xapiObject: vm }) => vm.$callAsync('hard_shutdown').then(noop),
snapshot: async ({ xapiObject: vm }, { name_label }) => { snapshot: withParams(
const ref = await vm.$snapshot({ name_label }) async ({ xapiObject: vm }, { name_label }) => {
return vm.$xapi.getField('VM', ref, 'uuid') const ref = await vm.$snapshot({ name_label })
}, return vm.$xapi.getField('VM', ref, 'uuid')
},
{ name_label: { type: 'string', optional: true } }
),
start: ({ xapiObject: vm }) => vm.$callAsync('start', false, false).then(noop), start: ({ xapiObject: vm }) => vm.$callAsync('start', false, false).then(noop),
} }
@ -594,9 +604,19 @@ export default class RestApi {
return next() return next()
} }
const params = req.body
const { validateParams } = fn
if (validateParams !== undefined) {
if (!validateParams(params)) {
res.statusCode = 400
return res.json(validateParams.errors)
}
}
const { xapiObject, xoObject } = req const { xapiObject, xoObject } = req
const task = app.tasks.create({ name: `REST: ${action} ${req.collection.type}`, objectId: xoObject.id }) const task = app.tasks.create({ name: `REST: ${action} ${req.collection.type}`, objectId: xoObject.id })
const pResult = task.run(() => fn({ xapiObject, xoObject }, req.body)) const pResult = task.run(() => fn({ xapiObject, xoObject }, params))
if (Object.hasOwn(req.query, 'sync')) { if (Object.hasOwn(req.query, 'sync')) {
pResult.then(result => res.json(result), next) pResult.then(result => res.json(result), next)
} else { } else {