feat(magic): RE-hardening Quick Wins (ACL, #if DEBUG guards, rate-limit)
Härtung der öffentlich downloadbaren Magic-Apps gegen Reverse Engineering (Assessment: docs/specs/magic-re-hardening.md): - Windows: protection.json per ACL auf SYSTEM+Admins (DNS-Token nicht mehr von Standard-Usern lesbar) — setup.rs - Mac: MacProfileInstaller.remove() + Debug-Supervision-Modi/Reset nur noch #if DEBUG (kein Removal-/Debug-Pfad im Release-Binary) - Mac: staging-URL einmal als Konstante statt 4x Literal; interne Infra-Notizen aus String-Literalen raus - Backend: Rate-Limit (10/IP/min) auf /api/magic/pair/redeem NUR Backend-Teil deployt via Push; Mac/Win brauchen Xcode-/Cargo-Release-Build (zied) + Smoke-Tests vor Release. MagicAPIClient.swift trägt etwas vorbestehenden WIP mit (gleiche Magic-Client-Domäne). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
8da782339e
commit
5fb441817f
@ -1,6 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
import Observation
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
enum DebugSupervisionMode: String, CaseIterable, Identifiable {
|
enum DebugSupervisionMode: String, CaseIterable, Identifiable {
|
||||||
case none
|
case none
|
||||||
case forceSupervised
|
case forceSupervised
|
||||||
@ -16,6 +17,7 @@ enum DebugSupervisionMode: String, CaseIterable, Identifiable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
@Observable
|
||||||
@ -39,7 +41,8 @@ final class WizardModel {
|
|||||||
|
|
||||||
var cooldownEndsAt: Date?
|
var cooldownEndsAt: Date?
|
||||||
|
|
||||||
// Debug-Reset State
|
#if DEBUG
|
||||||
|
// Debug-Reset State — nur in Debug-Builds vorhanden
|
||||||
var supervisionMode: DebugSupervisionMode = .none
|
var supervisionMode: DebugSupervisionMode = .none
|
||||||
var resetRunning: Bool = false
|
var resetRunning: Bool = false
|
||||||
var resetStatus: String?
|
var resetStatus: String?
|
||||||
@ -47,7 +50,8 @@ final class WizardModel {
|
|||||||
var resetEnrollmentProfile: Bool = true
|
var resetEnrollmentProfile: Bool = true
|
||||||
var resetLockProfile: Bool = true
|
var resetLockProfile: Bool = true
|
||||||
var resetApp: Bool = true
|
var resetApp: Bool = true
|
||||||
|
#endif
|
||||||
|
|
||||||
// Auth + Magic State
|
// Auth + Magic State
|
||||||
var authSession: AuthSession?
|
var authSession: AuthSession?
|
||||||
var showingLogin: Bool = false
|
var showingLogin: Bool = false
|
||||||
@ -151,11 +155,14 @@ final class WizardModel {
|
|||||||
configureError = nil
|
configureError = nil
|
||||||
showAdvancedLogs = false
|
showAdvancedLogs = false
|
||||||
cooldownEndsAt = nil
|
cooldownEndsAt = nil
|
||||||
|
#if DEBUG
|
||||||
resetStatus = nil
|
resetStatus = nil
|
||||||
|
#endif
|
||||||
magicRegistration = nil
|
magicRegistration = nil
|
||||||
registrationError = nil
|
registrationError = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
func startDebugReset() {
|
func startDebugReset() {
|
||||||
guard device != nil else {
|
guard device != nil else {
|
||||||
resetStatus = "Kein iPhone erkannt."
|
resetStatus = "Kein iPhone erkannt."
|
||||||
@ -234,4 +241,5 @@ final class WizardModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,6 +21,7 @@ struct RebreakMagicApp: App {
|
|||||||
.disabled(model.authSession == nil)
|
.disabled(model.authSession == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
CommandMenu("Aktionen") {
|
CommandMenu("Aktionen") {
|
||||||
Menu("Debug Supervision Mode") {
|
Menu("Debug Supervision Mode") {
|
||||||
Button(DebugSupervisionMode.none.title) {
|
Button(DebugSupervisionMode.none.title) {
|
||||||
@ -33,7 +34,7 @@ struct RebreakMagicApp: App {
|
|||||||
model.supervisionMode = .forceUnsupervised
|
model.supervisionMode = .forceUnsupervised
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Toggle("Profile + App entfernen", isOn: $model.resetAll)
|
Toggle("Profile + App entfernen", isOn: $model.resetAll)
|
||||||
Toggle("MDM Enrollment-Profil", isOn: $model.resetEnrollmentProfile)
|
Toggle("MDM Enrollment-Profil", isOn: $model.resetEnrollmentProfile)
|
||||||
.disabled(model.resetAll)
|
.disabled(model.resetAll)
|
||||||
@ -41,15 +42,16 @@ struct RebreakMagicApp: App {
|
|||||||
.disabled(model.resetAll)
|
.disabled(model.resetAll)
|
||||||
Toggle("ReBreak-App", isOn: $model.resetApp)
|
Toggle("ReBreak-App", isOn: $model.resetApp)
|
||||||
.disabled(model.resetAll)
|
.disabled(model.resetAll)
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
Button("Debug-Reset ausführen") {
|
Button("Debug-Reset ausführen") {
|
||||||
model.startDebugReset()
|
model.startDebugReset()
|
||||||
}
|
}
|
||||||
.keyboardShortcut("r", modifiers: [.command, .shift, .option])
|
.keyboardShortcut("r", modifiers: [.command, .shift, .option])
|
||||||
.disabled(model.device == nil || model.resetRunning)
|
.disabled(model.device == nil || model.resetRunning)
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -70,11 +70,14 @@ private struct PairRedeemResponseData: Codable {
|
|||||||
/// AuthService — managt Pairing-Code-Login + Keychain-Persistence.
|
/// AuthService — managt Pairing-Code-Login + Keychain-Persistence.
|
||||||
///
|
///
|
||||||
/// Config aus ~/.config/rebreak-magic/config.json (optional):
|
/// Config aus ~/.config/rebreak-magic/config.json (optional):
|
||||||
/// { "backendBaseUrl": "https://app.rebreak.org" }
|
/// { "backendBaseUrl": "https://<backend-host>" }
|
||||||
@MainActor
|
@MainActor
|
||||||
final class AuthService {
|
final class AuthService {
|
||||||
static let shared = AuthService()
|
static let shared = AuthService()
|
||||||
|
|
||||||
|
// Default-Backend — zentral damit der Wert nur einmal im Binary steht.
|
||||||
|
private static let defaultBackendURL = "https://staging.rebreak.org"
|
||||||
|
|
||||||
private let keychainService = "org.rebreak.magic"
|
private let keychainService = "org.rebreak.magic"
|
||||||
private let keychainAccount = "magic-session"
|
private let keychainAccount = "magic-session"
|
||||||
private var cachedSession: AuthSession?
|
private var cachedSession: AuthSession?
|
||||||
@ -101,7 +104,7 @@ final class AuthService {
|
|||||||
let data = try? Data(contentsOf: url),
|
let data = try? Data(contentsOf: url),
|
||||||
let config = try? JSONDecoder().decode(Config.self, from: data),
|
let config = try? JSONDecoder().decode(Config.self, from: data),
|
||||||
let base = config.backendBaseUrl else {
|
let base = config.backendBaseUrl else {
|
||||||
return "https://staging.rebreak.org"
|
return Self.defaultBackendURL
|
||||||
}
|
}
|
||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
|
|||||||
@ -67,8 +67,11 @@ enum MacProfileInstaller {
|
|||||||
|| result.stdout.localizedCaseInsensitiveContains("org.rebreak.protection.profile")
|
|| result.stdout.localizedCaseInsensitiveContains("org.rebreak.protection.profile")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Entfernt ReBreak-DNS-Profile (für Testing/Reset).
|
// MARK: - Debug only
|
||||||
/// Benötigt PayloadIdentifier — wir suchen nach "org.rebreak.protection.profile.*".
|
|
||||||
|
#if DEBUG
|
||||||
|
/// Entfernt ReBreak-DNS-Profile.
|
||||||
|
/// NUR in Debug-Builds verfügbar — darf nicht im Release-Binary landen.
|
||||||
static func remove() async throws {
|
static func remove() async throws {
|
||||||
// 1. Find identifier
|
// 1. Find identifier
|
||||||
guard let result = try? await ProcessRunner.run(
|
guard let result = try? await ProcessRunner.run(
|
||||||
@ -77,11 +80,11 @@ enum MacProfileInstaller {
|
|||||||
), result.exitCode == 0 else {
|
), result.exitCode == 0 else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse identifier aus Output (format: " <identifier>: <displayName>")
|
// Parse identifier aus Output (format: " <identifier>: <displayName>")
|
||||||
let lines = result.stdout.split(separator: "\n")
|
let lines = result.stdout.split(separator: "\n")
|
||||||
var identifier: String?
|
var identifier: String?
|
||||||
|
|
||||||
for line in lines {
|
for line in lines {
|
||||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||||
if trimmed.hasPrefix("org.rebreak.protection.profile") {
|
if trimmed.hasPrefix("org.rebreak.protection.profile") {
|
||||||
@ -90,17 +93,18 @@ enum MacProfileInstaller {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let id = identifier else { return }
|
guard let id = identifier else { return }
|
||||||
|
|
||||||
// 2. Remove profile
|
// 2. Remove profile
|
||||||
let removeResult = try await ProcessRunner.run(
|
let removeResult = try await ProcessRunner.run(
|
||||||
"/usr/bin/profiles",
|
"/usr/bin/profiles",
|
||||||
arguments: ["remove", "-identifier", id]
|
arguments: ["remove", "-identifier", id]
|
||||||
)
|
)
|
||||||
|
|
||||||
if removeResult.exitCode != 0 {
|
if removeResult.exitCode != 0 {
|
||||||
throw InstallerError.installFailed(removeResult.stderr)
|
throw InstallerError.installFailed(removeResult.stderr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@ -100,41 +100,43 @@ enum MagicError: Error, LocalizedError {
|
|||||||
@MainActor
|
@MainActor
|
||||||
final class MagicAPIClient {
|
final class MagicAPIClient {
|
||||||
static let shared = MagicAPIClient()
|
static let shared = MagicAPIClient()
|
||||||
|
|
||||||
|
// Default-Backend — zentral damit der Wert nur einmal im Binary steht.
|
||||||
|
private static let defaultBackendURL = "https://staging.rebreak.org"
|
||||||
|
|
||||||
private let authService = AuthService.shared
|
private let authService = AuthService.shared
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
// MARK: - Config
|
// MARK: - Config
|
||||||
|
|
||||||
private struct Config: Codable {
|
private struct Config: Codable {
|
||||||
let backendBaseUrl: String?
|
let backendBaseUrl: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
private static let configPath: String = {
|
private static let configPath: String = {
|
||||||
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
||||||
return "\(home)/.config/rebreak-magic/config.json"
|
return "\(home)/.config/rebreak-magic/config.json"
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private var baseURL: String {
|
private var baseURL: String {
|
||||||
get throws {
|
get throws {
|
||||||
// Override via env var for testing
|
// Override via env var for testing
|
||||||
if let envUrl = ProcessInfo.processInfo.environment["REBREAK_BACKEND_URL"] {
|
if let envUrl = ProcessInfo.processInfo.environment["REBREAK_BACKEND_URL"] {
|
||||||
return envUrl
|
return envUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
let url = URL(fileURLWithPath: Self.configPath)
|
let url = URL(fileURLWithPath: Self.configPath)
|
||||||
guard FileManager.default.fileExists(atPath: Self.configPath) else {
|
guard FileManager.default.fileExists(atPath: Self.configPath) else {
|
||||||
// Default to staging (app.rebreak.org hat aktuell falsches TLS-Zert)
|
return Self.defaultBackendURL
|
||||||
return "https://staging.rebreak.org"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let data = try Data(contentsOf: url)
|
let data = try Data(contentsOf: url)
|
||||||
let config = try JSONDecoder().decode(Config.self, from: data)
|
let config = try JSONDecoder().decode(Config.self, from: data)
|
||||||
return config.backendBaseUrl ?? "https://staging.rebreak.org"
|
return config.backendBaseUrl ?? Self.defaultBackendURL
|
||||||
} catch {
|
} catch {
|
||||||
return "https://staging.rebreak.org"
|
return Self.defaultBackendURL
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -345,6 +347,39 @@ final class MagicAPIClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Status (token-based, no auth)
|
||||||
|
|
||||||
|
/// Pollt `/api/magic/status?token=` — bestätigt serverseitig, dass das
|
||||||
|
/// Binding aktiv ist (`active=true`). Kein JWT nötig, der Token ist das Secret.
|
||||||
|
func status(token: String) async throws -> Bool {
|
||||||
|
let url = try URL(string: "\(baseURL)/api/magic/status?token=\(token)")!
|
||||||
|
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "GET"
|
||||||
|
// KEIN JWT — Token in Query
|
||||||
|
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
|
throw MagicError.networkError("Keine HTTP-Response")
|
||||||
|
}
|
||||||
|
guard httpResponse.statusCode == 200 else {
|
||||||
|
let body = String(data: data, encoding: .utf8) ?? ""
|
||||||
|
throw MagicError.httpError(httpResponse.statusCode, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StatusData: Codable { let active: Bool }
|
||||||
|
struct Response: Codable {
|
||||||
|
let success: Bool
|
||||||
|
let data: StatusData
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
return try JSONDecoder().decode(Response.self, from: data).data.active
|
||||||
|
} catch {
|
||||||
|
throw MagicError.decodingError(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Download Profile
|
// MARK: - Download Profile
|
||||||
|
|
||||||
func downloadProfile(token: String) async throws -> URL {
|
func downloadProfile(token: String) async throws -> URL {
|
||||||
|
|||||||
@ -31,7 +31,7 @@ pub fn service_exe_path() -> Result<PathBuf, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Baut das komplette elevated Setup-Script:
|
/// Baut das komplette elevated Setup-Script:
|
||||||
/// 1. DoH anwenden 2. State nach %ProgramData%\ReBreak 3. Service installieren+starten
|
/// 1. DoH anwenden 2. State nach %ProgramData%\ReBreak 3. ACL härten 4. Service installieren+starten
|
||||||
pub fn build_setup_script(state: &ProtectionState, service_exe: &str) -> String {
|
pub fn build_setup_script(state: &ProtectionState, service_exe: &str) -> String {
|
||||||
let mut script = String::new();
|
let mut script = String::new();
|
||||||
|
|
||||||
@ -49,6 +49,24 @@ New-Item -ItemType Directory -Force -Path '{state_dir}' | Out-Null
|
|||||||
{json}
|
{json}
|
||||||
'@ | Set-Content -Path '{state_path}' -Encoding UTF8
|
'@ | Set-Content -Path '{state_path}' -Encoding UTF8
|
||||||
|
|
||||||
|
# ACL härten: nur SYSTEM + Administratoren dürfen lesen/schreiben.
|
||||||
|
# Standard-User verlieren Read-Zugriff → DNS-Token nicht trivial auslesbar.
|
||||||
|
$acl = New-Object System.Security.AccessControl.FileSecurity
|
||||||
|
$acl.SetAccessRuleProtection($true, $false) # Vererbung deaktivieren
|
||||||
|
$ruleSystem = New-Object System.Security.AccessControl.FileSystemAccessRule(
|
||||||
|
"NT AUTHORITY\SYSTEM",
|
||||||
|
"FullControl",
|
||||||
|
"Allow"
|
||||||
|
)
|
||||||
|
$ruleAdmins = New-Object System.Security.AccessControl.FileSystemAccessRule(
|
||||||
|
"BUILTIN\Administrators",
|
||||||
|
"FullControl",
|
||||||
|
"Allow"
|
||||||
|
)
|
||||||
|
$acl.AddAccessRule($ruleSystem)
|
||||||
|
$acl.AddAccessRule($ruleAdmins)
|
||||||
|
Set-Acl -Path '{state_path}' -AclObject $acl
|
||||||
|
|
||||||
# Tamper-Protection-Service als SYSTEM installieren (idempotent: alte Instanz weg)
|
# Tamper-Protection-Service als SYSTEM installieren (idempotent: alte Instanz weg)
|
||||||
sc.exe stop {SERVICE_NAME} 2>$null | Out-Null
|
sc.exe stop {SERVICE_NAME} 2>$null | Out-Null
|
||||||
sc.exe delete {SERVICE_NAME} 2>$null | Out-Null
|
sc.exe delete {SERVICE_NAME} 2>$null | Out-Null
|
||||||
|
|||||||
@ -9,8 +9,54 @@ import { randomBytes } from "crypto";
|
|||||||
* Tauscht einen 6-stelligen Pairing-Code (single-use, 10min TTL) gegen einen
|
* Tauscht einen 6-stelligen Pairing-Code (single-use, 10min TTL) gegen einen
|
||||||
* MagicSession-Token ("mgc_<48 char>"). Token wird in Mac-Keychain gespeichert
|
* MagicSession-Token ("mgc_<48 char>"). Token wird in Mac-Keychain gespeichert
|
||||||
* und ersetzt Supabase-JWT für alle /api/magic/* Endpoints.
|
* und ersetzt Supabase-JWT für alle /api/magic/* Endpoints.
|
||||||
|
*
|
||||||
|
* Rate-Limit: max. 10 Requests pro IP pro 60s (In-Memory, Nitro Single-Process).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// In-Memory Rate-Limit-Store. Key = IP, Value = { count, windowStart (ms) }.
|
||||||
|
// Wird bei Serverstart zurückgesetzt — ausreichend für Single-Process-Nitro.
|
||||||
|
const RATE_WINDOW_MS = 60_000; // 1 Minute
|
||||||
|
const RATE_MAX_REQUESTS = 10;
|
||||||
|
|
||||||
|
interface RateEntry {
|
||||||
|
count: number;
|
||||||
|
windowStart: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rateLimitStore = new Map<string, RateEntry>();
|
||||||
|
|
||||||
|
function checkRateLimit(ip: string): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const entry = rateLimitStore.get(ip);
|
||||||
|
|
||||||
|
if (!entry || now - entry.windowStart >= RATE_WINDOW_MS) {
|
||||||
|
// Neues Fenster starten
|
||||||
|
rateLimitStore.set(ip, { count: 1, windowStart: now });
|
||||||
|
return true; // erlaubt
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.count >= RATE_MAX_REQUESTS) {
|
||||||
|
return false; // geblockt
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.count += 1;
|
||||||
|
return true; // erlaubt
|
||||||
|
}
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
|
// Rate-Limit prüfen (vor jeder anderen Logik)
|
||||||
|
const ip =
|
||||||
|
getRequestHeader(event, "x-forwarded-for")?.split(",")[0].trim() ||
|
||||||
|
getRequestHeader(event, "x-real-ip") ||
|
||||||
|
event.node.req.socket?.remoteAddress ||
|
||||||
|
"unknown";
|
||||||
|
|
||||||
|
if (!checkRateLimit(ip)) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 429,
|
||||||
|
message: "Zu viele Versuche. Bitte warte eine Minute.",
|
||||||
|
});
|
||||||
|
}
|
||||||
const body = await readBody(event);
|
const body = await readBody(event);
|
||||||
const { code, label } = body as { code?: string; label?: string };
|
const { code, label } = body as { code?: string; label?: string };
|
||||||
|
|
||||||
|
|||||||
251
docs/specs/magic-re-hardening.md
Normal file
251
docs/specs/magic-re-hardening.md
Normal file
@ -0,0 +1,251 @@
|
|||||||
|
# ReBreak Magic — RE-Hardening Assessment & Plan
|
||||||
|
|
||||||
|
**Erstellt:** 2026-06-09
|
||||||
|
**Scope:** `apps/rebreak-magic-mac/` · `apps/rebreak-magic-win/` · relevante Backend-Berührungspunkte
|
||||||
|
**Status:** Assessment + Plan zur Founder-Abnahme. Keine Implementierung.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. RE-Exposure-Analyse: Was liegt aktuell offen?
|
||||||
|
|
||||||
|
### 1.1 Mac-App (SwiftUI, .app-Bundle)
|
||||||
|
|
||||||
|
**Technische Ausgangslage:** Swift-Binaries sind Mach-O, kompiliert mit Optimierungen. String-Literale im `__TEXT`-Segment bleiben im Binary. `strings`-Analyse + Class-Dump (mit `class-dump` oder Ghidra) reicht für alles Relevante.
|
||||||
|
|
||||||
|
| Angriffsvektor | Was ist extrahierbar? | Sicherheitskritisch? |
|
||||||
|
|---|---|---|
|
||||||
|
| String-Scan des Binary | `https://staging.rebreak.org`, `https://dns.rebreak.org/dns-query`, `org.rebreak.magic`, `org.rebreak.protection.dns.filter`, `org.rebreak.protection.profile` | Niedrig — Base-URLs sind keine Secrets |
|
||||||
|
| `REBREAK_BACKEND_URL` env-Override | Hardcoded String + Kommentar im Binary: „Default to staging (app.rebreak.org hat aktuell falsches TLS-Zert)" | Mittel — zeigt interne Infrastruktur-Anmerkungen |
|
||||||
|
| Keychain-Keys | `org.rebreak.magic` / `magic-session` — kein Secret-Wert, nur Service/Account-Namen | Niedrig |
|
||||||
|
| `mgc_`-Token-Prefix | Token-Format `mgc_<base64url>` im Binary erkennbar | Niedrig |
|
||||||
|
| Profil-PayloadIdentifier | `org.rebreak.protection.dns.filter.*` im Binary + `.mobileconfig`-Dateiname | Niedrig |
|
||||||
|
| `MacProfileInstaller.remove()` | Vollständiger Removal-Pfad via `profiles remove -identifier org.rebreak.protection.profile.*` sichtbar | **Mittel-hoch** — RE kann Removal-Kommando 1:1 nachbauen |
|
||||||
|
| `profiles show -type configuration` | Identifier-Scan-Logik offenbart die exakten Identifier des Schutzes | Mittel |
|
||||||
|
| Debug-State in `WizardModel` | `DebugSupervisionMode` mit `forceSupervised`/`forceUnsupervised` im Release-Build sichtbar | Mittel — gibt Hinweise auf interne States |
|
||||||
|
| Removal-Password | **NICHT** im Binary. Kommt server-seitig via `/api/magic/profile.mobileconfig`. Liegt in Keychain auf dem Device. | Kein Exposure-Risiko im Binary |
|
||||||
|
| AdGuard-Creds | **NICHT** im Binary. Nur im Backend via Infisical. | Kein Exposure-Risiko |
|
||||||
|
|
||||||
|
**Kritische Erkenntnis Mac:**
|
||||||
|
Das eigentliche Schutz-Bypass-Risiko liegt **nicht im Binary**, sondern im Mechanismus selbst: Der User hat Admin-Passwort. Mit Admin-Passwort kann er das .mobileconfig-Profil über System Settings → Geräteverwaltung entfernen — unabhängig vom Binary-Wissen. Das `ProfileRemovalPassword` ändert das erst dann, wenn es korrekt vom Backend injiziert wird. Das ist der echte Lock.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.2 Windows-App (Tauri 2 / Rust)
|
||||||
|
|
||||||
|
**Technische Ausgangslage:** Rust-Binaries sind kompilierter Maschinencode. Ohne Debug-Symbols sind Funktionsnamen weg — aber String-Literale sind **direkt extrahierbar** mit `strings`. Tauri-Apps haben zusätzlich einen eingebetteten WebView mit React-Bundle (JavaScript-Klartext).
|
||||||
|
|
||||||
|
| Angriffsvektor | Was ist extrahierbar? | Sicherheitskritisch? |
|
||||||
|
|---|---|---|
|
||||||
|
| `strings` auf `.exe` | `https://staging.rebreak.org`, `dns.rebreak.org`, `178.105.101.137` (Fallback-IP), `RebreakProtection` (Service-Name), `HKLM:\SYSTEM\CurrentControlSet\Services\Dnscache\Parameters`, Registry-Pfade für Browser-Policies | Mittel |
|
||||||
|
| PowerShell-Scripts als String-Literale | **Komplett lesbar** — `apply_script()`, `teardown_script()`, `check_script()` sind reine Strings im Binary. Jeder sieht exakt was geschrieben und gelöscht wird | **Hoch** — Angreifer kann Teardown-Sequenz 1:1 reproduzieren |
|
||||||
|
| Service-Name `RebreakProtection` | Im `sc.exe`-Aufruf als Literal; Service kann per Name gestoppt werden | **Hoch** — `sc.exe stop RebreakProtection` + `sc.exe delete RebreakProtection` reicht für Bypass |
|
||||||
|
| DoH-Template `https://dns.rebreak.org/dns-query/{token}` | Token-Schema erkennbar | Niedrig |
|
||||||
|
| `protection.json` unter `%ProgramData%\ReBreak\` | Pfad im Binary. Datei enthält `dnsToken`, `serverIp`, `dohTemplate`, `backendUrl`, `deviceId` im Klartext | **Kritisch** — wer Pfad kennt, kann DNS-Token + Status-Polling-URL direkt lesen |
|
||||||
|
| Credential Manager Keys | `org.rebreak.magic` / `session-token` / `dns-token` — Keynames extrahierbar. Zugriff auf Werte braucht SYSTEM/selben User-Kontext | Mittel |
|
||||||
|
| WebView-Bundle (JS) | React-Frontend-Code im Klartext, komplett readable. Enthält keine Secrets (Token nie im Frontend), aber alle UI-Strings, State-Typen, IPC-Command-Namen | Niedrig — keine Secrets |
|
||||||
|
| `--console`-Debug-Flag | Im Binary sichtbar: `rebreak-protection-service.exe --console` startet den Service im Vordergrund | Niedrig — Debug-Feature, kein Angriffspfad |
|
||||||
|
| `DOH_FALLBACK_IP` `178.105.101.137` | Hetzner-Box direkt im Binary | Mittel — IP-Wissen; aber auch über DNS auflösbar |
|
||||||
|
|
||||||
|
**Kritische Erkenntnis Windows:**
|
||||||
|
|
||||||
|
**Das `protection.json`-File ist das größte Exposure-Problem.** Es liegt unter `%ProgramData%\ReBreak\protection.json` als lesbare JSON-Datei, enthält den DNS-Token im Klartext und ist für jeden User des Systems lesbar (ProgramData ist nicht ACL-beschränkt auf Nicht-Admin-User in Standardkonfiguration). Wer den Token kennt, kann `/api/magic/status?token=<token>` selbst pollen und damit theoretisch den Status-Check-Mechanismus verstehen.
|
||||||
|
|
||||||
|
**Der Service-Name-Bypass ist das kritischste operative Risiko:** Da `RebreakProtection` im Klartext im Binary steht und der Service mit `sc.exe` verwaltet wird, kann ein technisch versierter User mit Admin-Rechten:
|
||||||
|
1. `sc.exe stop RebreakProtection` ausführen
|
||||||
|
2. DoH manuell zurücksetzen (Registry/PowerShell)
|
||||||
|
3. Schutz ist weg — bis zum nächsten Service-Start (alle 5min Auto-Restart greift, aber Fenster ist offen)
|
||||||
|
|
||||||
|
Gegenargument: Das braucht Admin-Rechte. Jemand mit Admin kann ohnehin alles. Aber es macht es trivial und skriptbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.3 Backend-Berührungspunkte
|
||||||
|
|
||||||
|
| Punkt | Exposure | Kritisch? |
|
||||||
|
|---|---|---|
|
||||||
|
| `/api/magic/status?token=` ist unauthentifiziert | Design-gewollt — Token ist das Secret. 48 hex chars = 192 bit Entropie → praktisch nicht brute-forcebar | Nein |
|
||||||
|
| Tamper-Service pollt diesen Endpoint | Jeder der den Token aus `protection.json` liest, kann denselben Request abschicken und sieht `active=true/false` | Theoretisch — kein zusätzlicher Bypass damit möglich |
|
||||||
|
| `profile.mobileconfig?token=` ist unauthentifiziert | Wie oben — Token-basierter Auth. Profil enthält RemovalPassword im Klartext (für macOS) | Mittel — Removal-PW liegt im Profil-XML falls jemand das Profil-File auf dem Mac lesen kann |
|
||||||
|
| AdGuard-Creds | Nur Backend / Infisical. Kein Client-Exposure | Kein Risiko |
|
||||||
|
| Pairing-Code 6 Ziffern (1M Raum) | 10min TTL, max 5 aktive Attempts unwahrscheinlich throttled | Niedrig — kurze TTL schützt |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Sicherheitsbewertung: Was ist tatsächlich kritisch?
|
||||||
|
|
||||||
|
### Kritisch (direkter Bypass möglich)
|
||||||
|
- **Windows `protection.json` lesbar:** DNS-Token im Klartext für lokale User
|
||||||
|
- **Windows Service-Stop via bekanntem Namen + Auto-Restart-Fenster:** 5min-Fenster zwischen Service-Stop und Restart (aber: braucht Admin)
|
||||||
|
|
||||||
|
### Mittel (kein direkter Bypass, aber schlechte Praxis / Competitor-Wissen)
|
||||||
|
- **PowerShell-Teardown-Script vollständig im Binary:** Competitor kann Setup/Teardown exakt kopieren
|
||||||
|
- **Mac `profiles remove` Kommando-Syntax im Binary:** Analog zum DNS-Profil-Removal
|
||||||
|
- **`protection.json`-Pfad bekannt:** Komfort-Angriff
|
||||||
|
|
||||||
|
### Niedrig (kein operativer Bypass)
|
||||||
|
- Base-URLs im Binary — ohnehin über Proxy/Network sichtbar
|
||||||
|
- Service/Keychain-Namen — braucht lokalen Zugriff
|
||||||
|
- Debug-Flags im Release-Build
|
||||||
|
|
||||||
|
### Kein Risiko
|
||||||
|
- Removal-Password (server-gehalten, nicht im Binary)
|
||||||
|
- AdGuard-Creds (Infisical-only)
|
||||||
|
- JWT/mgc-Tokens (Keychain/Credential Manager)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Härtungs-Plan (priorisiert)
|
||||||
|
|
||||||
|
### Priorität 1 — Quick Wins (1–3 Tage Aufwand)
|
||||||
|
|
||||||
|
#### 3.1 Windows `protection.json` ACL härten
|
||||||
|
**Problem:** Datei unter `%ProgramData%\ReBreak\` ist für Standard-User lesbar.
|
||||||
|
**Fix:** Im elevated Setup-Script ACL direkt nach dem Schreiben setzen:
|
||||||
|
```powershell
|
||||||
|
$acl = Get-Acl $state_path
|
||||||
|
$acl.SetAccessRuleProtection($true, $false)
|
||||||
|
$rule = New-Object System.Security.AccessControl.FileSystemAccessRule("SYSTEM","FullControl","Allow")
|
||||||
|
$acl.SetAccessRule($rule)
|
||||||
|
$rule2 = New-Object System.Security.AccessControl.FileSystemAccessRule("Administrators","FullControl","Allow")
|
||||||
|
$acl.SetAccessRule($rule2)
|
||||||
|
# Standard-User: kein Zugriff
|
||||||
|
Set-Acl $state_path $acl
|
||||||
|
```
|
||||||
|
**Effekt:** DNS-Token nicht mehr über Datei extrahierbar ohne Admin-Rechte.
|
||||||
|
**Aufwand:** ~2h, nur `protection-core/src/lib.rs` + `setup.rs`-Script. Kein Backend-Change.
|
||||||
|
|
||||||
|
#### 3.2 Debug-Code aus Mac Release-Build entfernen
|
||||||
|
**Problem:** `DebugSupervisionMode` enum + Debug-Reset-Logik in `WizardModel.swift` ist im Release-Build sichtbar.
|
||||||
|
**Fix:** `#if DEBUG` Guards um alle Debug-Felder und `startDebugReset()`-Methode.
|
||||||
|
**Aufwand:** ~1h.
|
||||||
|
|
||||||
|
#### 3.3 Service-Name verschleiern (Windows)
|
||||||
|
**Problem:** `RebreakProtection` ist ein trivialer Stop-Angriff-Vektor.
|
||||||
|
**Diskussion:** Echter Schutz dagegen ist unmöglich — jeder Service-Name kann in der Service-Liste gesehen werden. Aber: einen weniger offensichtlichen Namen verwenden (z.B. `rbkdnsd` oder `SystemDnsOptimizer`) erhöht die Hürde für Script-Kiddies.
|
||||||
|
**Aufwand:** ~1h, aber Service-Name muss in protection-core, setup.rs, service/main.rs konsistent sein. + Re-Deploy.
|
||||||
|
**Warnung:** Security-Theater-Komponente. Echter Schutz: Windows Protected Services (nächste Priorität).
|
||||||
|
|
||||||
|
#### 3.4 Intern-Kommentar-Strings aus Mac-Binary entfernen
|
||||||
|
**Problem:** Kommentare wie „app.rebreak.org hat aktuell falsches TLS-Zert" sind als Swift-String-Literals im Binary.
|
||||||
|
**Fix:** Kommentare in Code-Kommentare umwandeln (nicht String-Literals), Infobox-Texte externalisieren.
|
||||||
|
**Aufwand:** ~30min.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Priorität 2 — Mittlerer Aufwand (1–2 Wochen)
|
||||||
|
|
||||||
|
#### 3.5 Windows: Protected Service / PPL (Größter Tamper-Schutz)
|
||||||
|
**Problem:** `sc.exe stop RebreakProtection` mit Admin reicht zum Bypass.
|
||||||
|
**Lösung:** Windows Protected Processes / Service Protection Level. Via `sc.exe sdset` + DACL oder (besser) Service als **Protected Service** signieren — verhindert Stop/Delete auch durch lokale Admins.
|
||||||
|
**Realistisch:** Echter PPL braucht Microsoft ELAM-Zertifikat (teuer + aufwändig). Alternativer Ansatz: **DACL-Restriktion** — Setup-Script entfernt die WRITE/STOP-Berechtigung von der "Administrators"-Gruppe auf dem Service-Object. Zwingt Angreifer zu mehr Aufwand (Token-Impersonation o.ä.).
|
||||||
|
**Aufwand:** ~3 Tage (Recherche + Test auf verschiedenen Windows-Versionen). Hypothese, ungeprüft — Windows Service DACL-Manipulation braucht sorgfältiges Testing.
|
||||||
|
**Eskalation an rebreak-ops für Deploy, kein Backend-Change nötig.**
|
||||||
|
|
||||||
|
#### 3.6 Windows: PowerShell-Scripts verschlüsseln / nicht als String-Literal
|
||||||
|
**Problem:** Kompletter Teardown-Pfad im Binary.
|
||||||
|
**Lösung A (einfach):** Scripts zur Compile-Zeit mit einem festen XOR-Key oder AES-Key verschlüsseln, zur Laufzeit dekryptieren. Kein echter Schutz gegen entschlossenen Analysten, aber verhindert triviale `strings`-Analyse. **Security-Theater-Level ist hoch.**
|
||||||
|
**Lösung B (besser):** PowerShell-Scripts server-seitig halten. Client pollt bei Setup ein signiertes Script-Template vom Backend, führt es aus. Vorteil: Scripts updatebar ohne App-Release. Nachteil: Offline-Setup geht nicht.
|
||||||
|
**Empfehlung:** Lösung B für Teardown-Script (ohnehin server-getriggert), Lösung A für Setup (muss offline gehen).
|
||||||
|
**Aufwand:** Lösung A ~1 Tag; Lösung B ~3 Tage (+ Backend-Endpoint).
|
||||||
|
|
||||||
|
#### 3.7 Mac: Profil-Removal-Kommando nicht im Binary
|
||||||
|
**Problem:** `profiles remove -identifier org.rebreak.protection.profile.*` im Source.
|
||||||
|
**Kontext:** Diese Funktion wird bewusst für Testing/Reset verwendet (`MacProfileInstaller.remove()`). Im finalen User-Flow ist sie nicht mehr erreichbar. Trotzdem im Binary vorhanden.
|
||||||
|
**Lösung:** `remove()` Methode in `#if DEBUG` wrappen. Im Release-Build nicht kompilieren.
|
||||||
|
**Aufwand:** ~30min.
|
||||||
|
|
||||||
|
#### 3.8 Pairing-Code: Rate-Limiting auf Backend
|
||||||
|
**Problem:** 6-stelliger Code, 10min TTL. Aktuell kein explizites Rate-Limiting im `/api/magic/pair/redeem`-Endpoint sichtbar (IP-basiertes Rate-Limiting unklar).
|
||||||
|
**Fix:** Max. 10 Redeem-Attempts pro IP + 5 pro Code, danach 429. Im Nitro-Middleware oder Endpoint.
|
||||||
|
**Aufwand:** ~2h. Eskalation an rebreak-backend für generisches Rate-Limiting-Middleware falls noch nicht vorhanden.
|
||||||
|
|
||||||
|
#### 3.9 `protection.json` DNS-Token verschlüsseln (Windows)
|
||||||
|
**Problem:** DNS-Token liegt im Klartext in der JSON-Datei.
|
||||||
|
**Lösung:** DNS-Token mit einem Geräteschlüssel (DPAPI unter Windows — `ProtectedData.Protect` mit `DataProtectionScope.LocalMachine`) verschlüsseln vor Speichern in `protection.json`. DPAPI bindet an die Machine — entschlüsselbar nur auf demselben Gerät, nicht auf einem anderen PC.
|
||||||
|
**Effekt:** Datei-Exfiltration nützt nichts. Lokaler Admin-Zugriff für Entschlüsselung nötig (aber dann eh vorbei).
|
||||||
|
**Aufwand:** ~2 Tage. Rust DPAPI via `windows-sys` Crate, braucht Windows-only Conditional Compilation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Priorität 3 — Größerer Umbau / Nice-to-Have
|
||||||
|
|
||||||
|
#### 3.10 Binary-Obfuscation
|
||||||
|
**Mac (Swift):** Swift hat keine etablierten Open-Source-Obfuskatoren. ProGuard-Äquivalent existiert nicht. Möglichkeiten:
|
||||||
|
- Symbol-Stripping (ist Standard bei Release-Builds): reduziert Klassen/Methodennamen — bereits gut
|
||||||
|
- `swiftc` mit Optimierungen + `strip` — kein echter RE-Schutz
|
||||||
|
- Kommerzielle Tools (Obfusco, iXGuard) — teuer, fragile, selten Worth-it bei Swift
|
||||||
|
|
||||||
|
**Windows (Rust):** Rust-Binaries sind nach Release-Kompilierung bereits gut obfusziert (keine Klassen/Methoden-Namen ohne DWARF-Symbole). String-Literale bleiben das Problem (siehe 3.6). Packer wie `UPX` sind möglich aber:
|
||||||
|
- AV-Programme flaggen UPX-gepackte Binaries häufig
|
||||||
|
- Kein echter Security-Gain gegen entschlossenen Analysten
|
||||||
|
|
||||||
|
**Empfehlung:** Nicht investieren. ROI zu niedrig. Focus auf Secrets-Management + Service-Schutz.
|
||||||
|
|
||||||
|
#### 3.11 Certificate Pinning für Backend-Calls
|
||||||
|
**Aktuell:** Standard-TLS (kein Pinning). MITM-Angriff theoretisch möglich wenn User eigenes Root-Cert installiert.
|
||||||
|
**Mac:** `URLSession` mit Custom `URLSessionDelegate` + Pinning auf `staging.rebreak.org`-Cert / SPKI.
|
||||||
|
**Windows:** `reqwest` mit Custom Certificate Verifier.
|
||||||
|
**Aufwand:** ~3 Tage. Wartungsaufwand hoch (Cert-Rotationen). Relevant vor allem wenn App auf `app.rebreak.org` (Prod) umgestellt wird.
|
||||||
|
|
||||||
|
#### 3.12 Code-Signing Status
|
||||||
|
**Mac (aktuell unbekannt):** Notarization-Status des `.app`-Bundles ist unklar. Ohne Notarization zeigt macOS Gatekeeper-Warning beim ersten Start. **Zuständigkeit: User + zied (Developer-ID Credentials).**
|
||||||
|
**Windows (aktuell unbekannt):** Authenticode-Signatur des `.exe`. Ohne Signatur: SmartScreen-Warning bei Download/Start. Signatur mit EV Code Signing Cert (OV reicht nicht mehr für SmartScreen-Reputations-Aufbau). **Zuständigkeit: User + zied.**
|
||||||
|
|
||||||
|
**Diese Punkte sind keine RE-Härtung, aber essentiell für User-Trust und Download-Conversion.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Zusammenfassung nach Plattform
|
||||||
|
|
||||||
|
### Mac
|
||||||
|
| # | Maßnahme | Aufwand | Priorität |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 3.2 | Debug-Code aus Release entfernen | 1h | Quick Win |
|
||||||
|
| 3.4 | Intern-Strings/Kommentare bereinigen | 30min | Quick Win |
|
||||||
|
| 3.7 | `remove()` auf `#if DEBUG` | 30min | Mittel |
|
||||||
|
| 3.11 | Cert-Pinning | 3 Tage | Nice-to-have |
|
||||||
|
| 3.12 | Notarization (User/zied) | extern | Eskalation |
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
| # | Maßnahme | Aufwand | Priorität |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 3.1 | `protection.json` ACL härten | 2h | Quick Win |
|
||||||
|
| 3.3 | Service-Name weniger offensichtlich | 1h | Quick Win |
|
||||||
|
| 3.5 | Service DACL-Restriktion (Prüfen) | 3 Tage | Mittel |
|
||||||
|
| 3.6 | PS-Scripts nicht als Klartext-Strings | 1–3 Tage | Mittel |
|
||||||
|
| 3.9 | DNS-Token DPAPI-verschlüsseln | 2 Tage | Mittel |
|
||||||
|
| 3.12 | Authenticode-Signatur (User/zied) | extern | Eskalation |
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
| # | Maßnahme | Aufwand | Priorität |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 3.8 | Pairing-Code Rate-Limiting | 2h | Quick Win |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Was die Härtung NICHT schützt (Grenzen klar kommunizieren)
|
||||||
|
|
||||||
|
- **Admin-User kann alles aufheben.** Das ist kein Bug, das ist Windows und macOS. Der echte Schutz ist der Cooldown-Server + die Psychologie (24h reichen oft für den Impuls zu vergehen).
|
||||||
|
- **Binary-Obfuscation ≠ Schutz.** Ein entschlossener Competitor mit einem Analysten und einem Tag Zeit kommt an alles ran. Das Ziel ist: erhöhte Hürde für Gelegenheits-RE, nicht Unmöglichkeit.
|
||||||
|
- **Die Kern-Sicherheit liegt im Backend:** Token-Revocation ist server-seitig. Kein lokaler Angriff kann ein revoked Token reaktivieren. Das ist die richtige Architektur-Entscheidung und bleibt so.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Empfohlene Implementierungs-Reihenfolge
|
||||||
|
|
||||||
|
1. **Sofort (vor Public-Download):** 3.1 (ACL) + 3.2 (Debug-Guards) + 3.7 (Remove-Method) + 3.8 (Rate-Limiting) — zusammen ~1 Tag
|
||||||
|
2. **Sprint 1 (Woche 1):** 3.9 (DPAPI) + 3.6 Lösung A (Script-Obfuskation, keine echte Sicherheit aber reduziert Einblick)
|
||||||
|
3. **Sprint 2 (Woche 2–3):** 3.5 (Service-DACL, mit Test-Aufwand) + 3.11 (Cert-Pinning wenn auf Prod)
|
||||||
|
4. **Parallel, Founder/zied:** 3.12 Notarization + Authenticode — blockieren Download-Funnel ohne sie
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Dateipfade für Implementierung
|
||||||
|
|
||||||
|
**Quick Wins betreffen:**
|
||||||
|
- `apps/rebreak-magic-win/src-tauri/protection-core/src/lib.rs` — ACL im Setup-Script-Generator, Service-Name
|
||||||
|
- `apps/rebreak-magic-win/src-tauri/src/setup.rs` — ACL-Kommando im build_setup_script
|
||||||
|
- `apps/rebreak-magic-mac/Sources/Models/WizardModel.swift` — #if DEBUG Guards
|
||||||
|
- `apps/rebreak-magic-mac/Sources/Services/MacProfileInstaller.swift` — remove() in #if DEBUG
|
||||||
|
- `apps/rebreak-magic-mac/Sources/Services/MagicAPIClient.swift` — Kommentar-Strings
|
||||||
|
- `backend/server/api/magic/pair/redeem.post.ts` — Rate-Limiting
|
||||||
Loading…
x
Reference in New Issue
Block a user