fix: CSRF token sur les requetes mutantes /internal + IPv6 Docker + tests Connection Request
Build & Deploy / build (push) Successful in 30s
Build & Deploy / docker (push) Successful in 1m6s
Build & Deploy / deploy (push) Failing after 33s

- Ajout de fetchWithCsrf() helper qui lit le cookie XSRF-TOKEN et injecte
  X-XSRF-TOKEN sur toutes les requetes POST/PUT/PATCH/DELETE vers /internal/*
- Remplacement de fetch() par fetchWithCsrf() dans tous les composants Vue
  concernes (Tr069Trace, DeviceDetail, SequenceViewer, Home, configurations,
  firmware_catalog)
- Activation IPv6 sur les reseaux Docker (enable_ipv6: true, subnet fd00:adam::/64)
  dans docker-compose.yml et docker-compose.prod.yml
- Ajout tests unitaires DeviceService.sendWakeOnWlan : IPv4, IPv6, ENETUNREACH,
  credentials fallback chain (11 tests)
This commit is contained in:
kevin.lhereec committed 2026-07-17 10:16:29 +02:00
1 parent 63ee6f94f1
commit 8cddf98093
15 files changed
+347 -20

No files matched your search

+6
View File
@@ -38,3 +38,9 @@ volumes:
networks:
databases_default:
external: true
default:
enable_ipv6: true
ipam:
config:
- subnet: "172.28.0.0/16"
- subnet: "fd00:adam::/64"
+8
View File
@@ -55,3 +55,11 @@ volumes:
mongo_data:
app_tr069_data:
app_tr069_logs:
networks:
default:
enable_ipv6: true
ipam:
config:
- subnet: "172.28.0.0/16"
- subnet: "fd00:adam::/64"
+2 -1
View File
@@ -2,6 +2,7 @@
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatTimestamp, truncateMethod, needsTruncation, formatTraceStartTime } from '../helpers/sequence_viewer_helpers'
import { fetchWithCsrf } from '../helpers/fetch_with_csrf'
const { t } = useI18n()
@@ -270,7 +271,7 @@ async function confirmClear() {
isClearing.value = true
clearError.value = ''
try {
const response = await fetch(
const response = await fetchWithCsrf(
`/internal/tr069-trace/${encodeURIComponent(props.deviceId)}/exchanges`,
{ method: 'DELETE' }
)
+38
View File
@@ -0,0 +1,38 @@
/**
* Reads the XSRF-TOKEN cookie set by AdonisJS Shield and returns its decoded value.
* Returns an empty string if the cookie is not present.
*/
function getCsrfToken(): string {
const match = document.cookie
.split('; ')
.find((row) => row.startsWith('XSRF-TOKEN='))
if (!match) return ''
return decodeURIComponent(match.split('=').slice(1).join('='))
}
/**
* Wrapper around the native fetch() that automatically adds the
* X-XSRF-TOKEN header for state-mutating requests (POST, PUT, PATCH, DELETE)
* to satisfy AdonisJS Shield CSRF protection on /internal/* routes.
*
* Usage: drop-in replacement for fetch() — same signature.
*/
export function fetchWithCsrf(input: RequestInfo | URL, init: RequestInit = {}): Promise<Response> {
const method = (init.method ?? 'GET').toUpperCase()
const mutating = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)
if (mutating) {
const token = getCsrfToken()
if (token) {
init = {
...init,
headers: {
...init.headers,
'X-XSRF-TOKEN': token,
},
}
}
}
return fetch(input, init)
}
+6 -5
View File
@@ -3,6 +3,7 @@ import { ref } from 'vue'
import { router, Link } from '@inertiajs/vue3'
import { useI18n } from 'vue-i18n'
import TitleBar from '../components/TitleBar.vue'
import { fetchWithCsrf } from '../helpers/fetch_with_csrf'
const { t } = useI18n()
@@ -31,10 +32,10 @@ async function toggleTrace() {
tracingAction.value = true
try {
if (isTraced.value) {
await fetch(`/internal/tr069-trace/${encodeURIComponent(device.identifier)}`, { method: 'DELETE' })
await fetchWithCsrf(`/internal/tr069-trace/${encodeURIComponent(device.identifier)}`, { method: 'DELETE' })
isTraced.value = false
} else {
const response = await fetch('/internal/tr069-trace', {
const response = await fetchWithCsrf('/internal/tr069-trace', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deviceId: device.identifier }),
@@ -48,7 +49,7 @@ async function toggleTrace() {
checkTraceStatus()
async function deleteDevice() {
await fetch(`/internal/devices/${encodeURIComponent(device.identifier)}`, { method: 'DELETE' })
await fetchWithCsrf(`/internal/devices/${encodeURIComponent(device.identifier)}`, { method: 'DELETE' })
router.visit('/')
}
const showGetModelPopup = ref(false)
@@ -152,7 +153,7 @@ const resolvingProvider = ref<string | null>(null)
async function resolveProvider(providerName: string) {
resolvingProvider.value = providerName
try {
await fetch(`/internal/devices/${encodeURIComponent(device.identifier)}/references/resolve/${providerName}`, { method: 'POST' })
await fetchWithCsrf(`/internal/devices/${encodeURIComponent(device.identifier)}/references/resolve/${providerName}`, { method: 'POST' })
router.visit(`/devices/${encodeURIComponent(device.identifier)}`, { preserveScroll: true })
} catch { /* ignore */ }
finally { resolvingProvider.value = null }
@@ -161,7 +162,7 @@ async function resolveProvider(providerName: string) {
async function resolveAllProviders() {
resolvingProvider.value = '__all__'
try {
await fetch(`/internal/devices/${encodeURIComponent(device.identifier)}/references/resolve`, { method: 'POST' })
await fetchWithCsrf(`/internal/devices/${encodeURIComponent(device.identifier)}/references/resolve`, { method: 'POST' })
router.visit(`/devices/${encodeURIComponent(device.identifier)}`, { preserveScroll: true })
} catch { /* ignore */ }
finally { resolvingProvider.value = null }
+2 -1
View File
@@ -3,6 +3,7 @@ import { ref, computed, watch, onMounted } from 'vue'
import { router } from '@inertiajs/vue3'
import { useI18n } from 'vue-i18n'
import TitleBar from '../components/TitleBar.vue'
import { fetchWithCsrf } from '../helpers/fetch_with_csrf'
const { t } = useI18n()
@@ -78,7 +79,7 @@ async function deleteSelected() {
try {
await Promise.all(
selectedIdentifiers.value.map((id) =>
fetch(`/internal/devices/${encodeURIComponent(id)}`, { method: 'DELETE' })
fetchWithCsrf(`/internal/devices/${encodeURIComponent(id)}`, { method: 'DELETE' })
)
)
router.reload()
+4 -3
View File
@@ -5,6 +5,7 @@ import TitleBar from '~/components/TitleBar.vue'
import SequenceViewer from '~/components/SequenceViewer.vue'
import InspectorPanel from '~/components/InspectorPanel.vue'
import type { CwmpExchange } from '~/composables/useExchangeStream'
import { fetchWithCsrf } from '~/helpers/fetch_with_csrf'
type TraceEntry = {
deviceId: string
@@ -94,7 +95,7 @@ async function removeDevice(deviceId: string) {
removingDeviceId.value = deviceId
actionError.value = ''
try {
const response = await fetch(`/internal/tr069-trace/${encodeURIComponent(deviceId)}`, {
const response = await fetchWithCsrf(`/internal/tr069-trace/${encodeURIComponent(deviceId)}`, {
method: 'DELETE',
})
if (!response.ok) {
@@ -114,7 +115,7 @@ async function enableTrace(deviceId: string) {
actionError.value = ''
pendingDeviceId.value = deviceId
try {
const response = await fetch('/internal/tr069-trace', {
const response = await fetchWithCsrf('/internal/tr069-trace', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deviceId }),
@@ -168,7 +169,7 @@ async function saveJsonConfig() {
}
try {
const response = await fetch('/internal/tr069-trace/config', {
const response = await fetchWithCsrf('/internal/tr069-trace/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ raw: jsonContent.value }),
@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n'
import TitleBar from '~/components/TitleBar.vue'
import TagsInput from '~/components/configuration/TagsInput.vue'
import CriteriaEditor from '~/components/configuration/CriteriaEditor.vue'
import { fetchWithCsrf } from '~/helpers/fetch_with_csrf'
const { t } = useI18n()
@@ -42,7 +43,7 @@ function removeParameter(index: number) {
async function submitForm() {
errorMsg.value = ''
try {
const response = await fetch('/internal/configurations', {
const response = await fetchWithCsrf('/internal/configurations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form.value),
@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n'
import TitleBar from '~/components/TitleBar.vue'
import PolicyBadge from '~/components/configuration/PolicyBadge.vue'
import ComplianceBadge from '~/components/configuration/ComplianceBadge.vue'
import { fetchWithCsrf } from '~/helpers/fetch_with_csrf'
const { t } = useI18n()
const props = defineProps<{ id: string }>()
@@ -68,7 +69,7 @@ async function runDryRun(deviceIdentifier?: string) {
showDryRunModal.value = true
try {
const res = await fetch(`/internal/configurations/${props.id}/dry-run`, {
const res = await fetchWithCsrf(`/internal/configurations/${props.id}/dry-run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deviceIdentifier: targetDevice }),
@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n'
import TitleBar from '~/components/TitleBar.vue'
import TagsInput from '~/components/configuration/TagsInput.vue'
import CriteriaEditor from '~/components/configuration/CriteriaEditor.vue'
import { fetchWithCsrf } from '~/helpers/fetch_with_csrf'
const { t } = useI18n()
@@ -51,7 +52,7 @@ function removeParameter(i: number) { form.value.desiredParameters.splice(i, 1)
async function submitForm() {
errorMsg.value = ''
try {
const res = await fetch(`/internal/configurations/${props.id}`, {
const res = await fetchWithCsrf(`/internal/configurations/${props.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form.value),
@@ -4,6 +4,7 @@ import { router } from '@inertiajs/vue3'
import { useI18n } from 'vue-i18n'
import TitleBar from '~/components/TitleBar.vue'
import PolicyBadge from '~/components/configuration/PolicyBadge.vue'
import { fetchWithCsrf } from '~/helpers/fetch_with_csrf'
const { t } = useI18n()
@@ -81,7 +82,7 @@ function removeFilter(key: keyof typeof filters.value) {
}
async function toggleEnabled(id: string, enabled: boolean) {
await fetch(`/internal/configurations/${id}/toggle`, {
await fetchWithCsrf(`/internal/configurations/${id}/toggle`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled }),
@@ -90,8 +91,8 @@ async function toggleEnabled(id: string, enabled: boolean) {
}
async function deleteConfig(id: string, name: string) {
if (!confirm(`Delete configuration "${name}"?`)) return
await fetch(`/internal/configurations/${id}`, { method: 'DELETE' })
if (!confirm(`Delete configuration "${name}\"?`)) return
await fetchWithCsrf(`/internal/configurations/${id}`, { method: 'DELETE' })
await loadConfigurations()
}
@@ -3,6 +3,7 @@ import { ref, onMounted, watch } from 'vue'
import { router } from '@inertiajs/vue3'
import { useI18n } from 'vue-i18n'
import TitleBar from '~/components/TitleBar.vue'
import { fetchWithCsrf } from '~/helpers/fetch_with_csrf'
const { t } = useI18n()
const entries = ref<any[]>([])
@@ -33,13 +34,13 @@ async function loadRoutes() {
async function deleteEntry(identifier: string) {
if (!confirm(t('modal.confirmDeleteMessage'))) return
await fetch(`/internal/firmware-catalog/entries/${identifier}`, { method: 'DELETE' })
await fetchWithCsrf(`/internal/firmware-catalog/entries/${identifier}`, { method: 'DELETE' })
await loadEntries()
}
async function deleteRoute(identifier: string) {
if (!confirm(t('modal.confirmDeleteMessage'))) return
await fetch(`/internal/firmware-catalog/routes/${identifier}`, { method: 'DELETE' })
await fetchWithCsrf(`/internal/firmware-catalog/routes/${identifier}`, { method: 'DELETE' })
await loadRoutes()
}
@@ -3,6 +3,7 @@ import { ref, onMounted } from 'vue'
import { router } from '@inertiajs/vue3'
import { useI18n } from 'vue-i18n'
import TitleBar from '~/components/TitleBar.vue'
import { fetchWithCsrf } from '~/helpers/fetch_with_csrf'
const { t } = useI18n()
const form = ref({
@@ -26,7 +27,7 @@ onMounted(async () => {
async function submitForm() {
try {
const res = await fetch('/internal/firmware-catalog/entries', {
const res = await fetchWithCsrf('/internal/firmware-catalog/entries', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form.value),
@@ -3,6 +3,7 @@ import { ref, onMounted } from 'vue'
import { router } from '@inertiajs/vue3'
import { useI18n } from 'vue-i18n'
import TitleBar from '~/components/TitleBar.vue'
import { fetchWithCsrf } from '~/helpers/fetch_with_csrf'
const { t } = useI18n()
const props = defineProps<{ id: string }>()
@@ -36,7 +37,7 @@ onMounted(async () => {
async function submitForm() {
try {
const res = await fetch(`/internal/firmware-catalog/entries/${props.id}`, {
const res = await fetchWithCsrf(`/internal/firmware-catalog/entries/${props.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form.value),
+264
View File
@@ -0,0 +1,264 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import DeviceService from '../../../app/services/device_service.js'
import type { DeviceRecord } from '../../../app/domain/device/device_record.js'
// ── Mocks ──────────────────────────────────────────────────────────────────────
vi.mock('@adonisjs/core/services/logger', () => ({
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}))
vi.mock('../../../app/models/device.js', () => ({
default: {
findByIdentifier: vi.fn(),
paginateDevices: vi.fn(),
deleteOne: vi.fn(),
},
}))
vi.mock('../../../app/models/command.js', () => ({
default: {
createCommand: vi.fn(),
getCollection: vi.fn(() => ({
findOne: vi.fn().mockResolvedValue(null),
find: vi.fn(() => ({ toArray: vi.fn().mockResolvedValue([]) })),
updateOne: vi.fn(),
})),
},
}))
// ── Helpers ────────────────────────────────────────────────────────────────────
function makeDevice(overrides: Partial<DeviceRecord> = {}): DeviceRecord {
return {
identifier: 'TestMfr-AABBCC-TestBox-SN001',
deviceId: { manufacturer: 'TestMfr', oui: 'AABBCC', productClass: 'TestBox', serialNumber: 'SN001' },
properties: [],
parameters: [],
...overrides,
} as unknown as DeviceRecord
}
function makeProperty(name: string, value: string) {
return { name, value }
}
// ── Tests ──────────────────────────────────────────────────────────────────────
describe('DeviceService — sendWakeOnWlan / Connection Request', () => {
let service: DeviceService
let fetchMock: ReturnType<typeof vi.fn>
beforeEach(() => {
service = new DeviceService()
fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
})
afterEach(() => {
vi.unstubAllGlobals()
vi.clearAllMocks()
})
// ── IPv4 ───────────────────────────────────────────────────────────────────
describe('IPv4 Connection Request', () => {
it('retourne true quand le CPE répond 200 sur une URL IPv4', async () => {
const device = makeDevice({
properties: [
makeProperty('Device.ManagementServer.ConnectionRequestURL', 'http://192.168.1.100:7547/cr'),
makeProperty('Device.ManagementServer.Username', 'admin'),
makeProperty('Device.ManagementServer.Password', 'secret'),
],
})
fetchMock.mockResolvedValue({ ok: true, status: 200 })
const result = await (service as any).sendWakeOnWlan(
new URL('http://192.168.1.100:7547/cr'),
device
)
expect(result).toBe(true)
expect(fetchMock).toHaveBeenCalledOnce()
const [calledUrl] = fetchMock.mock.calls[0]
expect(calledUrl).toBe('http://192.168.1.100:7547/cr')
})
it('retourne false et log error quand le CPE répond 401', async () => {
const device = makeDevice({
properties: [makeProperty('Device.ManagementServer.ConnectionRequestURL', 'http://10.0.0.1:7547/')],
})
fetchMock.mockResolvedValue({ ok: false, status: 401, statusText: 'Unauthorized' })
const result = await (service as any).sendWakeOnWlan(
new URL('http://10.0.0.1:7547/'),
device
)
expect(result).toBe(false)
})
it('retourne false quand fetch lève une erreur réseau IPv4', async () => {
const device = makeDevice({
properties: [makeProperty('Device.ManagementServer.ConnectionRequestURL', 'http://10.0.0.1:7547/')],
})
const networkError = Object.assign(new Error('fetch failed'), { cause: { code: 'ECONNREFUSED' } })
fetchMock.mockRejectedValue(networkError)
const result = await (service as any).sendWakeOnWlan(
new URL('http://10.0.0.1:7547/'),
device
)
expect(result).toBe(false)
})
})
// ── IPv6 ───────────────────────────────────────────────────────────────────
describe('IPv6 Connection Request', () => {
it('retourne true quand le CPE répond 200 sur une URL IPv6', async () => {
const ipv6Url = 'http://[2a01:cb15:890f:4a00:56ec:b0ff:fe9c:e490]:50895/kOmisavuzLYxpksz'
const device = makeDevice({
properties: [
makeProperty('Device.ManagementServer.ConnectionRequestURL', ipv6Url),
makeProperty('Device.ManagementServer.Username', 'user'),
makeProperty('Device.ManagementServer.Password', 'pass'),
],
})
fetchMock.mockResolvedValue({ ok: true, status: 200 })
const result = await (service as any).sendWakeOnWlan(new URL(ipv6Url), device)
expect(result).toBe(true)
expect(fetchMock).toHaveBeenCalledOnce()
const [calledUrl, init] = fetchMock.mock.calls[0]
// L'URL doit conserver les crochets IPv6
expect(calledUrl).toBe(ipv6Url)
// Le header Authorization doit être présent
expect(init.headers?.Authorization).toMatch(/^Basic /)
})
it('envoie la bonne URL IPv6 complète avec path et port', async () => {
const ipv6Url = 'http://[::1]:8080/wake'
const device = makeDevice({
properties: [makeProperty('Device.ManagementServer.ConnectionRequestURL', ipv6Url)],
})
fetchMock.mockResolvedValue({ ok: true, status: 204 })
await (service as any).sendWakeOnWlan(new URL(ipv6Url), device)
expect(fetchMock.mock.calls[0][0]).toBe('http://[::1]:8080/wake')
})
it('retourne false et ENETUNREACH est loggé quand l\'IPv6 n\'est pas joignable', async () => {
const ipv6Url = 'http://[2a01:cb15:890f:4a00:56ec:b0ff:fe9c:e490]:50895/cr'
const device = makeDevice({
properties: [makeProperty('Device.ManagementServer.ConnectionRequestURL', ipv6Url)],
})
const networkError = Object.assign(new Error('fetch failed'), {
cause: { code: 'ENETUNREACH' },
})
fetchMock.mockRejectedValue(networkError)
const result = await (service as any).sendWakeOnWlan(new URL(ipv6Url), device)
expect(result).toBe(false)
})
it('retourne false quand le CPE IPv6 répond 403', async () => {
const ipv6Url = 'http://[fe80::1]:7547/cr'
const device = makeDevice({
properties: [makeProperty('Device.ManagementServer.ConnectionRequestURL', ipv6Url)],
})
fetchMock.mockResolvedValue({ ok: false, status: 403, statusText: 'Forbidden' })
const result = await (service as any).sendWakeOnWlan(new URL(ipv6Url), device)
expect(result).toBe(false)
})
})
// ── Credentials fallback chain ─────────────────────────────────────────────
describe('Construction des credentials (Basic Auth)', () => {
it('utilise ManagementServer.Username/Password en priorité', async () => {
const device = makeDevice({
properties: [
makeProperty('Device.ManagementServer.ConnectionRequestURL', 'http://1.2.3.4:7547/'),
makeProperty('Device.ManagementServer.Username', 'mgmtUser'),
makeProperty('Device.ManagementServer.Password', 'mgmtPass'),
makeProperty('Device.ManagementServer.ConnectionRequestUsername', 'crUser'),
makeProperty('Device.ManagementServer.ConnectionRequestPassword', 'crPass'),
],
})
fetchMock.mockResolvedValue({ ok: true, status: 200 })
await (service as any).sendWakeOnWlan(new URL('http://1.2.3.4:7547/'), device)
const authHeader: string = fetchMock.mock.calls[0][1].headers.Authorization
const decoded = Buffer.from(authHeader.replace('Basic ', ''), 'base64').toString('utf-8')
expect(decoded).toBe('mgmtUser:mgmtPass')
})
it('utilise ConnectionRequestUsername/Password si ManagementServer absent', async () => {
const device = makeDevice({
properties: [
makeProperty('Device.ManagementServer.ConnectionRequestURL', 'http://1.2.3.4:7547/'),
makeProperty('Device.ManagementServer.ConnectionRequestUsername', 'crUser'),
makeProperty('Device.ManagementServer.ConnectionRequestPassword', 'crPass'),
],
})
fetchMock.mockResolvedValue({ ok: true, status: 200 })
await (service as any).sendWakeOnWlan(new URL('http://1.2.3.4:7547/'), device)
const authHeader: string = fetchMock.mock.calls[0][1].headers.Authorization
const decoded = Buffer.from(authHeader.replace('Basic ', ''), 'base64').toString('utf-8')
expect(decoded).toBe('crUser:crPass')
})
it('utilise OUI-ProductClass-SerialNumber comme username par défaut', async () => {
const device = makeDevice({
properties: [
makeProperty('Device.ManagementServer.ConnectionRequestURL', 'http://1.2.3.4:7547/'),
],
})
fetchMock.mockResolvedValue({ ok: true, status: 200 })
await (service as any).sendWakeOnWlan(new URL('http://1.2.3.4:7547/'), device)
const authHeader: string = fetchMock.mock.calls[0][1].headers.Authorization
const decoded = Buffer.from(authHeader.replace('Basic ', ''), 'base64').toString('utf-8')
// format: OUI-ProductClass-SerialNumber:
expect(decoded).toBe('AABBCC-TestBox-SN001:')
})
it('fonctionne avec un password vide', async () => {
const device = makeDevice({
properties: [
makeProperty('Device.ManagementServer.ConnectionRequestURL', 'http://1.2.3.4:7547/'),
makeProperty('Device.ManagementServer.Username', 'admin'),
],
})
fetchMock.mockResolvedValue({ ok: true, status: 200 })
await (service as any).sendWakeOnWlan(new URL('http://1.2.3.4:7547/'), device)
const authHeader: string = fetchMock.mock.calls[0][1].headers.Authorization
const decoded = Buffer.from(authHeader.replace('Basic ', ''), 'base64').toString('utf-8')
expect(decoded).toBe('admin:')
})
})
})