# Security Audit & Hardening — 2026-05-28

Comprehensive security audit of UNSPOOLED Android TV app. 17 findings across 4 severities. 12 CRITICAL fixes applied.

---

## CRITICAL Findings & Fixes

### 1. Hardcoded Real-Debrid Client ID Fallback
**File:** `data/debrid/DebridAuthManager.kt:48`
```kotlin
// BEFORE (BAD):
private val realDebridClientId: String
    get() = BuildConfig.RD_CLIENT_ID.ifBlank { "X245A4XAIBGVM" }

// AFTER (FIXED):
private val realDebridClientId: String
    get() {
        val id = BuildConfig.RD_CLIENT_ID.ifBlank { null }
        requireNotNull(id) { "RD_CLIENT_ID not configured" }
        return id
    }
```
**Rule:** Never hardcode fallback secrets. Throw explicit errors when config is missing.

### 2. Hardcoded YouTube API Key
**File:** `data/trailer/InAppYouTubeExtractor.kt:217`
Removed `"AIzaSy...qcW8"` fallback. Returns `CachedConfig.empty` when no key configured.

### 3-7. Logging Leaks in Release Builds
**Pattern:** All `android.util.Log.d/e/w/i` calls that touch URLs, tokens, or internal state MUST be guarded:
```kotlin
if (BuildConfig.DEBUG) {
    android.util.Log.d("TAG", "→ ${SensitiveLogSanitizer.sanitizeUrl(url)}")
}
```
**Files fixed:** `DebridModule.kt`, `StremioAddonClientImpl.kt`, `PlaybackOrchestrator.kt`, `DebridResolveCache.kt`, `NexStreamApplication.kt` (CrashLogger)

### 8. SensitiveLogSanitizer Token Regex Too Narrow
```kotlin
// BEFORE: 40+ chars, only [A-Za-z0-9_-]
private val tokenPattern = Regex("""[A-Za-z0-9_-]{40,}""")

// AFTER: 20+ chars, added +/= for base64 tokens
private val tokenPattern = Regex("""[A-Za-z0-9+/=_-]{20,}""")
```

### 9. Network Security Config — Overly Broad Cleartext
```xml
<!-- BEFORE: includeSubdomains="true" exposes api.real-debrid.com etc. -->
<domain includeSubdomains="true">real-debrid.com</domain>

<!-- AFTER: exact domain only -->
<domain includeSubdomains="false">real-debrid.com</domain>
```

### 10. YouTube fetchPlayerResponse Ignoring apiKey Param
```kotlin
// BEFORE: literal "***" placeholder
val endpoint = "https://www.youtube.com/youtubei/v1/player?key=***"

// AFTER: actual key interpolation
val endpoint = "https://www.youtube.com/youtubei/v1/player?key=$apiKey"
```

### 11. AddonRepository Coroutine Scope Leak
```kotlin
// BEFORE: anonymous scope, no lifecycle
init {
    CoroutineScope(Dispatchers.IO).launch { loadManifestCacheFromDisk() }
}

// AFTER: dedicated scope with SupervisorJob
private val cacheLoadScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
init {
    cacheLoadScope.launch { loadManifestCacheFromDisk() }
}
```

### 12. ProGuard Gson Rules
Added to `proguard-rules.pro`:
```proguard
-keepclassmembers class com.nexstream.app.domain.model.DebridSettings { *; }
-keepclassmembers class com.nexstream.app.domain.model.DebridStreamPreferences { *; }
```

---

## HIGH Findings (NOT YET FIXED)

| # | Finding | Priority |
|---|---|---|
| 1 | No certificate pinning on any OkHttpClient | Add for api.real-debrid.com, api.trakt.tv, api.themoviedb.org |
| 2 | Debrid API keys in plain DataStore | Migrate to SecureTokenStore (Keystore AES-256/GCM) |
| 3 | Trakt client secret + TMDB key in plain DataStore | Same — use SecureTokenStore |
| 4 | AddonConfigServer binds to 0.0.0.0 (NanoHTTPD default) | Bind to specific LAN IP |

---

## BuildConfig.DEBUG Guard Pattern

Standard pattern for any logging in Android modules:
```kotlin
import com.nexstream.app.BuildConfig

// Guard all logging:
if (BuildConfig.DEBUG) {
    android.util.Log.d(TAG, "message")
}

// For string interpolation with sensitive content:
if (BuildConfig.DEBUG) {
    val safeUrl = SensitiveLogSanitizer.sanitizeUrl(url)
    android.util.Log.d(TAG, "→ $safeUrl")
}
```

## Force-Unwrap Elimination

Pattern for replacing `!!` across the codebase:
```kotlin
// BEFORE:
message = state.error!!
displayItem.progress!!

// AFTER (nullable with default):
message = state.error ?: "Unknown error"
(displayItem.progress ?: 0f) > 0f

// AFTER (safe let with early return):
val next = uiState.nextEpisode ?: return
episodeTitle = next.title

// AFTER (local val for smart-cast):
if (selectedCastMember != null) {
    val member = selectedCastMember!!  // guaranteed non-null
    CastDetailView(member = member, ...)
}
```

## Debrid Per-Provider State Pattern

Multi-provider debrid auth requires per-provider state — a single global `debridStatus`/`debridProvider` string causes state collision:
```kotlin
@Stable
data class DebridAuthState(
    val status: String = "",        // "waiting", "connected", "failed"
    val deviceCode: String? = null,
    val verificationUrl: String? = null,
    val error: String? = null,
)

// In UI state:
val debridStates: Map<DebridProvider, DebridAuthState> = 
    DebridProvider.entries.associateWith { DebridAuthState() }
val activeDebridProviders: List<DebridProvider> = emptyList()

// Update per provider:
val states = state.debridStates.toMutableMap()
states[provider] = DebridAuthState(status = "connected")
state.copy(
    debridStates = states,
    activeDebridProviders = (state.activeDebridProviders + provider).distinct()
)
```
