#246: mode enforced — reject profile-violating configuration writes #295
@ -1,10 +1,11 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { DEFAULT_ATTACHMENT_EXTENSIONS } from '@dorfteich/shared';
|
||||
import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { DEFAULT_ATTACHMENT_EXTENSIONS, VS_NFD_PROFILE, isVsNfdCompliant } from '@dorfteich/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { AppConfig } from '../config/app-config.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
/**
|
||||
@ -201,6 +202,7 @@ export class InstanceSettingsService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly logger: PinoLogger,
|
||||
private readonly config: AppConfig,
|
||||
) {
|
||||
this.logger.setContext(InstanceSettingsService.name);
|
||||
}
|
||||
@ -237,6 +239,22 @@ export class InstanceSettingsService {
|
||||
details: { [key]: parsed.error.issues.map((i) => i.message) },
|
||||
});
|
||||
}
|
||||
// Mode `enforced` (#246, ADR 0027): a write that would set a
|
||||
// catalog-violating value is rejected at the ONE write path every
|
||||
// caller uses — hiding alone (#245) is UI cosmetics a scripted client
|
||||
// bypasses. Existing violating values are reported (startup log,
|
||||
// admin card), never auto-changed: the operator resolves them
|
||||
// consciously, and writes that DECREASE compliance are what this
|
||||
// blocks. 403, not 400: the request is well-formed, the policy says no.
|
||||
if (this.config.env.VS_NFD_MODE === 'enforced') {
|
||||
const entry = VS_NFD_PROFILE.find((e) => e.scope === 'instance' && e.key === key);
|
||||
if (entry && !isVsNfdCompliant(entry, parsed.data)) {
|
||||
throw new ForbiddenException({
|
||||
code: 'vs_nfd_profile_violation',
|
||||
details: { [key]: ['vs_nfd_profile_violation'] },
|
||||
});
|
||||
}
|
||||
}
|
||||
// Nullable settings (setup.completedAt) store JSON null explicitly —
|
||||
// Prisma requires the sentinel for that.
|
||||
const stored = parsed.data === null ? Prisma.JsonNull : parsed.data;
|
||||
|
||||
143
apps/api/src/settings/vs-nfd-enforced.e2e.db.test.ts
Normal file
143
apps/api/src/settings/vs-nfd-enforced.e2e.db.test.ts
Normal file
@ -0,0 +1,143 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { type VsNfdProfileView } from '@dorfteich/shared';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
||||
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
||||
import { UsersService } from '../users/users.service';
|
||||
|
||||
/**
|
||||
* Mode `enforced` (issue #246, ADR 0027): the api rejects settings writes
|
||||
* that would set a catalog-violating value — with a stable error code —
|
||||
* while existing violations are reported and never auto-changed. The mode
|
||||
* is env-fixed per boot, so each mode gets its own app (sequential
|
||||
* describes; pattern vs-nfd-profile.e2e.db.test.ts).
|
||||
*/
|
||||
async function makeAdmin(
|
||||
app: INestApplication,
|
||||
prisma: PrismaClient,
|
||||
suffix: string,
|
||||
): Promise<string> {
|
||||
const users = app.get(UsersService);
|
||||
const username = `enf-admin-${suffix}`;
|
||||
const user = await users.createUser({
|
||||
username,
|
||||
email: `${username}@example.org`,
|
||||
displayName: 'Enf Admin',
|
||||
password: 'erzwungen ist erzwungen 1',
|
||||
locale: 'en',
|
||||
});
|
||||
await users.markEmailVerified(user.id);
|
||||
await prisma.user.update({ where: { id: user.id }, data: { isSiteAdmin: true } });
|
||||
return sessionCookieOf(
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v1/auth/login')
|
||||
.send({ usernameOrEmail: username, password: 'erzwungen ist erzwungen 1' })
|
||||
.expect(200),
|
||||
);
|
||||
}
|
||||
|
||||
describe.skipIf(!hasTestDb)('VS-NfD mode enforced (e2e, issue #246)', () => {
|
||||
let app: INestApplication;
|
||||
let prisma: PrismaClient;
|
||||
let cookie: string;
|
||||
const suffix = uniqueSuffix();
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.VS_NFD_MODE = 'enforced';
|
||||
prisma = createTestPrisma();
|
||||
await prisma.rateLimit.deleteMany({});
|
||||
// A violation that exists BEFORE the enforced boot: reported, never
|
||||
// auto-changed (feeds.enabled default is true = violating anyway, but
|
||||
// pin it as an explicit stored row).
|
||||
await prisma.instanceSetting.upsert({
|
||||
where: { key: 'feeds.enabled' },
|
||||
create: { key: 'feeds.enabled', value: true },
|
||||
update: { value: true },
|
||||
});
|
||||
app = await createTestApp();
|
||||
cookie = await makeAdmin(app, prisma, suffix);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
delete process.env.VS_NFD_MODE;
|
||||
await prisma.instanceSetting.deleteMany({
|
||||
where: { key: { in: ['feeds.enabled', 'auth.registrationMode', 'upload.svgPolicy'] } },
|
||||
});
|
||||
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
|
||||
await prisma.$disconnect();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('rejects a violating write with the stable error code', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.patch('/api/v1/admin/settings')
|
||||
.set('Cookie', cookie)
|
||||
.send({ 'upload.svgPolicy': 'sanitize' })
|
||||
.expect(403);
|
||||
expect(res.body.code).toBe('vs_nfd_profile_violation');
|
||||
// Nothing was stored — the read still returns the schema default.
|
||||
const settings = await request(app.getHttpServer())
|
||||
.get('/api/v1/admin/settings')
|
||||
.set('Cookie', cookie)
|
||||
.expect(200);
|
||||
expect(settings.body['upload.svgPolicy']).toBe('sanitize');
|
||||
});
|
||||
|
||||
it('accepts compliant writes', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.patch('/api/v1/admin/settings')
|
||||
.set('Cookie', cookie)
|
||||
.send({ 'auth.registrationMode': 'closed', 'upload.svgPolicy': 'reject' })
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it('reports the pre-existing violation and never auto-changes it', async () => {
|
||||
const profile = (
|
||||
await request(app.getHttpServer())
|
||||
.get('/api/v1/admin/system/vs-nfd-profile')
|
||||
.set('Cookie', cookie)
|
||||
.expect(200)
|
||||
).body as VsNfdProfileView;
|
||||
expect(profile.mode).toBe('enforced');
|
||||
expect(profile.entries.find((e) => e.key === 'feeds.enabled')!.compliant).toBe(false);
|
||||
const settings = await request(app.getHttpServer())
|
||||
.get('/api/v1/admin/settings')
|
||||
.set('Cookie', cookie)
|
||||
.expect(200);
|
||||
expect(settings.body['feeds.enabled']).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!hasTestDb)('the same write passes outside enforced (issue #246)', () => {
|
||||
let app: INestApplication;
|
||||
let prisma: PrismaClient;
|
||||
let cookie: string;
|
||||
const suffix = uniqueSuffix();
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.VS_NFD_MODE = 'hidden';
|
||||
prisma = createTestPrisma();
|
||||
await prisma.rateLimit.deleteMany({});
|
||||
app = await createTestApp();
|
||||
cookie = await makeAdmin(app, prisma, suffix);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
delete process.env.VS_NFD_MODE;
|
||||
await prisma.instanceSetting.deleteMany({ where: { key: 'upload.svgPolicy' } });
|
||||
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
|
||||
await prisma.$disconnect();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('mode hidden: the violating write is NOT rejected (UI-level only)', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.patch('/api/v1/admin/settings')
|
||||
.set('Cookie', cookie)
|
||||
.send({ 'upload.svgPolicy': 'sanitize' })
|
||||
.expect(200);
|
||||
});
|
||||
});
|
||||
@ -93,6 +93,14 @@ describe.skipIf(!hasTestDb)('VS-NfD profile endpoint (e2e, issue #243)', () => {
|
||||
expect(view.violations).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('mode marked: a violating write is NOT rejected (issue #246 contrast)', async () => {
|
||||
await api()
|
||||
.patch('/api/v1/admin/settings')
|
||||
.set('Cookie', cookies.admin!)
|
||||
.send({ 'upload.svgPolicy': 'sanitize' })
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it('verdict follows a settings change', async () => {
|
||||
const settings = app.get(InstanceSettingsService);
|
||||
const before = (
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, type OnModuleInit } from '@nestjs/common';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
import {
|
||||
VS_NFD_PROFILE,
|
||||
describeCompliance,
|
||||
@ -20,11 +21,33 @@ import { InstanceSettingsService, type InstanceSettings } from './instance-setti
|
||||
* (#244–#246) build on this evaluation; here it is exposure only.
|
||||
*/
|
||||
@Injectable()
|
||||
export class VsNfdProfileService {
|
||||
export class VsNfdProfileService implements OnModuleInit {
|
||||
constructor(
|
||||
private readonly settings: InstanceSettingsService,
|
||||
private readonly config: AppConfig,
|
||||
) {}
|
||||
private readonly logger: PinoLogger,
|
||||
) {
|
||||
this.logger.setContext(VsNfdProfileService.name);
|
||||
}
|
||||
|
||||
/** Startup report (#246): existing violations are stated once, never
|
||||
* auto-changed — the operator resolves them consciously. Must not throw
|
||||
* on a database-less boot (healthz suites boot without a db). */
|
||||
async onModuleInit(): Promise<void> {
|
||||
if (this.config.env.VS_NFD_MODE === 'off') return;
|
||||
try {
|
||||
const view = await this.evaluate();
|
||||
this.logger.info(
|
||||
{
|
||||
mode: view.mode,
|
||||
violations: view.entries.filter((e) => !e.compliant).map((e) => e.key),
|
||||
},
|
||||
'VS-NfD profile verdict at startup',
|
||||
);
|
||||
} catch {
|
||||
// Stated at the next successful evaluation instead.
|
||||
}
|
||||
}
|
||||
|
||||
private valueOf(entry: VsNfdProfileEntry, settings: InstanceSettings): unknown {
|
||||
return entry.scope === 'instance'
|
||||
|
||||
@ -47,18 +47,18 @@ Settings-Cache ist in-process (operations.md).
|
||||
|
||||
### 1.2 Deploy-Konfiguration (`.env` / Compose — nur Plattformzugriff, bewusst nicht per Admin-UI)
|
||||
|
||||
| Variable | Referenzwert | Warum |
|
||||
| --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `BACKUP_ALLOWED_TARGETS` | leer lassen **oder** exakt der eine freigegebene Spiegel-Host | leere Allowlist schaltet ALLE Fernziele hart ab (ADR 0026, #192) — „Backup nur lokal" ist damit deploy-seitig erzwungen und vom Site-Admin nicht aufweichbar (Rollentrennung, Betriebshandbuch §6). |
|
||||
| `SESSION_ABSOLUTE_HOURS` | `12` (Default 168) | eine Sitzung überdauert keinen Arbeitstag; Neuanmeldung am nächsten Tag ist der Preis. |
|
||||
| `SESSION_IDLE_HOURS` | `2` (Default 72) | unbeaufsichtigte, noch angemeldete Arbeitsplätze fallen schnell zurück auf die Anmeldemaske. |
|
||||
| `SMTP_HOST` etc. | **unkonfiguriert lassen** (oder internes Relay) | ohne SMTP verlassen keinerlei Inhaltstitel die Instanz per Mail (Digest-Restrisiko I-23 entfällt vollständig). Konsequenz ehrlich benannt: dann gibt es keine Verifikations- und Passwort-Reset-Mails — Kontenpflege läuft über den Site-Admin. Wer Mail braucht, nutzt ein internes Relay und akzeptiert I-23 (Restrisikoliste). |
|
||||
| `WEB_PORT`/`API_PORT`/`COLLAB_PORT` | Defaults (127.0.0.1-gebunden) | Anwendungscontainer sind nie direkt exponiert; einzige Eintrittsstelle ist der Reverse Proxy (Sicherheitsdokumentation §2). |
|
||||
| `LOG_LEVEL` | `info` | Audit-Zeilen (`audit: `-Präfix) müssen den Collector erreichen; `debug` nur zur Störungssuche. |
|
||||
| `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_SCOPES`, `OIDC_PROVIDER_LABEL` | IdP der Behörde konfigurieren | Fremdauthentisierung (#214, ADR 0021): Authorization Code + PKCE gegen den IdP der Umgebung; deploy-seitig, weil die Authentisierungshoheit Plattformsache ist. Erst-Login legt Konten just-in-time an; bestehende lokale Konten werden NIE stillschweigend per E-Mail übernommen (expliziter Link-Flow). Konfigurationsdetails: security.md §External authentication. |
|
||||
| `AUTH_PROXY_HEADER`, `AUTH_PROXY_TRUSTED_PEERS`, `AUTH_PROXY_MAP`, `AUTH_PROXY_MODE`, `AUTH_PROXY_DN_ATTRIBUTE` | nur bei Perimeter-Authentisierung setzen | Alternativpfad (#215): Identität aus dem Proxy-Header, gültig NUR vom TCP-Peer der Allowlist; fremder Peer mit Header wird abgewiesen und auditiert (`auth.proxy_rejected`). Der Proxy MUSS den Header aus eingehendem Verkehr strippen. mTLS-Variante über weitergereichten Zertifikats-DN (`mtls-dn`). Ohne Perimeter-Auth: unkonfiguriert lassen (Header wirkungslos). |
|
||||
| `AUTH_LOCAL_ENABLED` | `false` (Default `true`) | **der** harte Schalter aus ADR 0021 (#216, deploy-seitige Realisierung von `auth.local.enabled`): `false` schaltet JEDEN lokalen Credential-Flow auf 404 (Login, Signup, Verifikation, Passwort vergessen/zuruecksetzen/aendern) — Anmeldung ausschliesslich ueber OIDC (#214) bzw. Perimeter-Auth (#215). Deploy-seitig, damit ein kompromittierter Site-Admin ihn nicht zurueckdrehen kann. Reihenfolge: erst Setup/Bootstrap (SETUP_ADMIN_*), dann auf `false`. PAT-/Feed-Token-Ausgabe bleibt fuer IdP-authentisierte Nutzer verfuegbar (eigene Schalter `api.enabled`/`feeds.enabled`; bewusste Entscheidung #216). |
|
||||
| `VS_NFD_MODE` | `marked` (Default `off`; Ziel `enforced`) | Härtungsprofil-Modus (#243, ADR 0027): die Anwendung kennt diese Referenzkonfiguration als maschinenlesbaren Katalog und bewertet die laufende Konfiguration dagegen (Site-Admin → Einstellungen). `marked` markiert Abweichungen, `hidden` blendet abweichende Optionen aus, `enforced` weist abweichende Schreibzugriffe serverseitig ab — die drei Behandlungsstufen rollen mit #244–#246 aus; bis dahin ist `marked` der empfohlene Wert (reine Anzeige). Deploy-seitig wie `BACKUP_ALLOWED_TARGETS`: ein kompromittierter Site-Admin kann den Modus nicht aufweichen. Außerhalb von VS-Kontexten bleibt der Default `off` — keinerlei Markierung. |
|
||||
| Variable | Referenzwert | Warum |
|
||||
| --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `BACKUP_ALLOWED_TARGETS` | leer lassen **oder** exakt der eine freigegebene Spiegel-Host | leere Allowlist schaltet ALLE Fernziele hart ab (ADR 0026, #192) — „Backup nur lokal" ist damit deploy-seitig erzwungen und vom Site-Admin nicht aufweichbar (Rollentrennung, Betriebshandbuch §6). |
|
||||
| `SESSION_ABSOLUTE_HOURS` | `12` (Default 168) | eine Sitzung überdauert keinen Arbeitstag; Neuanmeldung am nächsten Tag ist der Preis. |
|
||||
| `SESSION_IDLE_HOURS` | `2` (Default 72) | unbeaufsichtigte, noch angemeldete Arbeitsplätze fallen schnell zurück auf die Anmeldemaske. |
|
||||
| `SMTP_HOST` etc. | **unkonfiguriert lassen** (oder internes Relay) | ohne SMTP verlassen keinerlei Inhaltstitel die Instanz per Mail (Digest-Restrisiko I-23 entfällt vollständig). Konsequenz ehrlich benannt: dann gibt es keine Verifikations- und Passwort-Reset-Mails — Kontenpflege läuft über den Site-Admin. Wer Mail braucht, nutzt ein internes Relay und akzeptiert I-23 (Restrisikoliste). |
|
||||
| `WEB_PORT`/`API_PORT`/`COLLAB_PORT` | Defaults (127.0.0.1-gebunden) | Anwendungscontainer sind nie direkt exponiert; einzige Eintrittsstelle ist der Reverse Proxy (Sicherheitsdokumentation §2). |
|
||||
| `LOG_LEVEL` | `info` | Audit-Zeilen (`audit: `-Präfix) müssen den Collector erreichen; `debug` nur zur Störungssuche. |
|
||||
| `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_SCOPES`, `OIDC_PROVIDER_LABEL` | IdP der Behörde konfigurieren | Fremdauthentisierung (#214, ADR 0021): Authorization Code + PKCE gegen den IdP der Umgebung; deploy-seitig, weil die Authentisierungshoheit Plattformsache ist. Erst-Login legt Konten just-in-time an; bestehende lokale Konten werden NIE stillschweigend per E-Mail übernommen (expliziter Link-Flow). Konfigurationsdetails: security.md §External authentication. |
|
||||
| `AUTH_PROXY_HEADER`, `AUTH_PROXY_TRUSTED_PEERS`, `AUTH_PROXY_MAP`, `AUTH_PROXY_MODE`, `AUTH_PROXY_DN_ATTRIBUTE` | nur bei Perimeter-Authentisierung setzen | Alternativpfad (#215): Identität aus dem Proxy-Header, gültig NUR vom TCP-Peer der Allowlist; fremder Peer mit Header wird abgewiesen und auditiert (`auth.proxy_rejected`). Der Proxy MUSS den Header aus eingehendem Verkehr strippen. mTLS-Variante über weitergereichten Zertifikats-DN (`mtls-dn`). Ohne Perimeter-Auth: unkonfiguriert lassen (Header wirkungslos). |
|
||||
| `AUTH_LOCAL_ENABLED` | `false` (Default `true`) | **der** harte Schalter aus ADR 0021 (#216, deploy-seitige Realisierung von `auth.local.enabled`): `false` schaltet JEDEN lokalen Credential-Flow auf 404 (Login, Signup, Verifikation, Passwort vergessen/zuruecksetzen/aendern) — Anmeldung ausschliesslich ueber OIDC (#214) bzw. Perimeter-Auth (#215). Deploy-seitig, damit ein kompromittierter Site-Admin ihn nicht zurueckdrehen kann. Reihenfolge: erst Setup/Bootstrap (SETUP_ADMIN_*), dann auf `false`. PAT-/Feed-Token-Ausgabe bleibt fuer IdP-authentisierte Nutzer verfuegbar (eigene Schalter `api.enabled`/`feeds.enabled`; bewusste Entscheidung #216). |
|
||||
| `VS_NFD_MODE` | `enforced` (Default `off`) | Härtungsprofil-Modus (#243, ADR 0027): die Anwendung kennt diese Referenzkonfiguration als maschinenlesbaren Katalog und bewertet die laufende Konfiguration dagegen (Site-Admin → Einstellungen). `marked` markiert Abweichungen, `hidden` blendet abweichende Optionen aus, `enforced` weist abweichende Schreibzugriffe serverseitig ab — alle drei Behandlungsstufen sind umgesetzt (#244–#246); für den VS-NfD-Referenzbetrieb ist `enforced` der empfohlene Modus — nur er hält auch skriptgesteuerte Clients an das Profil. Bestehende Abweichungen werden beim Start und in der Admin-Ansicht gemeldet, nie automatisch geändert. Deploy-seitig wie `BACKUP_ALLOWED_TARGETS`: ein kompromittierter Site-Admin kann den Modus nicht aufweichen. Außerhalb von VS-Kontexten bleibt der Default `off` — keinerlei Markierung. |
|
||||
|
||||
### 1.3 Noch nicht verfügbar (Regel: landet hier im selben PR)
|
||||
|
||||
|
||||
@ -111,5 +111,6 @@
|
||||
"scope_required": "Dieses API-Token hat nicht den erforderlichen Scope.",
|
||||
"pond_not_found": "Der Teich existiert nicht.",
|
||||
"classification_lower_forbidden": "Zum Herabstufen der Einstufung fehlt die Berechtigung (Teich-Admin erforderlich).",
|
||||
"classified_upload_blocked": "Uploads auf eingestufte Seiten sind auf dieser Instanz blockiert."
|
||||
"classified_upload_blocked": "Uploads auf eingestufte Seiten sind auf dieser Instanz blockiert.",
|
||||
"vs_nfd_profile_violation": "Diese Einstellung würde vom VS-NfD-Referenzprofil abweichen — das Deployment erzwingt das Profil (VS_NFD_MODE=enforced)."
|
||||
}
|
||||
|
||||
@ -111,5 +111,6 @@
|
||||
"scope_required": "This API token does not have the required scope.",
|
||||
"pond_not_found": "The pond does not exist.",
|
||||
"classification_lower_forbidden": "You lack the permission to lower the classification (Pond Admin required).",
|
||||
"classified_upload_blocked": "Uploads to classified pages are blocked on this instance."
|
||||
"classified_upload_blocked": "Uploads to classified pages are blocked on this instance.",
|
||||
"vs_nfd_profile_violation": "This setting would deviate from the VS-NfD reference profile — the deployment enforces the profile (VS_NFD_MODE=enforced)."
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user