fix: handle base64 uploads correctly (#64)
Node.js CI / CI (push) Successful in 1m28s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m31s

### Explanation

_No response_

### Issue

_No response_

### Attestations

- [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/)
- [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/).
- [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/).

### Dependencies

- [ ] I have pinned the dependencies to a specific patch version.

### Style

- [ ] I have run the linter and resolved any errors.
- [ ] My pull request uses an appropriate title, matching the conventional commit standards.
- [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request.

### Tests

- [ ] My contribution adds new code, and I have added tests to cover it.
- [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes.
- [ ] All new and existing tests pass locally with my changes.
- [ ] Code coverage remains at or above the configured threshold.

### Documentation

_No response_

### Versioning

_No response_

Reviewed-on: #64
This commit was merged in pull request #64.
This commit is contained in:
2026-02-20 16:53:48 -08:00
parent 8468c4cfdd
commit 208c11d153
6 changed files with 98 additions and 11 deletions
+17 -2
View File
@@ -10,6 +10,7 @@ import {
validateUrl, validateUrl,
validateRating, validateRating,
validateStringLength, validateStringLength,
validateDataUrl,
MAX_LENGTHS, MAX_LENGTHS,
} from "../utils/validation"; } from "../utils/validation";
@@ -44,10 +45,24 @@ export class BookService {
throw new Error("Rating must be an integer between 0 and 10."); throw new Error("Rating must be an integer between 0 and 10.");
} }
// Validate cover image URL if (data.coverImage) {
if (data.coverImage && !validateUrl(data.coverImage)) { if (data.coverImage.startsWith("data:")) {
const sizeInBytes = data.coverImage.length * 0.75;
if (sizeInBytes > MAX_LENGTHS.DATA_URL) {
throw new Error("Cover image must be under 5MB.");
}
if (!validateDataUrl(data.coverImage)) {
throw new Error("Invalid image data URL.");
}
} else {
if (!validateStringLength(data.coverImage, MAX_LENGTHS.URL)) {
throw new Error(`Cover image URL must be ${MAX_LENGTHS.URL} characters or less.`);
}
if (!validateUrl(data.coverImage)) {
throw new Error("Invalid cover image URL. Only http and https URLs are allowed."); throw new Error("Invalid cover image URL. Only http and https URLs are allowed.");
} }
}
}
// Validate tags // Validate tags
if (data.tags) { if (data.tags) {
+17 -1
View File
@@ -10,6 +10,7 @@ import {
validateUrl, validateUrl,
validateRating, validateRating,
validateStringLength, validateStringLength,
validateDataUrl,
MAX_LENGTHS, MAX_LENGTHS,
} from "../utils/validation"; } from "../utils/validation";
@@ -42,9 +43,24 @@ export class GameService {
} }
// Validate cover image URL // Validate cover image URL
if (data.coverImage && !validateUrl(data.coverImage)) { if (data.coverImage) {
if (data.coverImage.startsWith("data:")) {
const sizeInBytes = data.coverImage.length * 0.75;
if (sizeInBytes > MAX_LENGTHS.DATA_URL) {
throw new Error("Cover image must be under 5MB.");
}
if (!validateDataUrl(data.coverImage)) {
throw new Error("Invalid image data URL.");
}
} else {
if (!validateStringLength(data.coverImage, MAX_LENGTHS.URL)) {
throw new Error(`Cover image URL must be ${MAX_LENGTHS.URL} characters or less.`);
}
if (!validateUrl(data.coverImage)) {
throw new Error("Invalid cover image URL. Only http and https URLs are allowed."); throw new Error("Invalid cover image URL. Only http and https URLs are allowed.");
} }
}
}
// Validate tags // Validate tags
if (data.tags) { if (data.tags) {
+17 -1
View File
@@ -10,6 +10,7 @@ import {
validateUrl, validateUrl,
validateRating, validateRating,
validateStringLength, validateStringLength,
validateDataUrl,
MAX_LENGTHS, MAX_LENGTHS,
} from "../utils/validation"; } from "../utils/validation";
@@ -42,9 +43,24 @@ export class MangaService {
} }
// Validate cover image URL // Validate cover image URL
if (data.coverImage && !validateUrl(data.coverImage)) { if (data.coverImage) {
if (data.coverImage.startsWith("data:")) {
const sizeInBytes = data.coverImage.length * 0.75;
if (sizeInBytes > MAX_LENGTHS.DATA_URL) {
throw new Error("Cover image must be under 5MB.");
}
if (!validateDataUrl(data.coverImage)) {
throw new Error("Invalid image data URL.");
}
} else {
if (!validateStringLength(data.coverImage, MAX_LENGTHS.URL)) {
throw new Error(`Cover image URL must be ${MAX_LENGTHS.URL} characters or less.`);
}
if (!validateUrl(data.coverImage)) {
throw new Error("Invalid cover image URL. Only http and https URLs are allowed."); throw new Error("Invalid cover image URL. Only http and https URLs are allowed.");
} }
}
}
// Validate tags // Validate tags
if (data.tags) { if (data.tags) {
+18 -2
View File
@@ -10,6 +10,7 @@ import {
validateUrl, validateUrl,
validateRating, validateRating,
validateStringLength, validateStringLength,
validateDataUrl,
MAX_LENGTHS, MAX_LENGTHS,
} from "../utils/validation"; } from "../utils/validation";
@@ -42,8 +43,23 @@ export class MusicService {
} }
// Validate cover art URL // Validate cover art URL
if (data.coverArt && !validateUrl(data.coverArt)) { if (data.coverArt) {
throw new Error("Invalid cover art URL. Only http and https URLs are allowed."); if (data.coverArt.startsWith("data:")) {
const sizeInBytes = data.coverArt.length * 0.75;
if (sizeInBytes > MAX_LENGTHS.DATA_URL) {
throw new Error("Cover image must be under 5MB.");
}
if (!validateDataUrl(data.coverArt)) {
throw new Error("Invalid image data URL.");
}
} else {
if (!validateStringLength(data.coverArt, MAX_LENGTHS.URL)) {
throw new Error(`Cover image URL must be ${MAX_LENGTHS.URL} characters or less.`);
}
if (!validateUrl(data.coverArt)) {
throw new Error("Invalid cover image URL. Only http and https URLs are allowed.");
}
}
} }
// Validate tags // Validate tags
+17 -1
View File
@@ -10,6 +10,7 @@ import {
validateUrl, validateUrl,
validateRating, validateRating,
validateStringLength, validateStringLength,
validateDataUrl,
MAX_LENGTHS, MAX_LENGTHS,
} from "../utils/validation"; } from "../utils/validation";
@@ -39,9 +40,24 @@ export class ShowService {
} }
// Validate cover image URL // Validate cover image URL
if (data.coverImage && !validateUrl(data.coverImage)) { if (data.coverImage) {
if (data.coverImage.startsWith("data:")) {
const sizeInBytes = data.coverImage.length * 0.75;
if (sizeInBytes > MAX_LENGTHS.DATA_URL) {
throw new Error("Cover image must be under 5MB.");
}
if (!validateDataUrl(data.coverImage)) {
throw new Error("Invalid image data URL.");
}
} else {
if (!validateStringLength(data.coverImage, MAX_LENGTHS.URL)) {
throw new Error(`Cover image URL must be ${MAX_LENGTHS.URL} characters or less.`);
}
if (!validateUrl(data.coverImage)) {
throw new Error("Invalid cover image URL. Only http and https URLs are allowed."); throw new Error("Invalid cover image URL. Only http and https URLs are allowed.");
} }
}
}
// Validate tags // Validate tags
if (data.tags) { if (data.tags) {
+8
View File
@@ -4,6 +4,13 @@
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
/**
* Validates that a URL is a proper base64 data string.
*/
export function validateDataUrl(url: string): boolean {
return /^data:image\/(jpeg|png|gif|webp|svg\+xml);base64,[A-Za-z0-9+/=]+$/.test(url);
}
/** /**
* Validates that a URL is safe and points to an allowed protocol. * Validates that a URL is safe and points to an allowed protocol.
* Prevents javascript:, data:, vbscript:, and file: URLs. * Prevents javascript:, data:, vbscript:, and file: URLs.
@@ -83,4 +90,5 @@ export const MAX_LENGTHS = {
NOTES: 5000, NOTES: 5000,
TAGS: 50, // per tag TAGS: 50, // per tag
ISBN: 50, ISBN: 50,
DATA_URL: 5 * 1024 * 1024, // 5MB in bytes (not chars)
} as const; } as const;