feat: apply comprehensive validation to all remaining services
Node.js CI / CI (pull_request) Failing after 34s
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m9s

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!
This commit is contained in:
2026-02-20 01:44:57 -08:00
committed by Naomi Carrigan
parent 5bbd3f7d6e
commit abb39c67f2
4 changed files with 254 additions and 0 deletions
+62
View File
@@ -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<Art> {
// 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<Art> {
// Validate input
this.validateArtData(data);
const art = await this.prisma.art.update({
where: { id },
data,
+65
View File
@@ -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<Manga[]> {
const manga = await this.prisma.manga.findMany({
orderBy: { updatedAt: "desc" },
@@ -53,6 +112,9 @@ export class MangaService {
}
async createManga(data: CreateMangaDto): Promise<Manga> {
// 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<Manga> {
// Validate input
this.validateMangaData(data);
const updateData = { ...data };
if (updateData.status) {
updateData.status = updateData.status.toUpperCase() as any;
+65
View File
@@ -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<Music> {
// 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<Music> {
// Validate input
this.validateMusicData(data);
const updateData = { ...data };
if (updateData.type) {
updateData.type = updateData.type.toUpperCase() as any;
+62
View File
@@ -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<Show[]> {
const shows = await this.prisma.show.findMany({
orderBy: { updatedAt: "desc" },
@@ -55,6 +111,9 @@ export class ShowService {
}
async createShow(data: CreateShowDto): Promise<Show> {
// 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<Show> {
// Validate input
this.validateShowData(data);
const updateData = { ...data };
if (updateData.type) {
updateData.type = updateData.type.toUpperCase() as any;