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:
Kevin L'Hereec committed 2026-07-23 10:53:13 +02:00
1 parent ec6734f491
commit 3cc189c534
7 files changed
+417 -24

No files matched your search

+36 -4
View File
@@ -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()
})
+8 -2
View File
@@ -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)