Merge pull request #28 from screentinker/fix/security-quick-wins

fix(security): quick-win fixes from the codebase security review
This commit is contained in:
screentinker 2026-06-08 19:04:14 -05:00 committed by GitHub
commit 50ad1f670b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 318 additions and 50 deletions

View file

@ -67,11 +67,21 @@
android:screenOrientation="landscape"
android:theme="@style/Theme.RemoteDisplay.Fullscreen" />
<!-- WebSocket foreground service -->
<!-- WebSocket foreground service. #5: declares ONLY mediaPlayback - the
always-on service must not claim the mediaProjection FGS type, which
Android 14+ rejects unless a projection consent token is held. -->
<service
android:name=".service.WebSocketService"
android:exported="false"
android:foregroundServiceType="mediaPlayback|mediaProjection" />
android:foregroundServiceType="mediaPlayback" />
<!-- #5: dedicated MediaProjection foreground service for system screen
capture. Started only after the user grants consent, so claiming the
mediaProjection FGS type is valid on Android 14+. -->
<service
android:name=".service.MediaProjectionService"
android:exported="false"
android:foregroundServiceType="mediaProjection" />
<!-- Accessibility service for power controls -->
<service

View file

@ -6,7 +6,7 @@ import android.content.Intent
import android.media.projection.MediaProjectionManager
import android.os.Bundle
import android.util.Log
import com.remotedisplay.player.service.ScreenCaptureService
import com.remotedisplay.player.service.MediaProjectionService
/**
* Transparent activity that requests MediaProjection permission.
@ -50,8 +50,11 @@ class ScreenCapturePermissionActivity : Activity() {
Companion.resultData = data?.clone() as? Intent
Companion.hasPermission = true
// Tell the service to start the projection
ScreenCaptureService.startProjection(this, resultCode, data)
// #5: hand the consent to the dedicated mediaProjection foreground
// service. It must enter the foreground with the mediaProjection FGS
// type BEFORE getMediaProjection() on Android 14+ - an Activity can't
// do that, so we can't call getMediaProjection() directly here.
MediaProjectionService.start(this, resultCode, data)
getSharedPreferences("remote_display", MODE_PRIVATE)
.edit().putBoolean("screen_capture_granted", true).apply()

View file

@ -0,0 +1,101 @@
package com.remotedisplay.player.service
import android.app.Activity
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import com.remotedisplay.player.RemoteDisplayApp
/**
* #5: Foreground service that owns the MediaProjection FGS type for system-wide
* screen capture (the `enable_system_capture` command).
*
* Android 14+ requires an FGS of type `mediaProjection` to be running - started
* AFTER the user grants consent - before MediaProjectionManager.getMediaProjection()
* may be called. An Activity can't enter that foreground state, so the consent
* Activity hands the result here. Kept separate from WebSocketService so the
* always-on service never claims the mediaProjection type at boot.
*/
class MediaProjectionService : Service() {
companion object {
private const val TAG = "MediaProjectionSvc"
private const val NOTIF_ID = 2
private const val EXTRA_RESULT_CODE = "result_code"
private const val EXTRA_RESULT_DATA = "result_data"
/** Start the projection FGS with the user's consent result. */
fun start(context: Context, resultCode: Int, data: Intent) {
val intent = Intent(context, MediaProjectionService::class.java).apply {
putExtra(EXTRA_RESULT_CODE, resultCode)
putExtra(EXTRA_RESULT_DATA, data)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
fun stop(context: Context) {
context.stopService(Intent(context, MediaProjectionService::class.java))
}
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// Enter the foreground with the mediaProjection type FIRST (required on
// Android 14+ before getMediaProjection()).
startForegroundCompat()
val resultCode = intent?.getIntExtra(EXTRA_RESULT_CODE, Activity.RESULT_CANCELED)
?: Activity.RESULT_CANCELED
@Suppress("DEPRECATION")
val data: Intent? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent?.getParcelableExtra(EXTRA_RESULT_DATA, Intent::class.java)
} else {
intent?.getParcelableExtra(EXTRA_RESULT_DATA)
}
if (resultCode != Activity.RESULT_OK || data == null) {
Log.e(TAG, "Missing/invalid projection consent; stopping service")
stopSelf()
return START_NOT_STICKY
}
return try {
ScreenCaptureService.startProjection(this, resultCode, data)
START_STICKY
} catch (e: Throwable) {
Log.e(TAG, "startProjection failed: ${e.message}", e)
stopSelf()
START_NOT_STICKY
}
}
private fun startForegroundCompat() {
val notif = NotificationCompat.Builder(this, RemoteDisplayApp.CHANNEL_ID)
.setContentTitle("ScreenTinker")
.setContentText("Screen capture active")
.setSmallIcon(android.R.drawable.ic_menu_camera)
.setOngoing(true)
.build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIF_ID, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION)
} else {
startForeground(NOTIF_ID, notif)
}
}
override fun onDestroy() {
// Release the projection when the service goes away.
try { ScreenCaptureService.stop() } catch (_: Throwable) {}
super.onDestroy()
}
}

