test(api): schema security (#50413)

* test: confirm all schemas pass basic validation

* refactor: use tested schemas in routes

* chore: move ajv to dev deps
This commit is contained in:
Oliver Eyton-Williams
2023-05-24 18:31:13 +02:00
committed by GitHub
parent 1fc0bccb6f
commit 39857b5aa4
7 changed files with 185 additions and 158 deletions
+1
View File
@@ -23,6 +23,7 @@
"@fastify/type-provider-typebox": "3.2.0",
"@types/express-session": "1.17.7",
"@types/supertest": "2.0.12",
"ajv": "8.12.0",
"dotenv-cli": "7.2.1",
"jest": "29.5.0",
"pino-pretty": "10.0.0",
+3 -16
View File
@@ -1,7 +1,5 @@
import {
FastifyPluginCallbackTypebox,
Type
} from '@fastify/type-provider-typebox';
import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
import { schemas } from '../schemas';
type Endpoints = [string, 'GET' | 'POST'][];
@@ -20,18 +18,7 @@ export const deprecatedEndpoints: FastifyPluginCallbackTypebox = (
fastify.route({
method,
url: endpoint,
schema: {
response: {
410: Type.Object({
message: Type.Object({
type: Type.Literal('info'),
message: Type.Literal(
'Please reload the app, this feature is no longer available.'
)
})
})
}
},
schema: schemas.deprecatedEndpoints,
handler: async (_req, reply) => {
void reply.status(410);
return {
+9 -105
View File
@@ -1,7 +1,6 @@
import {
Type,
type FastifyPluginCallbackTypebox
} from '@fastify/type-provider-typebox';
import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
import { schemas } from '../schemas';
export const settingRoutes: FastifyPluginCallbackTypebox = (
fastify,
@@ -17,32 +16,7 @@ export const settingRoutes: FastifyPluginCallbackTypebox = (
fastify.put(
'/update-my-profileui',
{
schema: {
body: Type.Object({
profileUI: Type.Object({
isLocked: Type.Boolean(),
showAbout: Type.Boolean(),
showCerts: Type.Boolean(),
showDonation: Type.Boolean(),
showHeatMap: Type.Boolean(),
showLocation: Type.Boolean(),
showName: Type.Boolean(),
showPoints: Type.Boolean(),
showPortfolio: Type.Boolean(),
showTimeLine: Type.Boolean()
})
}),
response: {
200: Type.Object({
message: Type.Literal('flash.privacy-updated'),
type: Type.Literal('success')
}),
500: Type.Object({
message: Type.Literal('flash.wrong-updating'),
type: Type.Literal('danger')
})
}
}
schema: schemas.updateMyProfileUI
},
async (req, reply) => {
try {
@@ -80,21 +54,7 @@ export const settingRoutes: FastifyPluginCallbackTypebox = (
fastify.put(
'/update-my-theme',
{
schema: {
body: Type.Object({
theme: Type.Union([Type.Literal('default'), Type.Literal('night')])
}),
response: {
200: Type.Object({
message: Type.Literal('flash.updated-themes'),
type: Type.Literal('success')
}),
500: Type.Object({
message: Type.Literal('flash.wrong-updating'),
type: Type.Literal('danger')
})
}
}
schema: schemas.updateMyTheme
},
async (req, reply) => {
try {
@@ -166,21 +126,7 @@ export const settingRoutes: FastifyPluginCallbackTypebox = (
fastify.put(
'/update-my-keyboard-shortcuts',
{
schema: {
body: Type.Object({
keyboardShortcuts: Type.Boolean()
}),
response: {
200: Type.Object({
message: Type.Literal('flash.keyboard-shortcut-updated'),
type: Type.Literal('success')
}),
500: Type.Object({
message: Type.Literal('flash.wrong-updating'),
type: Type.Literal('danger')
})
}
}
schema: schemas.updateMyKeyboardShortcuts
},
async (req, reply) => {
try {
@@ -206,21 +152,7 @@ export const settingRoutes: FastifyPluginCallbackTypebox = (
fastify.put(
'/update-my-quincy-email',
{
schema: {
body: Type.Object({
sendQuincyEmail: Type.Boolean()
}),
response: {
200: Type.Object({
message: Type.Literal('flash.subscribe-to-quincy-updated'),
type: Type.Literal('success')
}),
500: Type.Object({
message: Type.Literal('flash.wrong-updating'),
type: Type.Literal('danger')
})
}
}
schema: schemas.updateMyQuincyEmail
},
async (req, reply) => {
try {
@@ -246,21 +178,7 @@ export const settingRoutes: FastifyPluginCallbackTypebox = (
fastify.put(
'/update-my-honesty',
{
schema: {
body: Type.Object({
isHonest: Type.Literal(true)
}),
response: {
200: Type.Object({
message: Type.Literal('buttons.accepted-honesty'),
type: Type.Literal('success')
}),
500: Type.Object({
message: Type.Literal('flash.wrong-updating'),
type: Type.Literal('danger')
})
}
}
schema: schemas.updateMyHonesty
},
async (req, reply) => {
try {
@@ -286,21 +204,7 @@ export const settingRoutes: FastifyPluginCallbackTypebox = (
fastify.put(
'/update-privacy-terms',
{
schema: {
body: Type.Object({
quincyEmails: Type.Boolean()
}),
response: {
200: Type.Object({
message: Type.Literal('flash.privacy-updated'),
type: Type.Literal('success')
}),
500: Type.Object({
message: Type.Literal('flash.wrong-updating'),
type: Type.Literal('danger')
})
}
}
schema: schemas.updateMyPrivacyTerms
},
async (req, reply) => {
try {
+44
View File
@@ -0,0 +1,44 @@
import Ajv from 'ajv';
import secureSchema from 'ajv/lib/refs/json-schema-secure.json';
import { schemas } from './schemas';
// it's not strict, but that's okay - we're not using it to validate data
const ajv = new Ajv({ strictTypes: false });
const isSchemaSecure = ajv.compile(secureSchema);
describe('Schemas do not use obviously dangerous validation', () => {
Object.entries(schemas).forEach(([name, schema]) => {
describe(`schema ${name} is okay`, () => {
if ('body' in schema) {
test('body is secure', () => {
expect(isSchemaSecure(schema.body)).toBeTruthy();
});
}
if ('querystring' in schema) {
test('querystring is secure', () => {
expect(isSchemaSecure(schema.querystring)).toBeTruthy();
});
}
if ('params' in schema) {
test('params is secure', () => {
expect(isSchemaSecure(schema.params)).toBeTruthy();
});
}
if ('headers' in schema) {
test('headers is secure', () => {
expect(isSchemaSecure(schema.headers)).toBeTruthy();
});
}
Object.entries(schema.response).forEach(([code, codeSchema]) => {
test(`response ${code} is secure`, () => {
expect(isSchemaSecure(codeSchema)).toBeTruthy();
});
});
});
});
});
+119
View File
@@ -0,0 +1,119 @@
import { Type } from '@fastify/type-provider-typebox';
export const schemas = {
// Settings:
updateMyProfileUI: {
body: Type.Object({
profileUI: Type.Object({
isLocked: Type.Boolean(),
showAbout: Type.Boolean(),
showCerts: Type.Boolean(),
showDonation: Type.Boolean(),
showHeatMap: Type.Boolean(),
showLocation: Type.Boolean(),
showName: Type.Boolean(),
showPoints: Type.Boolean(),
showPortfolio: Type.Boolean(),
showTimeLine: Type.Boolean()
})
}),
response: {
200: Type.Object({
message: Type.Literal('flash.privacy-updated'),
type: Type.Literal('success')
}),
500: Type.Object({
message: Type.Literal('flash.wrong-updating'),
type: Type.Literal('danger')
})
}
},
updateMyTheme: {
body: Type.Object({
theme: Type.Union([Type.Literal('default'), Type.Literal('night')])
}),
response: {
200: Type.Object({
message: Type.Literal('flash.updated-themes'),
type: Type.Literal('success')
}),
500: Type.Object({
message: Type.Literal('flash.wrong-updating'),
type: Type.Literal('danger')
})
}
},
updateMyKeyboardShortcuts: {
body: Type.Object({
keyboardShortcuts: Type.Boolean()
}),
response: {
200: Type.Object({
message: Type.Literal('flash.keyboard-shortcut-updated'),
type: Type.Literal('success')
}),
500: Type.Object({
message: Type.Literal('flash.wrong-updating'),
type: Type.Literal('danger')
})
}
},
updateMyQuincyEmail: {
body: Type.Object({
sendQuincyEmail: Type.Boolean()
}),
response: {
200: Type.Object({
message: Type.Literal('flash.subscribe-to-quincy-updated'),
type: Type.Literal('success')
}),
500: Type.Object({
message: Type.Literal('flash.wrong-updating'),
type: Type.Literal('danger')
})
}
},
updateMyHonesty: {
body: Type.Object({
isHonest: Type.Literal(true)
}),
response: {
200: Type.Object({
message: Type.Literal('buttons.accepted-honesty'),
type: Type.Literal('success')
}),
500: Type.Object({
message: Type.Literal('flash.wrong-updating'),
type: Type.Literal('danger')
})
}
},
updateMyPrivacyTerms: {
body: Type.Object({
quincyEmails: Type.Boolean()
}),
response: {
200: Type.Object({
message: Type.Literal('flash.privacy-updated'),
type: Type.Literal('success')
}),
500: Type.Object({
message: Type.Literal('flash.wrong-updating'),
type: Type.Literal('danger')
})
}
},
// Deprecated endpoints:
deprecatedEndpoints: {
response: {
410: Type.Object({
message: Type.Object({
type: Type.Literal('info'),
message: Type.Literal(
'Please reload the app, this feature is no longer available.'
)
})
})
}
}
};
-31
View File
@@ -1,31 +0,0 @@
import { Static, Type } from '@fastify/type-provider-typebox';
// The schema that TypeBox generates is compatible with ajv, e.g. the
// Type.Object call below puts the following object into subSchema.
/*
{
type: 'object',
properties: {
bat: { type: 'number' },
baz: { type: 'string' }
},
required: ['bat', 'baz']
}
*/
export const subSchema = Type.Object({
bat: Type.Integer(),
baz: Type.String()
});
export const responseSchema = Type.Object({
value: Type.String(),
otherValue: Type.Boolean(),
optional: Type.Optional(Type.String())
});
// The schema types would be the only code the client needs to import
// { value: string; otherValue: boolean; optional?: string | undefined;}
export type ResponseSchema = Static<typeof responseSchema>;
// { bat: number; baz: string; }
export type SubSchema = Static<typeof subSchema>;
+9 -6
View File
@@ -213,6 +213,9 @@ importers:
'@types/supertest':
specifier: 2.0.12
version: 2.0.12
ajv:
specifier: 8.12.0
version: 8.12.0
dotenv-cli:
specifier: 7.2.1
version: 7.2.1
@@ -4056,7 +4059,7 @@ packages:
'@babel/core': ^7.0.0-0
dependencies:
'@babel/core': 7.10.5
'@babel/helper-plugin-utils': 7.21.5
'@babel/helper-plugin-utils': 7.20.2
/@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.12.9):
resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==}
@@ -4064,7 +4067,7 @@ packages:
'@babel/core': ^7.0.0-0
dependencies:
'@babel/core': 7.12.9
'@babel/helper-plugin-utils': 7.21.5
'@babel/helper-plugin-utils': 7.20.2
dev: true
/@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.18.0):
@@ -4082,7 +4085,7 @@ packages:
'@babel/core': ^7.0.0-0
dependencies:
'@babel/core': 7.20.12
'@babel/helper-plugin-utils': 7.21.5
'@babel/helper-plugin-utils': 7.20.2
/@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.21.8):
resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==}
@@ -4090,7 +4093,7 @@ packages:
'@babel/core': ^7.0.0-0
dependencies:
'@babel/core': 7.21.8
'@babel/helper-plugin-utils': 7.21.5
'@babel/helper-plugin-utils': 7.20.2
/@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.18.0):
resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==}
@@ -4132,7 +4135,7 @@ packages:
'@babel/core': ^7.0.0-0
dependencies:
'@babel/core': 7.20.12
'@babel/helper-plugin-utils': 7.21.5
'@babel/helper-plugin-utils': 7.20.2
/@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.21.8):
resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==}
@@ -4140,7 +4143,7 @@ packages:
'@babel/core': ^7.0.0-0
dependencies:
'@babel/core': 7.21.8
'@babel/helper-plugin-utils': 7.21.5
'@babel/helper-plugin-utils': 7.20.2
/@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.18.0):
resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==}