Skip to content
12 changes: 12 additions & 0 deletions app/models/measurement.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
measurements1hourView,
measurements1monthView,
measurements1yearView,
sensor,
} from '~/schema'
import {
type MinimalDevice,
Expand Down Expand Up @@ -287,3 +288,14 @@ export async function deleteMeasurementsForTime(date: Date) {
.delete(measurement)
.where(eq(measurement.time, date))
}

export async function deleteMeasurementsForDevice(deviceId: string) {
const sensorIds = drizzleClient
.select({ id: sensor.id })
.from(sensor)
.where(eq(sensor.deviceId, deviceId))

return await drizzleClient
.delete(measurement)
.where(inArray(measurement.sensorId, sensorIds))
}
151 changes: 151 additions & 0 deletions app/routes/device.$deviceId.edit.delete-measurements.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import * as React from 'react'
import { Trans, useTranslation } from 'react-i18next'
import {
type ActionFunctionArgs,
type LoaderFunctionArgs,
Form,
data,
redirect,
useActionData,
useLoaderData,
} from 'react-router'
import invariant from 'tiny-invariant'
import { Button } from '~/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '~/components/ui/card'
import { Input } from '~/components/ui/input'
import { Label } from '~/components/ui/label'
import { getUserDevice } from '~/models/device.server'
import { deleteMeasurementsForDevice } from '~/models/measurement.server'
import { verifyLogin } from '~/models/user.server'
import { getUserEmail, getUserId } from '~/utils/session.server'

export async function loader({ request, params }: LoaderFunctionArgs) {
const userId = await getUserId(request)
if (!userId) return redirect('/')

const deviceId = params.deviceId
invariant(typeof deviceId === 'string', 'Device id not found.')

const device = await getUserDevice({ id: deviceId, userId: userId })
if (!device) return redirect('/profile/me')

return data({ device })
}

export async function action({ request, params }: ActionFunctionArgs) {
const userId = await getUserId(request)
if (!userId) return redirect('/')

const deviceId = params.deviceId
invariant(typeof deviceId === 'string', 'Device id not found.')

const device = await getUserDevice({ id: deviceId, userId: userId })
if (!device) return redirect('/profile/me')

const formData = await request.formData()
const passwordConfirm = formData.get('passwordConfirm')
invariant(typeof passwordConfirm === 'string', 'password must be a string')

const userEmail = await getUserEmail(request)
invariant(typeof userEmail === 'string', 'email not found')

const user = await verifyLogin(userEmail, passwordConfirm)
if (!user) {
return data(
{
success: false,
noMeasurements: false,
errors: { passwordConfirm: 'Invalid password' } as { passwordConfirm: string } | null,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
},
{ status: 400 },
)
}

const result = await deleteMeasurementsForDevice(deviceId)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

sed -n '1,115p' 'app/routes/device.$deviceId.edit.delete-measurements.tsx'
sed -n '325,360p' app/db/models/measurement.server.ts
rg -n "update.*device|userId.*device|deviceId.*userId|set\\(.*userId|getUserDevice|deleteMeasurementsForDevice" app/db app/routes

Repository: openSenseMap/frontend

Length of output: 9733


Authorization Bypass (CWE-367): Time-of-check Time-of-use (TOCTOU) Race Condition

Reachability: External · Exploitability: Difficult

Make the ownership check atomic with deletion.

getUserDevice checks ownership before the separate, unscoped deletion. Pass userId to deleteMeasurementsForDevice and include it in the deletion predicate. Add a race test that transfers ownership between these operations and confirms that no measurements are deleted.

📍 Affects 2 files
  • app/routes/device.$deviceId.edit.delete-measurements.tsx#L70-L70 (this comment)
  • app/db/models/measurement.server.ts#L339-L347


return data({
success: true,
noMeasurements: result.count === 0,
errors: null as { passwordConfirm: string } | null,
})
}

export default function DeleteMeasurementsPage() {
const { device } = useLoaderData<typeof loader>()
const actionData = useActionData<typeof action>()
const passwordRef = React.useRef<HTMLInputElement>(null)
const [password, setPassword] = React.useState('')

const { t } = useTranslation('delete-measurements')

React.useEffect(() => {
if (actionData?.errors?.passwordConfirm) {
passwordRef.current?.focus()
}
if (actionData?.success) {
setPassword('')
}
}, [actionData])

return (
<Form method="post" className="space-y-6" noValidate>
<Card className="dark:border-white dark:bg-dark-boxes">
<CardHeader>
<CardTitle className="text-red-600">
{t('delete_measurements')}
</CardTitle>
<CardDescription>
<Trans
t={t}
i18nKey="confirm_permanent_deletion"
values={{ device: device.name }}
components={{ b: <b /> }}
/>
</CardDescription>
</CardHeader>

<CardContent className="space-y-4">
{actionData?.success && !actionData.noMeasurements && (
<div className="rounded border border-green-300 bg-green-50 p-3 text-sm text-green-700 dark:border-green-700 dark:bg-green-900/20 dark:text-green-400">
{t('delete_success')}
</div>
)}

{actionData?.success && actionData.noMeasurements && (
<div className="rounded border border-yellow-300 bg-yellow-50 p-3 text-sm text-yellow-700 dark:border-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-400">
{t('no_measurements')}
</div>
)}

<div className="space-y-2">
<Label htmlFor="passwordConfirm">{t('password')}</Label>
<Input
id="passwordConfirm"
name="passwordConfirm"
type="password"
ref={passwordRef}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
{actionData?.errors?.passwordConfirm && (
<div className="text-sm text-red-500">
{actionData.errors.passwordConfirm}
</div>
)}
</div>

<Button type="submit" variant="destructive" disabled={!password}>
{t('delete_measurements')}
</Button>
</CardContent>
</Card>
</Form>
)
}
6 changes: 6 additions & 0 deletions app/routes/device.$deviceId.edit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
Cpu,
ArrowLeft,
NotepadText,
Trash2,
} from 'lucide-react'
import { useState } from 'react'
import {
Expand Down Expand Up @@ -90,6 +91,11 @@ export default function EditBox() {
href: `/device/${deviceId}/edit/transfer`,
icon: ArrowRightLeft,
},
{
title: 'Delete Measurements',
href: `/device/${deviceId}/edit/delete-measurements`,
icon: Trash2,
},
]

return (
Expand Down
7 changes: 7 additions & 0 deletions public/locales/de/delete-measurements.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"delete_measurements": "Alle Messungen löschen",
"confirm_permanent_deletion": "Dadurch werden alle Messungen für alle Sensoren von <b>{{device}}</b> dauerhaft gelöscht. Das Gerät und seine Sensoren bleiben erhalten. Bitte bestätigen Sie mit Ihrem Passwort.",
"password": "Passwort",
"delete_success": "Alle Messungen wurden erfolgreich gelöscht.",
"no_measurements": "Dieses Gerät hat keine Messungen zum Löschen."
}
7 changes: 7 additions & 0 deletions public/locales/en/delete-measurements.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"delete_measurements": "Delete all measurements",
"confirm_permanent_deletion": "This will permanently delete all measurements for all sensors of <b>{{device}}</b>. The device and its sensors will remain. Please confirm with your password.",
"password": "Password",
"delete_success": "All measurements have been successfully deleted.",
"no_measurements": "This device has no measurements to delete."
}
Loading