View file

@ -54,13 +54,9 @@ object ScreenCaptureService {
imageReader = ImageReader.newInstance(captureWidth, captureHeight, PixelFormat.RGBA_8888, 4)
virtualDisplay = projection.createVirtualDisplay(
"ScreenTinker",
captureWidth, captureHeight, density,
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
imageReader!!.surface, null, null
)
// #5: Android 14+ requires a Callback registered BEFORE createVirtualDisplay,
// otherwise createVirtualDisplay throws IllegalStateException. (Was registered
// after, which broke system capture on Android 14+.)
projection.registerCallback(object : MediaProjection.Callback() {
override fun onStop() {
Log.i(TAG, "MediaProjection stopped by system")
@ -68,6 +64,13 @@ object ScreenCaptureService {
}
}, null)
virtualDisplay = projection.createVirtualDisplay(
"ScreenTinker",
captureWidth, captureHeight, density,
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
imageReader!!.surface, null, null
)
Log.i(TAG, "MediaProjection started: ${captureWidth}x${captureHeight}")
}

View file

@ -53,7 +53,16 @@ class WebSocketService : Service() {
super.onCreate()
config = ServerConfig(this)
deviceInfo = DeviceInfo(this)
// #5: claim ONLY the mediaPlayback FGS type. The 2-arg startForeground
// claims every manifest-declared type, and on Android 14+ claiming
// mediaProjection without a consent token throws and kills the service at
// boot (the "app won't run on newer Android" symptom). Screen capture has
// its own mediaProjection-typed service (MediaProjectionService).
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
startForeground(1, createNotification(), android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)
} else {
startForeground(1, createNotification())
}
// Keep CPU alive so the WebSocket connection stays alive in background
val pm = getSystemService(POWER_SERVICE) as android.os.PowerManager

View file

@ -1,5 +1,6 @@
import { showToast } from '../components/toast.js';
import { t } from '../i18n.js';
import { esc } from '../utils.js';
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json());
@ -44,12 +45,12 @@ async function renderList(container) {
<span style="font-size:48px">&#128433;</span>
</div>
<div class="content-item-body">
<div class="content-item-name">${p.name}</div>
<div class="content-item-name">${esc(p.name)}</div>
<div class="content-item-size">${t('kiosk.label')}</div>
</div>
<div class="content-item-actions">
<a href="/api/kiosk/${p.id}/render" target="_blank" class="btn btn-secondary btn-sm" style="text-decoration:none" onclick="event.stopPropagation()">${t('kiosk.preview')}</a>
<button class="btn btn-danger btn-sm" data-delete-kiosk="${p.id}" data-kiosk-name="${p.name}" onclick="event.stopPropagation()">${t('common.delete')}</button>
<button class="btn btn-danger btn-sm" data-delete-kiosk="${esc(p.id)}" data-kiosk-name="${esc(p.name)}" onclick="event.stopPropagation()">${t('common.delete')}</button>
</div>
</div>
`).join('');
@ -85,7 +86,7 @@ async function renderEditor(container, pageId) {
${t('kiosk.back')}
</a>
<div class="page-header">
<h1>${page.name}</h1>
<h1>${esc(page.name)}</h1>
<div style="display:flex;gap:8px">
<a href="/api/kiosk/${pageId}/render" target="_blank" class="btn btn-secondary" style="text-decoration:none">${t('kiosk.preview')}</a>
<button class="btn btn-primary" id="saveKioskBtn">${t('common.save')}</button>
@ -98,17 +99,17 @@ async function renderEditor(container, pageId) {
<div style="width:320px;max-height:calc(100vh - 140px);overflow-y:auto;display:flex;flex-direction:column;gap:12px">
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:12px">
<h4 style="font-size:13px;margin-bottom:10px">${t('kiosk.page_settings')}</h4>
<div class="form-group"><label>${t('kiosk.title_label')}</label><input type="text" id="kTitle" class="input" value="${config.title || ''}"></div>
<div class="form-group"><label>${t('kiosk.subtitle_label')}</label><input type="text" id="kSubtitle" class="input" value="${config.subtitle || ''}"></div>
<div class="form-group"><label>${t('kiosk.logo_url')}</label><input type="text" id="kLogo" class="input" value="${config.logoUrl || ''}" placeholder="https://..."></div>
<div class="form-group"><label>${t('kiosk.footer_text')}</label><input type="text" id="kFooter" class="input" value="${config.footer || ''}"></div>
<div class="form-group"><label>${t('kiosk.idle_title')}</label><input type="text" id="kIdleTitle" class="input" value="${config.idleTitle || t('kiosk.idle_default')}"></div>
<div class="form-group"><label>${t('kiosk.title_label')}</label><input type="text" id="kTitle" class="input" value="${esc(config.title || '')}"></div>
<div class="form-group"><label>${t('kiosk.subtitle_label')}</label><input type="text" id="kSubtitle" class="input" value="${esc(config.subtitle || '')}"></div>
<div class="form-group"><label>${t('kiosk.logo_url')}</label><input type="text" id="kLogo" class="input" value="${esc(config.logoUrl || '')}" placeholder="https://..."></div>
<div class="form-group"><label>${t('kiosk.footer_text')}</label><input type="text" id="kFooter" class="input" value="${esc(config.footer || '')}"></div>
<div class="form-group"><label>${t('kiosk.idle_title')}</label><input type="text" id="kIdleTitle" class="input" value="${esc(config.idleTitle || t('kiosk.idle_default'))}"></div>
<div class="form-group"><label>${t('kiosk.idle_timeout')}</label><input type="number" id="kIdleTimeout" class="input" value="${config.idleTimeout || 60}"></div>
</div>
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:12px">
<h4 style="font-size:13px;margin-bottom:10px">${t('kiosk.style')}</h4>
<div class="form-group"><label>${t('kiosk.background')}</label><input type="text" id="kBg" class="input" value="${config.style?.background || '#111827'}"></div>
<div class="form-group"><label>${t('kiosk.background')}</label><input type="text" id="kBg" class="input" value="${esc(config.style?.background || '#111827')}"></div>
<div class="form-group"><label>${t('kiosk.text_color')}</label><input type="color" id="kTextColor" value="${config.style?.textColor || '#f1f5f9'}" style="width:100%;height:28px;border:none;cursor:pointer"></div>
<div class="form-group"><label>${t('kiosk.columns')}</label><select id="kColumns" class="input" style="background:var(--bg-input)">
<option ${(config.style?.columns || 3) === 2 ? 'selected' : ''} value="2">2</option>
@ -135,10 +136,10 @@ async function renderEditor(container, pageId) {
list.innerHTML = config.buttons.map((btn, i) => `
<div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:8px;margin-bottom:6px">
<div style="display:flex;gap:6px;margin-bottom:6px">
<input type="text" class="input" value="${btn.icon || ''}" placeholder="${t('kiosk.icon_placeholder')}" style="width:50px;text-align:center" data-btn="${i}" data-field="icon">
<input type="text" class="input" value="${btn.label || ''}" placeholder="${t('kiosk.label_placeholder')}" style="flex:1" data-btn="${i}" data-field="label">
<input type="text" class="input" value="${esc(btn.icon || '')}" placeholder="${t('kiosk.icon_placeholder')}" style="width:50px;text-align:center" data-btn="${i}" data-field="icon">
<input type="text" class="input" value="${esc(btn.label || '')}" placeholder="${t('kiosk.label_placeholder')}" style="flex:1" data-btn="${i}" data-field="label">
</div>
<input type="text" class="input" value="${btn.sublabel || ''}" placeholder="${t('kiosk.sublabel_placeholder')}" style="font-size:12px;margin-bottom:4px" data-btn="${i}" data-field="sublabel">
<input type="text" class="input" value="${esc(btn.sublabel || '')}" placeholder="${t('kiosk.sublabel_placeholder')}" style="font-size:12px;margin-bottom:4px" data-btn="${i}" data-field="sublabel">
<div style="display:flex;gap:6px;align-items:center">
<select class="input" style="background:var(--bg-input);font-size:11px;flex:1" data-btn="${i}" data-field="action">
<option value="" ${!btn.action ? 'selected' : ''}>${t('kiosk.action_none')}</option>
@ -147,7 +148,7 @@ async function renderEditor(container, pageId) {
</select>
<button class="btn-icon" style="color:var(--danger)" data-remove-btn="${i}" title="${t('common.delete')}">&#10005;</button>
</div>
<input type="text" class="input" value="${btn.url || btn.page || ''}" placeholder="${t('kiosk.url_placeholder')}" style="font-size:11px;margin-top:4px" data-btn="${i}" data-field="url">
<input type="text" class="input" value="${esc(btn.url || btn.page || '')}" placeholder="${t('kiosk.url_placeholder')}" style="font-size:11px;margin-top:4px" data-btn="${i}" data-field="url">
</div>
`).join('') || `<p style="color:var(--text-muted);font-size:12px">${t('kiosk.no_buttons')}</p>`;

View file

@ -1,6 +1,7 @@
import { api } from '../api.js';
import { showToast } from '../components/toast.js';
import { t, tn } from '../i18n.js';
import { esc } from '../utils.js';
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json());
@ -84,12 +85,12 @@ function renderLayoutCard(layout, isTemplate) {
${(layout.zones || []).map(z => `
<div style="position:absolute;left:${z.x_percent}%;top:${z.y_percent}%;width:${z.width_percent}%;height:${z.height_percent}%;
background:rgba(59,130,246,0.15);border:1px solid rgba(59,130,246,0.4);display:flex;align-items:center;justify-content:center;
font-size:9px;color:var(--text-muted);overflow:hidden">${z.name}</div>
font-size:9px;color:var(--text-muted);overflow:hidden">${esc(z.name)}</div>
`).join('')}
</div>
</div>
<div class="content-item-body">
<div class="content-item-name">${layout.name}</div>
<div class="content-item-name">${esc(layout.name)}</div>
<div class="content-item-size">${zonesText}${isTemplate ? ' • ' + t('layout.template_label') : ''}</div>
</div>
<div class="content-item-actions">
@ -97,7 +98,7 @@ function renderLayoutCard(layout, isTemplate) {
? `<button class="btn btn-primary btn-sm" data-use-template="${layout.id}">${t('layout.use_template')}</button>`
: `<button class="btn btn-secondary btn-sm" data-edit-layout="${layout.id}">${t('common.edit')}</button>`
}
<button class="btn btn-danger btn-sm" data-delete-layout="${layout.id}" data-layout-name="${layout.name}" style="margin-left:4px">${t('common.delete')}</button>
<button class="btn btn-danger btn-sm" data-delete-layout="${layout.id}" data-layout-name="${esc(layout.name)}" style="margin-left:4px">${t('common.delete')}</button>
</div>
</div>
`;
@ -115,7 +116,7 @@ async function renderEditor(container, layoutId) {
${t('layout.back')}
</a>
<div class="page-header">
<h1 id="layoutName">${layout.name}</h1>
<h1 id="layoutName">${esc(layout.name)}</h1>
<div style="display:flex;gap:8px">
<button class="btn btn-secondary btn-sm" id="addZoneBtn">${t('layout.add_zone')}</button>
<button class="btn btn-primary btn-sm" id="saveLayoutBtn">${t('common.save')}</button>
@ -228,8 +229,8 @@ async function renderEditor(container, layoutId) {
<div style="padding:8px 10px;background:${selectedZone === i ? 'var(--bg-card-hover)' : 'var(--bg-secondary)'};
border:1px solid ${selectedZone === i ? 'var(--accent)' : 'var(--border)'};border-radius:var(--radius);
margin-bottom:4px;cursor:pointer;font-size:13px" data-zone-idx="${i}">
<div style="font-weight:500">${z.name}</div>
<div style="font-size:11px;color:var(--text-muted)">${Math.round(z.width_percent)}% x ${Math.round(z.height_percent)}% ${z.zone_type}</div>
<div style="font-weight:500">${esc(z.name)}</div>
<div style="font-size:11px;color:var(--text-muted)">${Math.round(z.width_percent)}% x ${Math.round(z.height_percent)}% ${esc(z.zone_type)}</div>
</div>
`).join('');

View file

@ -1,6 +1,7 @@
import { api } from '../api.js';
import { showToast } from '../components/toast.js';
import { t, tn } from '../i18n.js';
import { esc } from '../utils.js';
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json());
@ -46,9 +47,9 @@ async function renderList(container) {
<div class="content-item" style="cursor:pointer" onclick="window.location.hash='#/team/${team.id}'">
<div style="padding:20px">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px">
<div style="width:40px;height:40px;border-radius:50%;background:var(--accent);display:flex;align-items:center;justify-content:center;font-size:18px;font-weight:700;color:white">${team.name[0].toUpperCase()}</div>
<div style="width:40px;height:40px;border-radius:50%;background:var(--accent);display:flex;align-items:center;justify-content:center;font-size:18px;font-weight:700;color:white">${esc(team.name[0].toUpperCase())}</div>
<div>
<div style="font-weight:600;font-size:16px">${team.name}</div>
<div style="font-weight:600;font-size:16px">${esc(team.name)}</div>
<div style="font-size:12px;color:var(--text-muted)">${t('team.your_role', { role: team.my_role })} &middot; ${tn('team.member_count', team.member_count)}</div>
</div>
</div>
@ -77,7 +78,7 @@ async function renderTeamDetail(container, teamId) {
${t('team.back')}
</a>
<div class="page-header">
<h1>${team.name}</h1>
<h1>${esc(team.name)}</h1>
<div style="display:flex;gap:8px">
<button class="btn btn-danger btn-sm" id="deleteTeamBtn">${t('team.delete_team')}</button>
</div>
@ -92,10 +93,10 @@ async function renderTeamDetail(container, teamId) {
<div id="membersList">
${(team.members || []).map(m => `
<div style="display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid var(--border)">
<div style="width:32px;height:32px;border-radius:50%;background:var(--bg-input);display:flex;align-items:center;justify-content:center;font-size:13px;font-weight:600;color:var(--text-secondary)">${(m.user_name || m.email)[0].toUpperCase()}</div>
<div style="width:32px;height:32px;border-radius:50%;background:var(--bg-input);display:flex;align-items:center;justify-content:center;font-size:13px;font-weight:600;color:var(--text-secondary)">${esc((m.user_name || m.email)[0].toUpperCase())}</div>
<div style="flex:1;min-width:0">
<div style="font-size:13px;font-weight:500">${m.user_name || m.email}</div>
<div style="font-size:11px;color:var(--text-muted)">${m.email}</div>
<div style="font-size:13px;font-weight:500">${esc(m.user_name || m.email)}</div>
<div style="font-size:11px;color:var(--text-muted)">${esc(m.email)}</div>
</div>
<select class="input" style="max-width:100px;width:100%;background:var(--bg-input);font-size:12px;padding:4px 8px" data-member-id="${m.user_id}" ${m.role === 'owner' ? 'disabled' : ''}>
<option value="viewer" ${m.role === 'viewer' ? 'selected' : ''}>${t('team.role_viewer')}</option>
@ -113,7 +114,7 @@ async function renderTeamDetail(container, teamId) {
<h3 style="font-size:15px">${t('team.shared_devices', { n: devices.length })}</h3>
<select id="addDeviceToTeam" class="input" style="max-width:200px;width:100%;background:var(--bg-input);font-size:12px">
<option value="">${t('team.add_device')}</option>
${unassignedDevices.map(d => `<option value="${d.id}">${d.name}</option>`).join('')}
${unassignedDevices.map(d => `<option value="${esc(d.id)}">${esc(d.name)}</option>`).join('')}
</select>
</div>
<div id="teamDevicesList">
@ -121,8 +122,8 @@ async function renderTeamDetail(container, teamId) {
<div style="display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid var(--border)">
<span class="status-dot ${d.status}"></span>
<div style="flex:1">
<div style="font-size:13px;font-weight:500">${d.name}</div>
<div style="font-size:11px;color:var(--text-muted)">${d.status}</div>
<div style="font-size:13px;font-weight:500">${esc(d.name)}</div>
<div style="font-size:11px;color:var(--text-muted)">${esc(d.status)}</div>
</div>
<button class="btn-icon" data-remove-device="${d.id}" style="color:var(--danger)" title="${t('team.remove_from_team')}">&#10005;</button>
</div>

View file

@ -50,4 +50,14 @@ function resolveBranding(db, { workspaceId = null, domain = null } = {}) {
return platformDefaultRow(db) || { ...HARDCODED_BRANDING };
}
module.exports = { resolveBranding, platformDefaultRow, HARDCODED_BRANDING, PLATFORM_DEFAULT_ID };
// Presentational fields only. The PUBLIC resolver (GET /api/branding) and the
// by-domain lookup must not leak internal columns (id, user_id, workspace_id,
// custom_domain, timestamps) to unauthenticated / cross-tenant callers.
const PUBLIC_BRANDING_FIELDS = ['brand_name', 'logo_url', 'favicon_url', 'primary_color', 'secondary_color', 'bg_color', 'custom_css', 'hide_branding'];
function publicBranding(row) {
const out = {};
for (const f of PUBLIC_BRANDING_FIELDS) out[f] = row ? (row[f] ?? null) : null;
return out;
}
module.exports = { resolveBranding, platformDefaultRow, publicBranding, HARDCODED_BRANDING, PLATFORM_DEFAULT_ID };

View file

@ -0,0 +1,14 @@
'use strict';
// Security: never return a device's WebSocket auth secret to API/dashboard
// clients. `device_token` is the credential the device proves with (validated
// via crypto.timingSafeEqual on the /device socket); leaking it to any
// workspace user enables device impersonation. Strip it from every device row
// before it leaves the server.
function stripDeviceSecrets(d) {
if (!d || typeof d !== 'object') return d;
delete d.device_token;
return d;
}
module.exports = { stripDeviceSecrets };

View file

@ -53,6 +53,16 @@ function requireAuth(req, res, next) {
req.user = user;
// Tenancy middleware reads this on the resolver step.
req.jwtWorkspaceId = decoded.current_workspace_id || null;
// #7: enforce the forced first-login password change SERVER-SIDE (was a
// frontend-only redirect, so a provisioned temp password worked indefinitely
// via the API). While the flag is set, allow only reading/updating one's own
// profile (the password change is PUT /api/auth/me, which clears the flag)
// and logout; block everything else.
if (user.must_change_password) {
const url = (req.originalUrl || '').split('?')[0].replace(/\/$/, '');
const allowed = url === '/api/auth/me' || url === '/api/auth/logout';
if (!allowed) return res.status(403).json({ error: 'password_change_required' });
}
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired token' });

View file

@ -5,6 +5,7 @@ const { PLATFORM_ROLES, ELEVATED_ROLES, isPlatformStaff } = require('../middlewa
// Phase 2.2a: workspace-aware access. accessContext returns { workspaceRole, actingAs }
// or null based on the caller's reach into a specific workspace.
const { accessContext } = require('../lib/tenancy');
const { stripDeviceSecrets } = require('../lib/device-sanitize');
// List devices in the caller's current workspace.
// Phase 2.2a: filter by workspace_id instead of user_id. The caller's current
@ -39,7 +40,7 @@ router.get('/', (req, res) => {
ORDER BY d.created_at ASC
LIMIT ? OFFSET ?
`).all(req.workspaceId, limit, offset);
res.json(devices);
res.json(devices.map(stripDeviceSecrets));
});
// List unclaimed provisioning devices (admin only).
@ -118,7 +119,7 @@ router.get('/:id', (req, res) => {
'SELECT reported_at FROM device_telemetry WHERE device_id = ? AND reported_at > ? ORDER BY reported_at ASC'
).all(req.params.id, dayAgo).map(r => r.reported_at);
res.json({ ...device, telemetry, screenshot, assignments, playlist_status, playlist_has_published, uptimeData, statusLog });
res.json({ ...stripDeviceSecrets(device), telemetry, screenshot, assignments, playlist_status, playlist_has_published, uptimeData, statusLog });
});
// Helper: check device write access via the workspace the device belongs to.
@ -162,7 +163,7 @@ router.put('/:id', (req, res) => {
}
const updated = db.prepare('SELECT * FROM devices WHERE id = ?').get(req.params.id);
res.json(updated);
res.json(stripDeviceSecrets(updated));
});
// Delete device

View file

@ -17,7 +17,7 @@ router.post('/', (req, res) => {
`).run(device.id);
const updated = db.prepare('SELECT * FROM devices WHERE id = ?').get(device.id);
res.json(updated);
res.json(require('../lib/device-sanitize').stripDeviceSecrets(updated));
});
module.exports = router;

View file

@ -5,7 +5,7 @@ const { db } = require('../db/database');
// Phase 2.2f: workspace-scoped branding. POST gated by requireWorkspaceAdmin
// per the design doc (branding is a workspace_admin power, not editor).
const { requireWorkspaceAdmin } = require('../lib/permissions');
const { resolveBranding } = require('../lib/branding');
const { resolveBranding, publicBranding } = require('../lib/branding');
// Get the current workspace's effective branding. #15: when the workspace has no
// row of its own, fall through to the platform default (workspace_id IS NULL)
@ -19,7 +19,7 @@ router.get('/', (req, res) => {
// hardcoded. (Mounted behind requireAuth like the rest of this router; the
// public/pre-login path is GET /api/branding, registered before auth.)
router.get('/domain/:domain', (req, res) => {
res.json(resolveBranding(db, { domain: req.params.domain }));
res.json(publicBranding(resolveBranding(db, { domain: req.params.domain })));
});
// Create or update the current workspace's white-label config. Restricted to
@ -30,6 +30,17 @@ router.post('/', requireWorkspaceAdmin, (req, res) => {
const { brand_name, logo_url, favicon_url, primary_color, secondary_color, bg_color,
custom_domain, custom_css, hide_branding } = req.body;
// Security (#3): custom_domain drives the PUBLIC, pre-auth branding resolver
// (GET /api/branding) and custom_css is injected into the login page's <style>.
// A workspace_admin who set custom_domain to the platform's own host would
// hijack every visitor's login page (defacement / fake-login CSS). Both are
// powerful, cross-tenant-affecting fields - restrict them to platform admins.
const setsDomain = custom_domain !== undefined && custom_domain !== null && custom_domain !== '';
const setsCss = custom_css !== undefined && custom_css !== null && custom_css !== '';
if (!req.isPlatformAdmin && (setsDomain || setsCss)) {
return res.status(403).json({ error: 'custom_domain and custom_css can only be set by a platform administrator.' });
}
let wl = db.prepare('SELECT * FROM white_labels WHERE workspace_id = ?').get(req.workspaceId);
if (wl) {

View file

@ -282,9 +282,11 @@ app.use('/api/player-debug', require('./routes/player-debug'));
// the request hostname (trust-proxy resolves the forwarded Host behind CF/Nginx).
app.get('/api/branding', (req, res) => {
const { db } = require('./db/database');
const { resolveBranding } = require('./lib/branding');
const { resolveBranding, publicBranding } = require('./lib/branding');
const domain = (req.query.domain || req.hostname || '').toString();
res.json(resolveBranding(db, { domain }));
// publicBranding strips internal columns (id/user_id/workspace_id/custom_domain
// /timestamps) so this unauthenticated endpoint only exposes presentational fields.
res.json(publicBranding(resolveBranding(db, { domain })));
});
// Stripe billing routes (checkout, portal)
@ -349,6 +351,13 @@ app.get('/api/content/:id/thumbnail', (req, res) => {
const { db } = require('./db/database');
const content = db.prepare('SELECT * FROM content WHERE id = ?').get(req.params.id);
if (!content || !content.thumbnail_path) return res.status(404).json({ error: 'Thumbnail not found' });
// Security: gate the same way as /file - only serve when the content is
// referenced by a playlist or by a widget IN THE CONTENT'S WORKSPACE. Without
// this, any anonymous caller holding a content UUID could pull any tenant's
// thumbnail (the /file route already had this check; the thumbnail route did not).
const inPlaylist = db.prepare('SELECT id FROM playlist_items WHERE content_id = ? LIMIT 1').get(req.params.id);
const inWidget = inPlaylist ? null : db.prepare('SELECT id FROM widgets WHERE workspace_id = ? AND config LIKE ? LIMIT 1').get(content.workspace_id, `%/api/content/${req.params.id}/%`);
if (!inPlaylist && !inWidget) return res.status(403).json({ error: 'Content not assigned to any playlist or widget' });
const safePath = path.resolve(config.contentDir, path.basename(content.thumbnail_path));
if (!safePath.startsWith(path.resolve(config.contentDir))) return res.status(403).json({ error: 'Invalid path' });
res.sendFile(safePath);
@ -541,6 +550,7 @@ app.post('/api/provision/pair', requireAuth, resolveTenancy, checkDeviceLimit, (
deviceNs.to(device.id).emit('device:paired', { device_id: device.id, name: deviceName });
const updated = db.prepare('SELECT * FROM devices WHERE id = ?').get(device.id);
require('./lib/device-sanitize').stripDeviceSecrets(updated); // never leak device_token to clients
// Phase 2.3: scope to the workspace the device was just claimed into.
const { workspaceRoom, emitToWorkspace } = require('./lib/socket-rooms');
emitToWorkspace(dashboardNs, workspaceRoom(updated.workspace_id), 'dashboard:device-added', updated);

View file

@ -0,0 +1,83 @@
'use strict';
// Tests for the security quick-win fixes:
// - stripDeviceSecrets() never leaks device_token
// - publicBranding() exposes only presentational fields
// - requireAuth enforces must_change_password server-side (#7)
const test = require('node:test');
const assert = require('node:assert/strict');
const Database = require('better-sqlite3');
process.env.JWT_SECRET = 'test-secret-security-fixes';
const db = new Database(':memory:');
db.exec(`
CREATE TABLE users (
id TEXT PRIMARY KEY, email TEXT UNIQUE NOT NULL, name TEXT DEFAULT '',
password_hash TEXT, auth_provider TEXT NOT NULL DEFAULT 'local', avatar_url TEXT,
role TEXT NOT NULL DEFAULT 'user', plan_id TEXT DEFAULT 'free', email_alerts INTEGER DEFAULT 1,
must_change_password INTEGER NOT NULL DEFAULT 0
);
`);
const dbModulePath = require.resolve('../db/database');
require.cache[dbModulePath] = { id: dbModulePath, filename: dbModulePath, loaded: true, exports: { db } };
const express = require('express');
const { generateToken, requireAuth } = require('../middleware/auth');
const { stripDeviceSecrets } = require('../lib/device-sanitize');
const { publicBranding } = require('../lib/branding');
test('stripDeviceSecrets removes device_token, keeps other fields', () => {
const row = { id: 'd1', name: 'Lobby', device_token: 'SECRET', status: 'online' };
const out = stripDeviceSecrets(row);
assert.equal(out.device_token, undefined);
assert.equal(out.name, 'Lobby');
assert.equal(out.status, 'online');
assert.equal(stripDeviceSecrets(null), null); // null-safe
});
test('publicBranding exposes only presentational fields (no internal columns)', () => {
const dbRow = {
id: 'wl1', user_id: 'u1', workspace_id: 'ws1', custom_domain: 'evil.example',
created_at: 1, updated_at: 2,
brand_name: 'Acme', logo_url: 'l', favicon_url: 'f', primary_color: '#000',
secondary_color: '#111', bg_color: '#222', custom_css: 'body{}', hide_branding: 1,
};
const pub = publicBranding(dbRow);
for (const leaked of ['id', 'user_id', 'workspace_id', 'custom_domain', 'created_at', 'updated_at']) {
assert.equal(pub[leaked], undefined, `${leaked} must not be exposed`);
}
assert.equal(pub.brand_name, 'Acme');
assert.equal(pub.custom_css, 'body{}'); // login page needs this
assert.equal(pub.hide_branding, 1);
});
// --- #7: must_change_password enforced server-side ---
db.prepare("INSERT INTO users (id, email, role, must_change_password) VALUES ('u-mcp','mcp@test.local','user',1)").run();
db.prepare("INSERT INTO users (id, email, role, must_change_password) VALUES ('u-ok','ok@test.local','user',0)").run();
const tokMcp = generateToken({ id: 'u-mcp', email: 'mcp@test.local', role: 'user' }, null);
const tokOk = generateToken({ id: 'u-ok', email: 'ok@test.local', role: 'user' }, null);
const app = express();
// Mount requireAuth at the real prefixes so req.originalUrl matches the allowlist.
app.get('/api/auth/me', requireAuth, (req, res) => res.json({ ok: true }));
app.get('/api/devices', requireAuth, (req, res) => res.json({ ok: true }));
const server = app.listen(0);
let base;
test.before(async () => { await new Promise(r => server.listening ? r() : server.once('listening', r)); base = `http://127.0.0.1:${server.address().port}`; });
test.after(() => { server.close(); db.close(); });
test('must_change_password user is blocked from non-/me routes (403) but can reach /me', async () => {
const dev = await fetch(base + '/api/devices', { headers: { Authorization: `Bearer ${tokMcp}` } });
assert.equal(dev.status, 403);
assert.equal((await dev.json()).error, 'password_change_required');
const me = await fetch(base + '/api/auth/me', { headers: { Authorization: `Bearer ${tokMcp}` } });
assert.equal(me.status, 200, '/api/auth/me must stay reachable so the user can change their password');
});
test('a normal user (flag cleared) is not gated', async () => {
const dev = await fetch(base + '/api/devices', { headers: { Authorization: `Bearer ${tokOk}` } });
assert.equal(dev.status, 200);
});