Add validation schemas and improve texture handling

Introduces zod-based validation schemas for Minecraft and Mojang API endpoints. Refactors texture route to support hash-based file serving and removes the old static texture route. Updates database schema for player properties and adds an event to clean expired certificates. Improves ValidationError formatting, adjusts skin/cape URL construction, and adds SSRF protection for skin uploads.
This commit is contained in:
Gilles Lazures 2025-12-28 09:02:10 +01:00
parent 1fe46a03fd
commit 5cfadfd7ac
29 changed files with 490 additions and 24 deletions

View File

@ -1,14 +1,52 @@
const DefaultError = require("./DefaultError") const DefaultError = require("./DefaultError")
const YggdrasilError = require("./YggdrasilError") const YggdrasilError = require("./YggdrasilError")
const SessionError = require("./SessionError") const SessionError = require("./SessionError")
const ServiceError = require("./ServiceError")
const logger = require("../modules/logger") const logger = require("../modules/logger")
class ValidationError extends DefaultError { class ValidationError extends DefaultError {
constructor(zodResult, config = {}, context = {}) { constructor(result, config = {}, context = {}) {
const formattedErrors = zodResult.error.issues.map(e => ({ let formattedErrors = []
field: e.path.join("."), if (result && result.error && Array.isArray(result.error.issues)) {
message: e.message formattedErrors = result.error.issues.flatMap(issue => {
})) if (issue.code === "unrecognized_keys") {
return issue.keys.map(key => ({
field: [...issue.path, key].join("."),
message: "Field not allowed"
}))
}
if (issue.code === "invalid_union") {
return {
field: issue.path.join("."),
message: "Invalid input format (union mismatch)"
}
}
return {
field: issue.path.join("."),
message: issue.message
}
})
}
else if (result instanceof Error) {
formattedErrors = [{
field: "global",
message: result.message
}]
}
else if (typeof result === "string") {
formattedErrors = [{
field: "global",
message: result
}]
}
else {
formattedErrors = [{
field: "unknown",
message: "Unknown validation error"
}]
}
const message = config.message || "Validation failed" const message = config.message || "Validation failed"
const statusCode = config.code || 400 const statusCode = config.code || 400
@ -54,11 +92,27 @@ class ValidationError extends DefaultError {
return err.serialize() return err.serialize()
} }
return { if (this.config.errorFormat === "ServiceError") {
const err = new ServiceError(
this.code,
this.context.path || "",
this.config.errorName || "ValidationException",
this.message,
this.formattedErrors
)
return err.serialize()
}
const response = {
code: this.code, code: this.code,
message: this.message, message: this.message,
errors: this.formattedErrors errors: this.formattedErrors
} }
if (this.cause) {
response.cause = this.cause
}
return response
} }
} }

View File

@ -45,7 +45,8 @@ async function setupDatabase() {
name VARCHAR(256) NOT NULL, name VARCHAR(256) NOT NULL,
value VARCHAR(512) NOT NULL, value VARCHAR(512) NOT NULL,
uuid VARCHAR(36) NOT NULL, uuid VARCHAR(36) NOT NULL,
FOREIGN KEY (uuid) REFERENCES players(uuid) UNIQUE KEY unique_property (uuid, name),
FOREIGN KEY (uuid) REFERENCES players(uuid) ON DELETE CASCADE
) )
`) `)
logger.log(`${"playersProperties".bold} table ready`, ["MariaDB", "yellow"]) logger.log(`${"playersProperties".bold} table ready`, ["MariaDB", "yellow"])
@ -333,6 +334,17 @@ async function setupDatabase() {
) )
`) `)
logger.log(`${"serverSessions".bold} table ready`, ["MariaDB", "yellow"]) logger.log(`${"serverSessions".bold} table ready`, ["MariaDB", "yellow"])
await conn.query(`SET GLOBAL event_scheduler = ON;`)
logger.log("MariaDB Event Scheduler enabled.", ["MariaDB", "yellow"])
await conn.query(`
CREATE EVENT IF NOT EXISTS clean_expired_certificates
ON SCHEDULE EVERY 1 HOUR
DO
DELETE FROM playerCertificates WHERE expiresAt < NOW();
`)
logger.log(`${"clean_expired_certificates".bold} event ready`, ["MariaDB", "yellow"])
logger.log("MariaDB database successfully initialised!", ["MariaDB", "yellow"]) logger.log("MariaDB database successfully initialised!", ["MariaDB", "yellow"])

View File

