fix(security): close IDOR on computers/components update & destroy
- Scope update/destroy queries by userId (Computers & Components), matching the pattern already used in index/show/edit - Add missing auth.authenticate() calls on destroy actions - handleComponents now requires userId and scopes component lookups to the current user, preventing cross-user component takeover - handleComponents rejects attaching more than one component of the same 'unique' type to a computer (new InvalidComponentSelectionException, 422) - handlePictures scopes picture lookups by computerId - destroy(Computer) now detaches its components (computerId = null) instead of losing them to cascade delete, and removes the computer's uploads dir - Switch store/update actions to request.validateUsing() for consistency - Fix relative redirects to absolute paths (/computers, /computers/:id) - Untrack .env.production and add it to .gitignore (secrets must not be committed); local file kept on disk
This commit is contained in:
1 parent
ec6734f491
commit
3cc189c534
7 files changed
+417
-24
No files matched your search
@@ -18,9 +18,8 @@ export default class ComponentsController {
|
||||
}
|
||||
|
||||
async store({ request, response, auth }: HttpContext) {
|
||||
const data = request.all()
|
||||
const user = await auth.authenticate()
|
||||
const { quantity, ...componentData } = await storeComponentValidator.validate(data)
|
||||
const { quantity, ...componentData } = await request.validateUsing(storeComponentValidator)
|
||||
await ComponentType.findOrFail(componentData.typeId)
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
await Component.create({
|
||||
@@ -59,9 +58,8 @@ export default class ComponentsController {
|
||||
}
|
||||
|
||||
async update({ params, auth, request, response }: HttpContext) {
|
||||
const data = request.all()
|
||||
const componentData = await updateComponentValidator.validate(data)
|
||||
const user = await auth.authenticate()
|
||||
const componentData = await request.validateUsing(updateComponentValidator)
|
||||
const component = await Component.query()
|
||||
.where('userId', user.id)
|
||||
.andWhere('id', params.id)
|
||||
@@ -74,9 +72,13 @@ export default class ComponentsController {
|
||||
return response.redirect('/components')
|
||||
}
|
||||
|
||||
async destroy({ params, response }: HttpContext) {
|
||||
async destroy({ params, auth, response }: HttpContext) {
|
||||
const user = await auth.authenticate()
|
||||
const componentId = params.id
|
||||
const component = await Component.findOrFail(componentId)
|
||||
const component = await Component.query()
|
||||
.where('userId', user.id)
|
||||
.andWhere('id', componentId)
|
||||
.firstOrFail()
|
||||
await component.delete()
|
||||
return response.redirect('/components')
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import { existsSync, rmSync } from 'node:fs'
|
||||
import Computer from '#models/computer'
|
||||
import ComputerState from '#models/computer_state'
|
||||
import Component from '#models/component'
|
||||
@@ -33,17 +34,17 @@ export default class ComputersController {
|
||||
}
|
||||
|
||||
async store({ auth, request, response }: HttpContext) {
|
||||
const data = request.all()
|
||||
const user = await auth.authenticate()
|
||||
const { pictures, componentsId, ...computerData } = await storeComputerValidator.validate(data)
|
||||
const { pictures, componentsId, ...computerData } =
|
||||
await request.validateUsing(storeComputerValidator)
|
||||
await ComputerState.findOrFail(computerData.stateId)
|
||||
const computer = await Computer.create({
|
||||
...computerData,
|
||||
userId: user.id,
|
||||
})
|
||||
await handleComponents(componentsId as number[], computer)
|
||||
await handleComponents(componentsId as number[], computer, user.id)
|
||||
await handlePictures(pictures as Image[], request, computer)
|
||||
return response.redirect('computers/')
|
||||
return response.redirect('/computers')
|
||||
}
|
||||
|
||||
async show({ auth, inertia, params }: HttpContext) {
|
||||
@@ -89,22 +90,36 @@ export default class ComputersController {
|
||||
return inertia.render('computers/edit', { computer: computer.serialize(), availableComponents: availableComponents.map((c) => c.serialize()), states: states.map((s) => s.serialize()) })
|
||||
}
|
||||
|
||||
async update({ params, request, response }: HttpContext) {
|
||||
async update({ auth, params, request, response }: HttpContext) {
|
||||
const user = await auth.authenticate()
|
||||
const computerId = params.id
|
||||
const { pictures, componentsId, ...computerData } = await updateComputerValidator.validate(
|
||||
request.all()
|
||||
const { pictures, componentsId, ...computerData } = await request.validateUsing(
|
||||
updateComputerValidator
|
||||
)
|
||||
const computer = await Computer.findOrFail(computerId)
|
||||
const computer = await Computer.query()
|
||||
.where('user_id', user.id)
|
||||
.andWhere('id', computerId)
|
||||
.firstOrFail()
|
||||
computer.merge(computerData)
|
||||
await computer.save()
|
||||
await handleComponents(componentsId as number[], computer)
|
||||
await handleComponents(componentsId as number[], computer, user.id)
|
||||
await handlePictures(pictures as Image[], request, computer)
|
||||
return response.redirect(`${computer.id}`)
|
||||
return response.redirect(`/computers/${computer.id}`)
|
||||
}
|
||||
|
||||
async destroy({ params, response }: HttpContext) {
|
||||
async destroy({ auth, params, response }: HttpContext) {
|
||||
const user = await auth.authenticate()
|
||||
const computerId = params.id
|
||||
const computer = await Computer.findOrFail(computerId)
|
||||
const computer = await Computer.query()
|
||||
.where('user_id', user.id)
|
||||
.andWhere('id', computerId)
|
||||
.firstOrFail()
|
||||
|
||||
const uploadsDir = `uploads/computer_${computer.id}`
|
||||
await computer.related('components').query().update({ computerId: null })
|
||||
if (existsSync(uploadsDir)) {
|
||||
rmSync(uploadsDir, { recursive: true, force: true })
|
||||
}
|
||||
await computer.delete()
|
||||
return response.redirect('/computers')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Exception } from '@adonisjs/core/exceptions'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
|
||||
/**
|
||||
* Thrown when a component selection is invalid, either because:
|
||||
* - the referenced component does not belong to the current user, or
|
||||
* - more than one component of the same "unique" type is being attached
|
||||
* to the same computer at once.
|
||||
*/
|
||||
export default class InvalidComponentSelectionException extends Exception {
|
||||
static status = 422
|
||||
static code = 'E_INVALID_COMPONENT_SELECTION'
|
||||
|
||||
handle(error: this, { response }: HttpContext) {
|
||||
return response.status(error.status).send({ errors: [{ message: error.message }] })
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,53 @@
|
||||
import Component from '#models/component'
|
||||
import Computer from '#models/computer'
|
||||
import InvalidComponentSelectionException from '#exceptions/invalid_component_selection_exception'
|
||||
|
||||
async function handleComponents(componentsId: number[], computer: Computer) {
|
||||
async function handleComponents(componentsId: number[], computer: Computer, userId: number) {
|
||||
const currentComponents = await computer.related('components').query()
|
||||
const currentComponentsIds = currentComponents.map((component) => component.id)
|
||||
const componentsToAdd = componentsId.filter((id) => !currentComponentsIds.includes(id))
|
||||
const componentsToRemove = currentComponentsIds.filter((id) => !componentsId.includes(id))
|
||||
|
||||
const componentsToAddRecords = await Promise.all(
|
||||
componentsToAdd.map((id: number) =>
|
||||
Component.query().where('userId', userId).andWhere('id', id).preload('type').firstOrFail()
|
||||
)
|
||||
)
|
||||
|
||||
// Ensure at most one component of each "unique" type ends up attached to
|
||||
// the computer once the additions/removals are applied.
|
||||
const remainingComponentIds = currentComponentsIds.filter(
|
||||
(id) => !componentsToRemove.includes(id)
|
||||
)
|
||||
const remainingComponents = remainingComponentIds.length
|
||||
? await Component.query().whereIn('id', remainingComponentIds).preload('type')
|
||||
: []
|
||||
const finalComponents = [...remainingComponents, ...componentsToAddRecords]
|
||||
|
||||
const uniqueTypeCounts = new Map<number, number>()
|
||||
for (const component of finalComponents) {
|
||||
if (!component.type.unique) continue
|
||||
const count = (uniqueTypeCounts.get(component.type.id) ?? 0) + 1
|
||||
uniqueTypeCounts.set(component.type.id, count)
|
||||
if (count > 1) {
|
||||
throw new InvalidComponentSelectionException(
|
||||
`Only one component of type "${component.type.name}" can be attached to a computer at a time.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
componentsToAdd.map(async (id: number) => {
|
||||
const component = await Component.findOrFail(id)
|
||||
componentsToAddRecords.map(async (component) => {
|
||||
component.computerId = computer.id
|
||||
await component.save()
|
||||
})
|
||||
)
|
||||
await Promise.all(
|
||||
componentsToRemove.map(async (id: number) => {
|
||||
const component = await Component.findOrFail(id)
|
||||
const component = await Component.query()
|
||||
.where('userId', userId)
|
||||
.andWhere('id', id)
|
||||
.firstOrFail()
|
||||
component.computerId = null
|
||||
await component.save()
|
||||
})
|
||||
|
||||
@@ -19,7 +19,10 @@ async function handlePictures(
|
||||
await Promise.all(
|
||||
pictures.map(async (picture: Image, index: number) => {
|
||||
if ('id' in picture) {
|
||||
const existingPicture = await ComputerPicture.findOrFail(picture.id)
|
||||
const existingPicture = await ComputerPicture.query()
|
||||
.where('computerId', computer.id)
|
||||
.andWhere('id', picture.id)
|
||||
.firstOrFail()
|
||||
existingPicture.order = picture.order
|
||||
await existingPicture.save()
|
||||
} else {
|
||||
@@ -44,7 +47,10 @@ async function handlePictures(
|
||||
|
||||
await Promise.all(
|
||||
imagesToRemove.map(async (id: number) => {
|
||||
const picture = await ComputerPicture.findOrFail(id)
|
||||
const picture = await ComputerPicture.query()
|
||||
.where('computerId', computer.id)
|
||||
.andWhere('id', id)
|
||||
.firstOrFail()
|
||||
const filePath = `uploads/computer_${computer.id}/${picture.link.split('/').pop()}`
|
||||
if (existsSync(filePath)) {
|
||||
unlinkSync(filePath)
|
||||
|
||||
Reference in new issue
Block a user