From abb39c67f2f61f435181d940fd0cfc0d8b03f279 Mon Sep 17 00:00:00 2001 From: Hikari Date: Fri, 20 Feb 2026 01:44:57 -0800 Subject: [PATCH] feat: apply comprehensive validation to all remaining services Extended the comprehensive input validation pattern to Music, Art, Show, and Manga services, completing security coverage across all media types in the library. Services Updated: 1. Music Service - Title validation (max 500 characters) - Artist validation (max 200 characters) - Notes validation (max 5000 characters) - Cover art URL validation (max 2048 characters, http/https only) - Rating validation (0-10 integers) - Tags validation (each max 50 characters) - Links validation (valid URLs, max lengths) 2. Art Service - Title validation (max 500 characters) - Artist validation (max 200 characters) - Description validation (max 5000 characters) - Image URL validation (required, valid URL) - Links validation (valid URLs, max lengths) 3. Show Service - Title validation (max 500 characters) - Notes validation (max 5000 characters) - Cover image URL validation (max 2048 characters, http/https only) - Rating validation (0-10 integers) - Tags validation (each max 50 characters) - Links validation (valid URLs, max lengths) 4. Manga Service - Title validation (max 500 characters) - Author validation (max 200 characters) - Notes validation (max 5000 characters) - Cover image URL validation (max 2048 characters, http/https only) - Rating validation (0-10 integers) - Tags validation (each max 50 characters) - Links validation (valid URLs, max lengths) Security Improvements: All services now protect against: - XSS attacks via malicious URLs (javascript:, data:, vbscript:, file:) - Buffer overflow via excessively long strings - Invalid data formats - DoS attacks via massive input Validation Pattern: Each service includes: - Private validateData() method with comprehensive checks - Validation calls at the start of create() and update() methods - Descriptive error messages for all validation failures - Consistent use of MAX_LENGTHS constants Files Modified: - api/src/app/services/music.service.ts - api/src/app/services/art.service.ts - api/src/app/services/show.service.ts - api/src/app/services/manga.service.ts The entire application now has consistent, comprehensive input validation across all user-facing services! --- api/src/app/services/art.service.ts | 62 +++++++++++++++++++++++++ api/src/app/services/manga.service.ts | 65 +++++++++++++++++++++++++++ api/src/app/services/music.service.ts | 65 +++++++++++++++++++++++++++ api/src/app/services/show.service.ts | 62 +++++++++++++++++++++++++ 4 files changed, 254 insertions(+) diff --git a/api/src/app/services/art.service.ts b/api/src/app/services/art.service.ts index 5b8649c..7a3d648 100644 --- a/api/src/app/services/art.service.ts +++ b/api/src/app/services/art.service.ts @@ -6,12 +6,68 @@ import { Art, CreateArtDto, UpdateArtDto } from "@library/shared-types"; import { prisma } from "../lib/prisma"; +import { + validateUrl, + validateStringLength, + MAX_LENGTHS, +} from "../utils/validation"; export class ArtService { private prisma = prisma; constructor() {} + /** + * Validate art data for security. + */ + private validateArtData(data: CreateArtDto | UpdateArtDto): void { + // Validate string lengths + if (!validateStringLength(data.title, MAX_LENGTHS.TITLE)) { + throw new Error(`Title must be ${MAX_LENGTHS.TITLE} characters or less.`); + } + if (!validateStringLength(data.artist, MAX_LENGTHS.AUTHOR)) { + throw new Error(`Artist must be ${MAX_LENGTHS.AUTHOR} characters or less.`); + } + if (!validateStringLength(data.description, MAX_LENGTHS.DESCRIPTION)) { + throw new Error(`Description must be ${MAX_LENGTHS.DESCRIPTION} characters or less.`); + } + + // Validate image URL (required) + if (!data.imageUrl) { + throw new Error("Image URL is required."); + } + if (!validateUrl(data.imageUrl)) { + throw new Error("Invalid image URL. Only http and https URLs are allowed."); + } + if (!validateStringLength(data.imageUrl, MAX_LENGTHS.URL)) { + throw new Error(`Image URL must be ${MAX_LENGTHS.URL} characters or less.`); + } + + // Validate tags + if (data.tags) { + for (const tag of data.tags) { + if (!validateStringLength(tag, MAX_LENGTHS.TAGS)) { + throw new Error(`Each tag must be ${MAX_LENGTHS.TAGS} characters or less.`); + } + } + } + + // Validate link URLs + if (data.links) { + for (const link of data.links) { + if (!validateUrl(link.url)) { + throw new Error(`Invalid link URL: ${link.title}. Only http and https URLs are allowed.`); + } + if (!validateStringLength(link.title, MAX_LENGTHS.AUTHOR)) { + throw new Error(`Link title must be ${MAX_LENGTHS.AUTHOR} characters or less.`); + } + if (!validateStringLength(link.url, MAX_LENGTHS.URL)) { + throw new Error(`Link URL must be ${MAX_LENGTHS.URL} characters or less.`); + } + } + } + } + /** * Get all art pieces. */ @@ -56,6 +112,9 @@ export class ArtService { * Create new art piece. */ async createArt(data: CreateArtDto): Promise { + // Validate input + this.validateArtData(data); + const art = await this.prisma.art.create({ data, }); @@ -75,6 +134,9 @@ export class ArtService { * Update art by ID. */ async updateArt(id: string, data: UpdateArtDto): Promise { + // Validate input + this.validateArtData(data); + const art = await this.prisma.art.update({ where: { id }, data, diff --git a/api/src/app/services/manga.service.ts b/api/src/app/services/manga.service.ts index f63dcd0..e164622 100644 --- a/api/src/app/services/manga.service.ts +++ b/api/src/app/services/manga.service.ts @@ -6,12 +6,71 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto } from "@library/shared-types"; import { prisma } from "../lib/prisma"; +import { + validateUrl, + validateRating, + validateStringLength, + MAX_LENGTHS, +} from "../utils/validation"; export class MangaService { private prisma = prisma; constructor() {} + /** + * Validate manga data for security. + */ + private validateMangaData(data: CreateMangaDto | UpdateMangaDto): void { + // Validate string lengths + if (!validateStringLength(data.title, MAX_LENGTHS.TITLE)) { + throw new Error(`Title must be ${MAX_LENGTHS.TITLE} characters or less.`); + } + if (!validateStringLength(data.author, MAX_LENGTHS.AUTHOR)) { + throw new Error(`Author must be ${MAX_LENGTHS.AUTHOR} characters or less.`); + } + if (!validateStringLength(data.notes, MAX_LENGTHS.NOTES)) { + throw new Error(`Notes must be ${MAX_LENGTHS.NOTES} characters or less.`); + } + if (!validateStringLength(data.coverImage, MAX_LENGTHS.URL)) { + throw new Error(`Cover image URL must be ${MAX_LENGTHS.URL} characters or less.`); + } + + // Validate rating + if (!validateRating(data.rating)) { + throw new Error("Rating must be an integer between 0 and 10."); + } + + // Validate cover image URL + if (data.coverImage && !validateUrl(data.coverImage)) { + throw new Error("Invalid cover image URL. Only http and https URLs are allowed."); + } + + // Validate tags + if (data.tags) { + for (const tag of data.tags) { + if (!validateStringLength(tag, MAX_LENGTHS.TAGS)) { + throw new Error(`Each tag must be ${MAX_LENGTHS.TAGS} characters or less.`); + } + } + } + + // Validate link URLs + if (data.links) { + for (const link of data.links) { + if (!validateUrl(link.url)) { + throw new Error(`Invalid link URL: ${link.title}. Only http and https URLs are allowed.`); + } + if (!validateStringLength(link.title, MAX_LENGTHS.TITLE)) { + throw new Error(`Link title must be ${MAX_LENGTHS.TITLE} characters or less.`); + } + if (!validateStringLength(link.url, MAX_LENGTHS.URL)) { + throw new Error(`Link URL must be ${MAX_LENGTHS.URL} characters or less.`); + } + } + } + } + async getAllManga(): Promise { const manga = await this.prisma.manga.findMany({ orderBy: { updatedAt: "desc" }, @@ -53,6 +112,9 @@ export class MangaService { } async createManga(data: CreateMangaDto): Promise { + // Validate input + this.validateMangaData(data); + const manga = await this.prisma.manga.create({ data: { ...data, @@ -75,6 +137,9 @@ export class MangaService { } async updateManga(id: string, data: UpdateMangaDto): Promise { + // Validate input + this.validateMangaData(data); + const updateData = { ...data }; if (updateData.status) { updateData.status = updateData.status.toUpperCase() as any; diff --git a/api/src/app/services/music.service.ts b/api/src/app/services/music.service.ts index 405d8f0..34890f6 100644 --- a/api/src/app/services/music.service.ts +++ b/api/src/app/services/music.service.ts @@ -6,12 +6,71 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto } from "@library/shared-types"; import { prisma } from "../lib/prisma"; +import { + validateUrl, + validateRating, + validateStringLength, + MAX_LENGTHS, +} from "../utils/validation"; export class MusicService { private prisma = prisma; constructor() {} + /** + * Validate music data for security. + */ + private validateMusicData(data: CreateMusicDto | UpdateMusicDto): void { + // Validate string lengths + if (!validateStringLength(data.title, MAX_LENGTHS.TITLE)) { + throw new Error(`Title must be ${MAX_LENGTHS.TITLE} characters or less.`); + } + if (!validateStringLength(data.artist, MAX_LENGTHS.AUTHOR)) { + throw new Error(`Artist must be ${MAX_LENGTHS.AUTHOR} characters or less.`); + } + if (!validateStringLength(data.notes, MAX_LENGTHS.NOTES)) { + throw new Error(`Notes must be ${MAX_LENGTHS.NOTES} characters or less.`); + } + if (!validateStringLength(data.coverArt, MAX_LENGTHS.URL)) { + throw new Error(`Cover art URL must be ${MAX_LENGTHS.URL} characters or less.`); + } + + // Validate rating + if (data.rating !== undefined && !validateRating(data.rating)) { + throw new Error("Rating must be an integer between 0 and 10."); + } + + // Validate cover art URL + if (data.coverArt && !validateUrl(data.coverArt)) { + throw new Error("Invalid cover art URL. Only http and https URLs are allowed."); + } + + // Validate tags + if (data.tags) { + for (const tag of data.tags) { + if (!validateStringLength(tag, MAX_LENGTHS.TAGS)) { + throw new Error(`Each tag must be ${MAX_LENGTHS.TAGS} characters or less.`); + } + } + } + + // Validate link URLs + if (data.links) { + for (const link of data.links) { + if (!validateUrl(link.url)) { + throw new Error(`Invalid link URL: ${link.title}. Only http and https URLs are allowed.`); + } + if (!validateStringLength(link.title, MAX_LENGTHS.TITLE)) { + throw new Error(`Link title must be ${MAX_LENGTHS.TITLE} characters or less.`); + } + if (!validateStringLength(link.url, MAX_LENGTHS.URL)) { + throw new Error(`Link URL must be ${MAX_LENGTHS.URL} characters or less.`); + } + } + } + } + /** * Get all music. */ @@ -64,6 +123,9 @@ export class MusicService { * Create new music. */ async createMusic(data: CreateMusicDto): Promise { + // Validate input + this.validateMusicData(data); + const music = await this.prisma.music.create({ data: { ...data, @@ -91,6 +153,9 @@ export class MusicService { * Update music by ID. */ async updateMusic(id: string, data: UpdateMusicDto): Promise { + // Validate input + this.validateMusicData(data); + const updateData = { ...data }; if (updateData.type) { updateData.type = updateData.type.toUpperCase() as any; diff --git a/api/src/app/services/show.service.ts b/api/src/app/services/show.service.ts index 7d87652..51bcd40 100644 --- a/api/src/app/services/show.service.ts +++ b/api/src/app/services/show.service.ts @@ -6,12 +6,68 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto } from "@library/shared-types"; import { prisma } from "../lib/prisma"; +import { + validateUrl, + validateRating, + validateStringLength, + MAX_LENGTHS, +} from "../utils/validation"; export class ShowService { private prisma = prisma; constructor() {} + /** + * Validate show data for security. + */ + private validateShowData(data: CreateShowDto | UpdateShowDto): void { + // Validate string lengths + if (!validateStringLength(data.title, MAX_LENGTHS.TITLE)) { + throw new Error(`Title must be ${MAX_LENGTHS.TITLE} characters or less.`); + } + if (!validateStringLength(data.notes, MAX_LENGTHS.NOTES)) { + throw new Error(`Notes must be ${MAX_LENGTHS.NOTES} characters or less.`); + } + if (!validateStringLength(data.coverImage, MAX_LENGTHS.URL)) { + throw new Error(`Cover image URL must be ${MAX_LENGTHS.URL} characters or less.`); + } + + // Validate rating + if (!validateRating(data.rating)) { + throw new Error("Rating must be an integer between 0 and 10."); + } + + // Validate cover image URL + if (data.coverImage && !validateUrl(data.coverImage)) { + throw new Error("Invalid cover image URL. Only http and https URLs are allowed."); + } + + // Validate tags + if (data.tags) { + for (const tag of data.tags) { + if (!validateStringLength(tag, MAX_LENGTHS.TAGS)) { + throw new Error(`Each tag must be ${MAX_LENGTHS.TAGS} characters or less.`); + } + } + } + + // Validate link URLs + if (data.links) { + for (const link of data.links) { + if (!validateUrl(link.url)) { + throw new Error(`Invalid link URL: ${link.title}. Only http and https URLs are allowed.`); + } + if (!validateStringLength(link.title, MAX_LENGTHS.TITLE)) { + throw new Error(`Link title must be ${MAX_LENGTHS.TITLE} characters or less.`); + } + if (!validateStringLength(link.url, MAX_LENGTHS.URL)) { + throw new Error(`Link URL must be ${MAX_LENGTHS.URL} characters or less.`); + } + } + } + } + async getAllShows(): Promise { const shows = await this.prisma.show.findMany({ orderBy: { updatedAt: "desc" }, @@ -55,6 +111,9 @@ export class ShowService { } async createShow(data: CreateShowDto): Promise { + // Validate input + this.validateShowData(data); + const show = await this.prisma.show.create({ data: { ...data, @@ -79,6 +138,9 @@ export class ShowService { } async updateShow(id: string, data: UpdateShowDto): Promise { + // Validate input + this.validateShowData(data); + const updateData = { ...data }; if (updateData.type) { updateData.type = updateData.type.toUpperCase() as any;