@ -5,7 +5,11 @@ const { DefaultError } = require("../errors/errors")
async function addPropertyToPlayer(key, value, uuid) { async function addPropertyToPlayer(key, value, uuid) {
try { try {
const sql = `INSERT INTO playersProperties (name, value, uuid) VALUES (?, ?, ?)` const sql = `
INSERT INTO playersProperties (name, value, uuid)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE value = VALUES(value)
`
const result = await database.query(sql, [key, value, uuid]) const result = await database.query(sql, [key, value, uuid])
if (result.affectedRows > 0) { if (result.affectedRows > 0) {

View File

@ -3,7 +3,7 @@ const router = express.Router()
const userService = require("../../../../services/userService") const userService = require("../../../../services/userService")
const authService = require("../../../../services/authService") const authService = require("../../../../services/authService")
router.put("/", async (req, res) => { router.get("/", async (req, res) => {
const player = await authService.verifyAccessToken({ accessToken: req.headers.authorization.replace("Bearer ", "") }) const player = await authService.verifyAccessToken({ accessToken: req.headers.authorization.replace("Bearer ", "") })
const nameChangeInformation = await userService.getPlayerNameChangeStatus(player.user.uuid) const nameChangeInformation = await userService.getPlayerNameChangeStatus(player.user.uuid)
return res.status(nameChangeInformation.code).json(nameChangeInformation.data) return res.status(nameChangeInformation.code).json(nameChangeInformation.data)

View File

@ -1,3 +1,5 @@
const fs = require("node:fs")
const path = require("node:path")
const express = require("express") const express = require("express")
const router = express.Router() const router = express.Router()
const multer = require("multer") const multer = require("multer")
@ -22,7 +24,11 @@ const uploadLimiter = rateLimit({
max: 20, max: 20,
standardHeaders: true, standardHeaders: true,
legacyHeaders: false, legacyHeaders: false,
validate: {
ip: false
},
keyGenerator: (req) => { keyGenerator: (req) => {
rateLimit.ipKeyGenerator()
return req.headers.authorization || req.ip return req.headers.authorization || req.ip
}, },
handler: (req, res, next, options) => { handler: (req, res, next, options) => {

View File

@ -1,13 +0,0 @@
const express = require("express")
const router = express.Router()
const path = require("node:path")
const fs = require("node:fs")
const TEXTURES_DIR = path.join(process.cwd(), "data", "textures")
if (!fs.existsSync(TEXTURES_DIR)) {
fs.mkdirSync(TEXTURES_DIR, { recursive: true })
}
router.use(express.static(TEXTURES_DIR))
module.exports = router

View File

@ -0,0 +1,27 @@
const express = require("express")
const router = express.Router({ mergeParams: true })
const path = require("node:path")
const fs = require("node:fs")
const { DefaultError } = require("../../../errors/errors")
const TEXTURES_DIR = path.join(process.cwd(), "data", "textures")
router.get("/", async (req, res, next) => {
try {
const hash = req.params.hash
if (!/^[a-f0-9]{64}$/i.test(hash)) {
throw new DefaultError(404, "Texture not found")
}
const subDir = hash.substring(0, 2)
const filePath = path.join(TEXTURES_DIR, subDir, hash)
if (!fs.existsSync(filePath)) {
throw new DefaultError(404, "Texture not found")
}
res.sendFile(filePath)
} catch (err) {
return next(err)
}
})
module.exports = router

View File

@ -0,0 +1,13 @@
const z = require("zod")
module.exports = {
GET: {
headers: z.object({
"authorization": z.string().min(1, { message: "Authorization header is required." })
}),
error: {
code: 401,
message: "Unauthorized"
}
}
}

View File

@ -0,0 +1,28 @@
const z = require("zod")
module.exports = {
DELETE: {
headers: z.object({
"authorization": z.string().min(1, { message: "Authorization header is required." })
}),
error: {
code: 401,
message: "Unauthorized"
}
},
PUT: {
headers: z.object({
"content-type": z.string()
.regex(/application\/json/i, { message: "Content-Type must be application/json" }),
"authorization": z.string().min(1, { message: "Authorization header is required." })
}),
body: z.object({
capeId: z.string().uuid({ message: "Invalid Cape UUID." })
}),
error: {
code: 400,
message: "profile does not own cape",
error: "IllegalArgumentException"
}
}
}

View File

@ -0,0 +1,18 @@
const z = require("zod")
module.exports = {
POST: {
headers: z.object({
"content-type": z.string()
.regex(/application\/json/i, { message: "Content-Type must be application/json" })
}),
body: z.array(z.string().trim().min(1))
.min(1, { message: "RequestPayload is an empty array." })
.max(10, { message: "RequestPayload has more than 10 elements." }),
error: {
code: 400,
error: "CONSTRAINT_VIOLATION",
errorMessage: "size must be between 1 and 10"
}
}
}

View File

@ -0,0 +1,13 @@
const z = require("zod")
module.exports = {
GET: {
params: z.object({
username: z.string().min(1)
}),
error: {
code: 404,
message: "Not Found"
}
}
}

View File

@ -0,0 +1,22 @@
const z = require("zod")
const nameSchema = z.string()
.min(1)
.max(16)
.regex(/^[a-zA-Z0-9_]+$/, { message: "Name can only contain alphanumeric characters and underscores." });
module.exports = {
PUT: {
headers: z.object({
"authorization": z.string().min(1, { message: "Authorization header is required." })
}),
params: z.object({
name: nameSchema
}),
error: {
code: 400,
error: "CONSTRAINT_VIOLATION",
errorMessage: "Invalid profile name"
}
}
}

View File

@ -0,0 +1,21 @@
const z = require("zod")
const nameSchema = z.string()
.min(1)
.max(16)
.regex(/^[a-zA-Z0-9_]+$/, { message: "Name can only contain alphanumeric characters and underscores." });
module.exports = {
GET: {
headers: z.object({
"authorization": z.string().min(1, { message: "Authorization header is required." })
}),
params: z.object({
name: nameSchema
}),
error: {
code: 401,
message: "Unauthorized"
}
}
}

View File

@ -0,0 +1,13 @@
const z = require("zod")
module.exports = {
GET: {
headers: z.object({
"authorization": z.string().min(1, { message: "Authorization header is required." })
}),
error: {
code: 401,
message: "Unauthorized"
}
}
}

View File

@ -0,0 +1,24 @@
const z = require("zod")
module.exports = {
POST: {
headers: z.object({
"content-type": z.string()
.regex(/application\/json/i, { message: "Content-Type must be application/json" }),
"authorization": z.string().min(1, { message: "Authorization header is required." })
}),
body: z.object({
variant: z.enum(["classic", "slim"], {
errorMap: () => ({ message: "Variant must be 'classic' or 'slim'." })
}),
url: z.string()
.url({ message: "Invalid URL format." })
.max(2048, { message: "URL is too long." })
}),
error: {
code: 400,
message: "Invalid skin URL or variant.",
error: "IllegalArgumentException"
}
}
}

View File

@ -0,0 +1,13 @@
const z = require("zod")
module.exports = {
DELETE: {
headers: z.object({
"authorization": z.string().min(1, { message: "Authorization header is required." })
}),
error: {
code: 401,
message: "Unauthorized"
}
}
}

View File

@ -0,0 +1,29 @@
const z = require("zod")
module.exports = {
GET: {
headers: z.object({
"authorization": z.string().min(1, { message: "Authorization header is required." })
}),
error: {
code: 401,
message: "Unauthorized"
}
},
POST: {
headers: z.object({
"content-type": z.string()
.regex(/application\/json/i, { message: "Content-Type must be application/json" }),
"authorization": z.string().min(1, { message: "Authorization header is required." })
}),
body: z.object({
profanityFilterPreferences: z.object({
profanityFilterOn: z.boolean({ required_error: "profanityFilterOn is required" })
})
}),
error: {
code: 400,
message: "Invalid attributes format."
}
}
}

View File

@ -0,0 +1,13 @@
const z = require("zod")
module.exports = {
POST: {
headers: z.object({
"authorization": z.string().min(1, { message: "Authorization header is required." })
}),
error: {
code: 401,
message: "Unauthorized"
}
}
}

View File

@ -0,0 +1,13 @@
const z = require("zod")
module.exports = {
GET: {
headers: z.object({
"authorization": z.string().min(1, { message: "Authorization header is required." })
}),
error: {
code: 401,
message: "Unauthorized"
}
}
}

View File

@ -0,0 +1,28 @@
const z = require("zod")
module.exports = {
PUT: {
headers: z.object({
"authorization": z.string().min(1, { message: "Authorization header is required." })
}),
params: z.object({
uuid: z.string().uuid({ message: "Invalid UUID." })
}),
error: {
code: 400,
message: "Invalid UUID"
}
},
DELETE: {
headers: z.object({
"authorization": z.string().min(1, { message: "Authorization header is required." })
}),
params: z.object({
uuid: z.string().uuid({ message: "Invalid UUID." })
}),
error: {
code: 400,
message: "Invalid UUID"
}
}
}

View File

@ -0,0 +1,29 @@
const z = require("zod")
module.exports = {
GET: {
headers: z.object({
"authorization": z.string().min(1, { message: "Authorization header is required." })
}),
error: {
code: 401,
message: "Unauthorized"
}
},
POST: {
headers: z.object({
"content-type": z.string()
.regex(/application\/json/i, { message: "Content-Type must be application/json" }),
"authorization": z.string().min(1, { message: "Authorization header is required." })
}),
body: z.object({
profanityFilterPreferences: z.object({
profanityFilterOn: z.boolean({ required_error: "profanityFilterOn is required" })
}).optional()
}),
error: {
code: 401,
message: "Unauthorized"
}
}
}

View File

@ -0,0 +1,18 @@
const z = require("zod")
module.exports = {
POST: {
headers: z.object({
"content-type": z.string()
.regex(/application\/json/i, { message: "Content-Type must be application/json" })
}),
body: z.array(z.string().trim().min(1))
.min(1, { message: "RequestPayload is an empty array." })
.max(10, { message: "RequestPayload has more than 10 elements." }),
error: {
code: 400,
error: "CONSTRAINT_VIOLATION",
errorMessage: "size must be between 1 and 10"
}
}
}

View File

@ -0,0 +1,13 @@
const z = require("zod")
module.exports = {
GET: {
params: z.object({
username: z.string().min(1, { message: "Username is required." })
}),
error: {
code: 404,
message: "Not Found"
}
}
}

View File

@ -0,0 +1,18 @@
const z = require("zod")
module.exports = {
POST: {
headers: z.object({
"content-type": z.string()
.regex(/application\/json/i, { message: "Content-Type must be application/json" })
}),
body: z.array(z.string().trim().min(1))
.min(1, { message: "RequestPayload is an empty array." })
.max(10, { message: "RequestPayload has more than 10 elements." }),
error: {
code: 400,
error: "CONSTRAINT_VIOLATION",
errorMessage: "size must be between 1 and 10"
}
}
}

View File

@ -0,0 +1,18 @@
const z = require("zod")
module.exports = {
GET: {
headers: z.object({
"content-type": z.string()
.regex(/application\/json/i, { message: "Content-Type must be application/json" })
.optional()
}),
params: z.object({
uuid: z.string().uuid({ message: "Invalid UUID format." })
}),
error: {
code: 204,
message: "No content"
}
}
}

View File

@ -0,0 +1,13 @@
const z = require("zod")
module.exports = {
GET: {
params: z.object({
username: z.string().min(1, { message: "Username is required." })
}),
error: {
code: 404,
message: "Not Found"
}
}
}

View File

@ -0,0 +1,16 @@
const z = require("zod")
module.exports = {
GET: {
params: z.object({
uuid: z.string().length(32).regex(/^[0-9a-fA-F]+$/, { message: "Invalid UUID (no dashes expected)." })
}),
query: z.object({
unsigned: z.enum(["true", "false"]).optional()
}),
error: {
code: 204,
message: "No content (UUID not found)"
}
}
}

View File

@ -73,12 +73,12 @@ async function getProfile({ uuid, unsigned = false }) {
const hasValidCape = !!activeCape const hasValidCape = !!activeCape
const skinNode = hasValidSkin ? { const skinNode = hasValidSkin ? {
url: activeSkin.url, url: (process.env.TEXTURES_ENDPOINTS || `http://localhost:${process.env.WEB_PORT}/textures/`) + activeSkin.url,
metadata: activeSkin.variant === "SLIM" ? { model: "slim" } : undefined metadata: activeSkin.variant === "SLIM" ? { model: "slim" } : undefined
} : undefined } : undefined
const capeNode = hasValidCape ? { const capeNode = hasValidCape ? {
url: activeCape.url url: (process.env.TEXTURES_ENDPOINTS || `http://localhost:${process.env.WEB_PORT}/textures/`) + activeCape.url
} : undefined } : undefined
const texturesObject = { const texturesObject = {

View File

@ -1,7 +1,9 @@
const fs = require("node:fs/promises") const fs = require("node:fs/promises")
const path = require("node:path")
const util = require("node:util") const util = require("node:util")
const logger = require("../modules/logger") const logger = require("../modules/logger")
const crypto = require("node:crypto") const crypto = require("node:crypto")
const ssrfcheck = require("ssrfcheck")
const certsManager = require("../modules/certificatesManager") const certsManager = require("../modules/certificatesManager")
const userRepository = require("../repositories/userRepository") const userRepository = require("../repositories/userRepository")
const { DefaultError } = require("../errors/errors") const { DefaultError } = require("../errors/errors")
@ -475,6 +477,7 @@ async function uploadSkin(uuid, fileObject, variant) {
async function uploadSkinFromUrl(uuid, url, variant) { async function uploadSkinFromUrl(uuid, url, variant) {
if (!url) throw new DefaultError(400, "Missing 'url' parameter.") if (!url) throw new DefaultError(400, "Missing 'url' parameter.")
if (ssrfcheck.isSSRFSafeURL(url)) throw new DefaultError(400, "Bad request", null)
let buffer let buffer
try { try {