stricter image checking

This commit is contained in:
Evert Prants 2024-06-09 12:00:15 +03:00
parent 46351fb17d
commit d8f6d24511
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
5 changed files with 61 additions and 10 deletions

29
package-lock.json generated
View File

@ -13,6 +13,7 @@
"cropperjs": "^1.6.2", "cropperjs": "^1.6.2",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"drizzle-orm": "^0.30.10", "drizzle-orm": "^0.30.10",
"image-size": "^1.1.1",
"jose": "^5.3.0", "jose": "^5.3.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"mysql2": "^3.9.7", "mysql2": "^3.9.7",
@ -3622,6 +3623,20 @@
"node": ">= 4" "node": ">= 4"
} }
}, },
"node_modules/image-size": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/image-size/-/image-size-1.1.1.tgz",
"integrity": "sha512-541xKlUw6jr/6gGuk92F+mYM5zaFAc5ahphvkqvNe2bQ6gVBkd6bfrmVJ2t4KDAfikAYZyIqTnktX3i6/aQDrQ==",
"dependencies": {
"queue": "6.0.2"
},
"bin": {
"image-size": "bin/image-size.js"
},
"engines": {
"node": ">=16.x"
}
},
"node_modules/import-fresh": { "node_modules/import-fresh": {
"version": "3.3.0", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
@ -4075,9 +4090,9 @@
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
}, },
"node_modules/mysql2": { "node_modules/mysql2": {
"version": "3.9.7", "version": "3.10.0",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.9.7.tgz", "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.10.0.tgz",
"integrity": "sha512-KnJT8vYRcNAZv73uf9zpXqNbvBG7DJrs+1nACsjZP1HMJ1TgXEy8wnNilXAn/5i57JizXKtrUtwDB7HxT9DDpw==", "integrity": "sha512-qx0mfWYt1DpTPkw8mAcHW/OwqqyNqBLBHvY5IjN8+icIYTjt6znrgYJ+gxqNNRpVknb5Wc/gcCM4XjbCR0j5tw==",
"dependencies": { "dependencies": {
"denque": "^2.1.0", "denque": "^2.1.0",
"generate-function": "^2.3.1", "generate-function": "^2.3.1",
@ -4487,6 +4502,14 @@
"node": ">=10.13.0" "node": ">=10.13.0"
} }
}, },
"node_modules/queue": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
"dependencies": {
"inherits": "~2.0.3"
}
},
"node_modules/queue-microtask": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",

View File

@ -43,6 +43,7 @@
"cropperjs": "^1.6.2", "cropperjs": "^1.6.2",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"drizzle-orm": "^0.30.10", "drizzle-orm": "^0.30.10",
"image-size": "^1.1.1",
"jose": "^5.3.0", "jose": "^5.3.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"mysql2": "^3.9.7", "mysql2": "^3.9.7",

View File

@ -7,7 +7,7 @@
import 'cropperjs/dist/cropper.css'; import 'cropperjs/dist/cropper.css';
import { invalidateAll } from '$app/navigation'; import { invalidateAll } from '$app/navigation';
import FormControl from '../form/FormControl.svelte'; import FormControl from '../form/FormControl.svelte';
import { allowedImages } from '$lib/constants'; import { ALLOWED_IMAGES } from '$lib/constants';
export let show: Writable<boolean>; export let show: Writable<boolean>;
export let url: string = '/account'; export let url: string = '/account';
@ -99,7 +99,11 @@
{#if picker} {#if picker}
<FormControl> <FormControl>
<label for="avatar-file">{$t('account.avatar.uploadLabel')}</label> <label for="avatar-file">{$t('account.avatar.uploadLabel')}</label>
<input type="file" on:change={(e) => readFile(e.target)} accept={allowedImages.join(',')} /> <input
type="file"
on:change={(e) => readFile(e.target)}
accept={ALLOWED_IMAGES.join(',')}
/>
<span>{$t('account.avatar.hint')}</span> <span>{$t('account.avatar.hint')}</span>
</FormControl> </FormControl>
{/if} {/if}

View File

@ -1,3 +1,4 @@
export const allowedImages = ['image/png', 'image/jpg', 'image/jpeg']; export const ALLOWED_IMAGES = ['image/png', 'image/jpg', 'image/jpeg'];
export const OAUTH2_MAX_REDIRECTS = 5; export const OAUTH2_MAX_REDIRECTS = 5;
export const OAUTH2_MAX_URLS = 1; export const OAUTH2_MAX_URLS = 1;
export const MAX_FILE_SIZE_MB = 10;

View File

@ -13,6 +13,9 @@ import { readFile, stat, unlink, writeFile } from 'fs/promises';
import { join } from 'path'; import { join } from 'path';
import * as mime from 'mime-types'; import * as mime from 'mime-types';
import { OAuth2Clients } from './oauth2'; import { OAuth2Clients } from './oauth2';
import { MAX_FILE_SIZE_MB, ALLOWED_IMAGES } from '$lib/constants';
import { error } from '@sveltejs/kit';
import imageSize from 'image-size';
export class Uploads { export class Uploads {
static userFallbackImage: Buffer; static userFallbackImage: Buffer;
@ -115,12 +118,31 @@ export class Uploads {
.where(eq(oauth2Client.id, client.id)); .where(eq(oauth2Client.id, client.id));
} }
static async ensureAllowedFile(file: File) {
if (!ALLOWED_IMAGES.includes(file.type)) {
error(400, 'File is not an image');
}
if (file.size > MAX_FILE_SIZE_MB * 1e6) {
error(400, 'File too big');
}
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const result = imageSize(buffer);
if (!result?.height || !result?.width || result.height / result.width !== 1) {
error(400, 'Invalid image file');
}
return buffer;
}
static async saveAvatar(subject: User, file: File) { static async saveAvatar(subject: User, file: File) {
const ext = mime.extension(file.type); const ext = mime.extension(file.type);
const newName = `user-${subject.uuid.split('-')[0]}-${Math.floor(Date.now() / 1000)}.${ext}`; const newName = `user-${subject.uuid.split('-')[0]}-${Math.floor(Date.now() / 1000)}.${ext}`;
const arrayBuffer = await file.arrayBuffer(); const buffer = await Uploads.ensureAllowedFile(file);
// Write to filesystem // Write to filesystem
await writeFile(join(Uploads.uploads, newName), Buffer.from(arrayBuffer)); await writeFile(join(Uploads.uploads, newName), buffer);
// Remove old // Remove old
await Uploads.removeAvatar(subject); await Uploads.removeAvatar(subject);
// Update DB // Update DB
@ -139,9 +161,9 @@ export class Uploads {
static async saveClientAvatar(client: OAuth2Client, uploader: User, file: File) { static async saveClientAvatar(client: OAuth2Client, uploader: User, file: File) {
const ext = mime.extension(file.type); const ext = mime.extension(file.type);
const newName = `client-${client.client_id.substring(0, 8)}-${Math.floor(Date.now() / 1000)}.${ext}`; const newName = `client-${client.client_id.substring(0, 8)}-${Math.floor(Date.now() / 1000)}.${ext}`;
const arrayBuffer = await file.arrayBuffer(); const buffer = await Uploads.ensureAllowedFile(file);
// Write to filesystem // Write to filesystem
await writeFile(join(Uploads.uploads, newName), Buffer.from(arrayBuffer)); await writeFile(join(Uploads.uploads, newName), buffer);
// Remove old // Remove old
await Uploads.removeClientAvatar(client); await Uploads.removeClientAvatar(client);
// Update DB // Update DB