diff --git a/.env.example b/.env.example index 076d4f6..8d0072e 100644 --- a/.env.example +++ b/.env.example @@ -18,11 +18,10 @@ NODE_ENV=development CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 -MULTER_MAX_FILE_BYTES=10485760 - ALTCHA_ENABLED=true ALTCHA_HMAC_KEY=change-me-use-long-random-secret ALTCHA_MAX_NUMBER=50000 +ALTCHA_CHALLENGE_TTL_MS=600000 NEVER_MODE_INACTIVITY_DAYS=365 @@ -31,11 +30,13 @@ THROTTLE_LIMIT=1000 THROTTLE_UPLOAD_TTL_MS=60000 THROTTLE_UPLOAD_LIMIT=20 -VALIDATION_UPLOAD_MAX_IMAGES_PER_UPLOAD=10 VALIDATION_UPLOAD_MAX_VIEWS_MAX=1000000 +VALIDATION_UPLOAD_MAX_IMAGE_BYTES=10485760 # 10MiB +VALIDATION_UPLOAD_JSON_BODY_PADDING_BYTES=2097152 # 2MiB VITE_API_BASE_URL=http://localhost:3000/api VITE_CLEARNET_DOMAIN=localhost:5173 VITE_ONION_HOSTNAME= -VITE_VALIDATION_UPLOAD_MAX_IMAGES_PER_UPLOAD=10 +VITE_UPLOAD_MAX_QUEUE_SIZE=10 VITE_VALIDATION_UPLOAD_MAX_VIEWS_MAX=1000000 +VITE_VALIDATION_UPLOAD_MAX_IMAGE_BYTES=10485760 # 10MiB diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 397701e..32c8a9c 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -1,7 +1,7 @@ -import { AppConfig } from '../types/AppConfig'; -import { AltchaConfig } from '../types/AltchaConfig'; -import { PostgresConfig } from '../types/PostgresConfig'; +import { AltchaConfig } from '../types/config/AltchaConfig'; +import { AppConfig } from '../types/config/AppConfig'; import { NodeEnv } from '../types/NodeEnv'; +import { PostgresConfig } from '../types/config/PostgresConfig'; const env = (key: string): string => process.env[key] || ''; @@ -28,12 +28,19 @@ export const getAltchaConfig = (): AltchaConfig => { return { enabled: isEnabled('ALTCHA_ENABLED'), hmacKey: env('ALTCHA_HMAC_KEY'), - maxNumber: envInt('ALTCHA_MAX_NUMBER') + maxNumber: envInt('ALTCHA_MAX_NUMBER'), + challengeTtlMs: envInt('ALTCHA_CHALLENGE_TTL_MS') }; }; export const getAppConfig = (): AppConfig => { + const aesGcmCiphertextOverheadBytes = 16; + const jsonBase64ByteRatioNumerator = 4; + const jsonBase64ByteRatioDenominator = 3; + const rawOrigins = env('CORS_ORIGINS'); + const maxImageBytes = envInt('VALIDATION_UPLOAD_MAX_IMAGE_BYTES'); + const jsonBodyPaddingBytes = envInt('VALIDATION_UPLOAD_JSON_BODY_PADDING_BYTES'); return { port: envInt('BACKEND_PORT'), @@ -51,8 +58,14 @@ export const getAppConfig = (): AppConfig => { limit: envInt('THROTTLE_UPLOAD_LIMIT') }, validation: { - maxImagesPerUpload: envInt('VALIDATION_UPLOAD_MAX_IMAGES_PER_UPLOAD'), - uploadMaxViewsMax: envInt('VALIDATION_UPLOAD_MAX_VIEWS_MAX') + upload: { + maxViewsMax: envInt('VALIDATION_UPLOAD_MAX_VIEWS_MAX'), + maxImageBytes, + maxCiphertextBytes: maxImageBytes + aesGcmCiphertextOverheadBytes, + maxRequestBodyBytes: + Math.ceil((maxImageBytes * jsonBase64ByteRatioNumerator) / jsonBase64ByteRatioDenominator) + + jsonBodyPaddingBytes + } } }; }; diff --git a/backend/src/config/validate.spec.ts b/backend/src/config/validate.spec.ts index eda9226..17c168c 100644 --- a/backend/src/config/validate.spec.ts +++ b/backend/src/config/validate.spec.ts @@ -11,11 +11,12 @@ const validConfig = (overrides: Record = {}): Record) => { diff --git a/backend/src/types/ValidationConfig.ts b/backend/src/types/ValidationConfig.ts deleted file mode 100644 index 83ff1de..0000000 --- a/backend/src/types/ValidationConfig.ts +++ /dev/null @@ -1,4 +0,0 @@ -export type ValidationConfig = { - maxImagesPerUpload: number; - uploadMaxViewsMax: number; -}; diff --git a/backend/src/types/AltchaConfig.ts b/backend/src/types/config/AltchaConfig.ts similarity index 77% rename from backend/src/types/AltchaConfig.ts rename to backend/src/types/config/AltchaConfig.ts index ef224f8..65c8753 100644 --- a/backend/src/types/AltchaConfig.ts +++ b/backend/src/types/config/AltchaConfig.ts @@ -2,4 +2,5 @@ export type AltchaConfig = { enabled: boolean; hmacKey: string; maxNumber: number; + challengeTtlMs: number; }; diff --git a/backend/src/types/AppConfig.ts b/backend/src/types/config/AppConfig.ts similarity index 88% rename from backend/src/types/AppConfig.ts rename to backend/src/types/config/AppConfig.ts index 40a557a..67390d8 100644 --- a/backend/src/types/AppConfig.ts +++ b/backend/src/types/config/AppConfig.ts @@ -1,4 +1,4 @@ -import { NodeEnv } from './NodeEnv'; +import { NodeEnv } from '../NodeEnv'; import { ThrottleConfig } from './ThrottleConfig'; import { ValidationConfig } from './ValidationConfig'; diff --git a/backend/src/types/Config.ts b/backend/src/types/config/Config.ts similarity index 100% rename from backend/src/types/Config.ts rename to backend/src/types/config/Config.ts diff --git a/backend/src/types/PostgresConfig.ts b/backend/src/types/config/PostgresConfig.ts similarity index 100% rename from backend/src/types/PostgresConfig.ts rename to backend/src/types/config/PostgresConfig.ts diff --git a/backend/src/types/ThrottleConfig.ts b/backend/src/types/config/ThrottleConfig.ts similarity index 100% rename from backend/src/types/ThrottleConfig.ts rename to backend/src/types/config/ThrottleConfig.ts diff --git a/backend/src/types/config/UploadValidationConfig.ts b/backend/src/types/config/UploadValidationConfig.ts new file mode 100644 index 0000000..2c58eb1 --- /dev/null +++ b/backend/src/types/config/UploadValidationConfig.ts @@ -0,0 +1,6 @@ +export type UploadValidationConfig = { + maxViewsMax: number; + maxImageBytes: number; + maxCiphertextBytes: number; + maxRequestBodyBytes: number; +}; diff --git a/backend/src/types/config/ValidationConfig.ts b/backend/src/types/config/ValidationConfig.ts new file mode 100644 index 0000000..f117c63 --- /dev/null +++ b/backend/src/types/config/ValidationConfig.ts @@ -0,0 +1,5 @@ +import { UploadValidationConfig } from './UploadValidationConfig'; + +export type ValidationConfig = { + upload: UploadValidationConfig; +}; diff --git a/backend/src/types/validation/IsBase64Options.ts b/backend/src/types/validation/IsBase64Options.ts index e008f27..6b11519 100644 --- a/backend/src/types/validation/IsBase64Options.ts +++ b/backend/src/types/validation/IsBase64Options.ts @@ -1,3 +1,4 @@ export interface IsBase64Options { byteLength?: number; + maxByteLength?: number; } diff --git a/backend/src/validation/decorators/isBase64.spec.ts b/backend/src/validation/decorators/isBase64.spec.ts index cdc6b6b..7b9e4f3 100644 --- a/backend/src/validation/decorators/isBase64.spec.ts +++ b/backend/src/validation/decorators/isBase64.spec.ts @@ -235,6 +235,29 @@ describe('IsBase64', () => { }); }); + describe('with maxByteLength', () => { + class MaxByteLengthDto { + @IsBase64({ maxByteLength: 64 }) + payload: string; + } + + it('accepts base64 up to the decoded byte limit', () => { + accepts(MaxByteLengthDto, 'payload', toBase64(Buffer.alloc(64, 0x01))); + }); + + it('rejects base64 that decodes above the limit', () => { + rejects(MaxByteLengthDto, 'payload', toBase64(Buffer.alloc(65, 0x01))); + }); + + it('returns maxByteLength-specific error message', () => { + const errors = validateField(MaxByteLengthDto, 'payload', toBase64(Buffer.alloc(128, 0x01))); + + expect(constraintMessage(errors, 'payload')).toBe( + 'payload must be a base64-encoded value of at most 64 bytes' + ); + }); + }); + describe('default options object', () => { class DefaultOptionsDto { @IsBase64({}) diff --git a/backend/src/validation/decorators/isBase64.ts b/backend/src/validation/decorators/isBase64.ts index b2cdecf..3d537f4 100644 --- a/backend/src/validation/decorators/isBase64.ts +++ b/backend/src/validation/decorators/isBase64.ts @@ -25,22 +25,30 @@ class IsBase64Constraint implements ValidatorConstraintInterface { return false; } - const [{ byteLength }] = args.constraints as [IsBase64Options]; + const [{ byteLength, maxByteLength }] = args.constraints as [IsBase64Options]; if (byteLength !== undefined) { return decoded.length === byteLength; } + if (maxByteLength !== undefined) { + return decoded.length <= maxByteLength; + } + return true; } defaultMessage(args: ValidationArguments): string { - const [{ byteLength }] = args.constraints as [IsBase64Options]; + const [{ byteLength, maxByteLength }] = args.constraints as [IsBase64Options]; if (byteLength !== undefined) { return `$property must be a base64-encoded ${byteLength}-byte value`; } + if (maxByteLength !== undefined) { + return `$property must be a base64-encoded value of at most ${maxByteLength} bytes`; + } + return '$property must be a valid base64 string'; } }