From 2eb38195f13d3e0bcb1a4ef9c402ea16d7da1444 Mon Sep 17 00:00:00 2001 From: Guoguo Date: Tue, 5 May 2026 02:33:25 -0700 Subject: [PATCH] fix: address critical security and data integrity issues from cross-audit - Add expire_time > NOW() filter to findActive() preventing stale subscriptions - Add express-rate-limit on login endpoints (user: 10/15min, admin: 5/15min) - Add production guard for default admin credentials - Fix BLE bindDevice userId encoding (uint32 instead of hexToBytes on numeric) - Wrap adminCreate in transaction to prevent race condition - Add settings cache invalidation after admin saves - Read trial_days from settings instead of hardcoding 7 - Fix double JSON.stringify in commandDao.finish call - Cancel stale pending bindings before creating new ones - Reduce token refresh grace period from 3 days to 1 day - Fix subscribe-success to fetch expiry from server (correct for renewals) - Add keep-alive name property to DashboardView and SettingsView - Fix BLE disconnect() to preserve listener registrations across reconnects --- admin-console/src/views/DashboardView.vue | 1 + admin-console/src/views/SettingsView.vue | 1 + .../subscribe-success/subscribe-success.js | 33 ++++++++---- miniprogram/services/ble/commands.js | 4 +- miniprogram/services/ble/connection.js | 4 +- server/package-lock.json | 31 ++++++++++- server/package.json | 1 + server/src/app.js | 20 +++++++ server/src/config.js | 1 + server/src/dao/binding.dao.js | 13 +++++ server/src/dao/subscription.dao.js | 54 ++++++++++--------- server/src/lib/settings-cache.js | 7 ++- server/src/routes/admin.js | 2 + server/src/routes/auth.js | 2 +- server/src/routes/device.js | 5 +- server/src/routes/firmware.js | 6 +++ server/src/routes/subscription.js | 7 ++- 17 files changed, 147 insertions(+), 45 deletions(-) diff --git a/admin-console/src/views/DashboardView.vue b/admin-console/src/views/DashboardView.vue index f4b0605..02c842f 100644 --- a/admin-console/src/views/DashboardView.vue +++ b/admin-console/src/views/DashboardView.vue @@ -113,6 +113,7 @@ import { get } from '../utils/request' import { formatDate as formatDateUtil } from '../utils/format' export default { + name: 'DashboardView', data() { return { stats: {}, diff --git a/admin-console/src/views/SettingsView.vue b/admin-console/src/views/SettingsView.vue index 8252400..30ca041 100644 --- a/admin-console/src/views/SettingsView.vue +++ b/admin-console/src/views/SettingsView.vue @@ -129,6 +129,7 @@ import { get, post } from '../utils/request' export default { + name: 'SettingsView', data() { return { settings: { diff --git a/miniprogram/pages/subscribe-success/subscribe-success.js b/miniprogram/pages/subscribe-success/subscribe-success.js index 9c07ee4..d713def 100644 --- a/miniprogram/pages/subscribe-success/subscribe-success.js +++ b/miniprogram/pages/subscribe-success/subscribe-success.js @@ -1,3 +1,5 @@ +var api = require('../../utils/api') + Page({ data: { statusBarHeight: 44, @@ -14,21 +16,32 @@ Page({ }, onLoad: function (options) { + var self = this var app = getApp() - this.setData({ statusBarHeight: app.globalData.statusBarHeight }) + self.setData({ statusBarHeight: app.globalData.statusBarHeight }) var planMap = { yearly: '年卡会员', monthly: '月卡会员', trial: '试用会员' } - var durationMap = { yearly: 365, monthly: 30, trial: 7 } var plan = options.plan || 'yearly' + self.setData({ planName: planMap[plan] || '会员' }) - var now = new Date() - now.setDate(now.getDate() + (durationMap[plan] || 365)) - var y = now.getFullYear() - var m = ('0' + (now.getMonth() + 1)).slice(-2) - var d = ('0' + now.getDate()).slice(-2) - this.setData({ - planName: planMap[plan] || '会员', - expiryDate: y + '年' + m + '月' + d + '日' + // Fetch actual subscription expiry from server instead of computing client-side + api.getSubscription().then(function (res) { + if (res && res.expire_time) { + var date = new Date(res.expire_time) + var y = date.getFullYear() + var m = ('0' + (date.getMonth() + 1)).slice(-2) + var d = ('0' + date.getDate()).slice(-2) + self.setData({ expiryDate: y + '年' + m + '月' + d + '日' }) + } + }).catch(function () { + // Fallback: compute from current date if server call fails + var durationMap = { yearly: 365, monthly: 30, trial: 7 } + var now = new Date() + now.setDate(now.getDate() + (durationMap[plan] || 365)) + var y = now.getFullYear() + var m = ('0' + (now.getMonth() + 1)).slice(-2) + var d = ('0' + now.getDate()).slice(-2) + self.setData({ expiryDate: y + '年' + m + '月' + d + '日' }) }) }, diff --git a/miniprogram/services/ble/commands.js b/miniprogram/services/ble/commands.js index b20dff6..5f12cb2 100644 --- a/miniprogram/services/ble/commands.js +++ b/miniprogram/services/ble/commands.js @@ -155,7 +155,7 @@ function queryStatus() { } function bindDevice(userId, bindToken) { - var userBytes = protocol.hexToBytes(userId) + var userBytes = protocol.uint32ToBytes(parseInt(userId, 10)) var tokenBytes = protocol.hexToBytes(bindToken) var ts = Math.floor(Date.now() / 1000) var tsBytes = protocol.uint32ToBytes(ts) @@ -165,7 +165,7 @@ function bindDevice(userId, bindToken) { } function unbindDevice(userId) { - var userBytes = protocol.hexToBytes(userId) + var userBytes = protocol.uint32ToBytes(parseInt(userId, 10)) var payload = [0x02].concat(userBytes) return writeCommandWithRetry(protocol.CMD.UNBIND, payload) } diff --git a/miniprogram/services/ble/connection.js b/miniprogram/services/ble/connection.js index 478a8df..0967d4b 100644 --- a/miniprogram/services/ble/connection.js +++ b/miniprogram/services/ble/connection.js @@ -236,7 +236,9 @@ function disconnect() { _chars = {} var commands = require('./commands') commands.clearPendingAcks() - _listeners = {} + // Only emit disconnected event; do not clear _listeners so that + // subscribers (other modules) retain their registrations across reconnects. + emit('disconnect_cleanup', null) wx.closeBluetoothAdapter({}) } diff --git a/server/package-lock.json b/server/package-lock.json index aca821f..f1b2255 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -12,10 +12,10 @@ "cos-nodejs-sdk-v5": "^2.14.7", "dotenv": "^16.4.5", "express": "^5.2.1", + "express-rate-limit": "^8.5.0", "jsonwebtoken": "^9.0.2", "mysql2": "^3.11.3" - }, - "devDependencies": {} + } }, "node_modules/@types/node": { "version": "25.6.0", @@ -600,6 +600,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.5.0", + "resolved": "https://registry.npmmirror.com/express-rate-limit/-/express-rate-limit-8.5.0.tgz", + "integrity": "sha512-XKhFohWaSBdVJNTi5TaHziqnPkv04I9UQV6q1Wy7Ui6GGQZVW12ojDFwqer14EvCXxjvPG0CyWXx7cAXpALB4Q==", + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/express/node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.54.0.tgz", @@ -965,6 +983,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmmirror.com/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz", diff --git a/server/package.json b/server/package.json index ae6ee77..4d60469 100644 --- a/server/package.json +++ b/server/package.json @@ -13,6 +13,7 @@ "cos-nodejs-sdk-v5": "^2.14.7", "dotenv": "^16.4.5", "express": "^5.2.1", + "express-rate-limit": "^8.5.0", "jsonwebtoken": "^9.0.2", "mysql2": "^3.11.3" } diff --git a/server/src/app.js b/server/src/app.js index 8373ec0..511b4df 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -1,9 +1,26 @@ const express = require('express') +const rateLimit = require('express-rate-limit') const { ok, fail } = require('./lib/response') const { authMiddleware } = require('./middleware/auth') const app = express() +// Rate limiting for auth-sensitive endpoints +const userLoginLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 10, + standardHeaders: true, + legacyHeaders: false, + message: { code: 2001, message: 'too_many_attempts' } +}) +const adminLoginLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 5, + standardHeaders: true, + legacyHeaders: false, + message: { code: 2001, message: 'too_many_attempts' } +}) + app.use(express.json()) app.use((req, res, next) => { res.header('Access-Control-Allow-Origin', '*') @@ -13,6 +30,9 @@ app.use((req, res, next) => { next() }) +app.use('/api/v1/auth/login', userLoginLimiter) +app.use('/api/v1/admin/login', adminLoginLimiter) + app.use(authMiddleware) app.get('/health', (req, res) => res.json(ok({ status: 'ok' }))) diff --git a/server/src/config.js b/server/src/config.js index a88a9db..1e5e0c3 100644 --- a/server/src/config.js +++ b/server/src/config.js @@ -35,6 +35,7 @@ const config = { if (config.nodeEnv === 'production') { if (config.jwt.secret === 'dev-user-secret') throw new Error('JWT_SECRET must be set in production') if (config.jwt.adminSecret === 'dev-admin-secret') throw new Error('ADMIN_JWT_SECRET must be set in production') + if (config.admin.username === 'admin' || config.admin.password === 'admin') throw new Error('ADMIN_USERNAME and ADMIN_PASSWORD must be changed from defaults in production') } module.exports = config diff --git a/server/src/dao/binding.dao.js b/server/src/dao/binding.dao.js index d3c5ad9..edf6827 100644 --- a/server/src/dao/binding.dao.js +++ b/server/src/dao/binding.dao.js @@ -42,6 +42,18 @@ async function findDeviceExists(deviceId, conn) { ) } +/** + * Cancel any existing pending bindings for a user (set bind_status=4) + * @param {number} userId + * @returns {Promise} query result + */ +async function cancelPending(userId) { + return query( + 'UPDATE bindings SET bind_status = 4 WHERE user_id = :user_id AND bind_status = 3', + { user_id: userId } + ) +} + /** * Create a pending binding request with 10-minute expiry * @param {number} userId @@ -193,6 +205,7 @@ async function countActiveByUser(userId) { module.exports = { findActiveByUser, findDeviceExists, + cancelPending, createPending, confirmBind, mockBind, diff --git a/server/src/dao/subscription.dao.js b/server/src/dao/subscription.dao.js index 45e2caf..5868b13 100644 --- a/server/src/dao/subscription.dao.js +++ b/server/src/dao/subscription.dao.js @@ -7,7 +7,7 @@ const { query, one, transaction, limitClause } = require('../lib/db') */ async function findActive(userId) { return one( - 'SELECT *, GREATEST(DATEDIFF(expire_time, NOW()), 0) AS remaining_days FROM subscriptions WHERE user_id = :user_id AND status = 1 ORDER BY expire_time DESC LIMIT 1', + 'SELECT *, GREATEST(DATEDIFF(expire_time, NOW()), 0) AS remaining_days FROM subscriptions WHERE user_id = :user_id AND status = 1 AND expire_time > NOW() ORDER BY expire_time DESC LIMIT 1', { user_id: userId } ) } @@ -49,14 +49,16 @@ async function findAnyActive(userId) { } /** - * Create a trial subscription (7 days, amount=0) + * Create a trial subscription (configurable days, amount=0) * @param {number} userId * @param {string} [orderId] - optional order ID + * @param {number} [days] - trial duration in days (default 7) * @returns {Promise} query result */ -async function createTrial(userId, orderId) { +async function createTrial(userId, orderId, days) { + const trialDays = days || 7 return query( - "INSERT INTO subscriptions (user_id, plan, status, amount, order_id, start_time, expire_time) VALUES (:user_id, 'trial', 1, 0, :order_id, NOW(), DATE_ADD(NOW(), INTERVAL 7 DAY))", + "INSERT INTO subscriptions (user_id, plan, status, amount, order_id, start_time, expire_time) VALUES (:user_id, 'trial', 1, 0, :order_id, NOW(), DATE_ADD(NOW(), INTERVAL " + Number(trialDays) + " DAY))", { user_id: userId, order_id: orderId || 'TRIAL' + Date.now() } ) } @@ -99,7 +101,7 @@ async function purchase(userId, plan, amount, orderId, days) { } /** - * Admin-create a subscription: extend existing or create new + * Admin-create a subscription: extend existing or create new (transactional) * If user has an active subscription, add days to current expire_time. * If no active subscription, insert a new one starting NOW(). * @param {number} userId @@ -110,27 +112,29 @@ async function purchase(userId, plan, amount, orderId, days) { * @returns {Promise} */ async function adminCreate(userId, plan, amount, orderId, days) { - const active = await one( - 'SELECT subscription_id FROM subscriptions WHERE user_id = :user_id AND status = 1 AND expire_time > NOW() LIMIT 1', - { user_id: userId } - ) - if (active) { - // Extend existing active subscription - await query( - 'UPDATE subscriptions SET expire_time = DATE_ADD(expire_time, INTERVAL :days DAY), plan = :plan, amount = amount + :amount WHERE subscription_id = :sid', - { days, plan, amount, sid: active.subscription_id } + return transaction(async conn => { + const [rows] = await conn.execute( + 'SELECT subscription_id FROM subscriptions WHERE user_id = ? AND status = 1 AND expire_time > NOW() LIMIT 1', + [userId] ) - } else { - // Expire any stale rows, then insert new - await query( - 'UPDATE subscriptions SET status = 2 WHERE user_id = :user_id AND status = 1', - { user_id: userId } - ) - await query( - 'INSERT INTO subscriptions (user_id, plan, status, amount, order_id, start_time, expire_time) VALUES (:user_id, :plan, 1, :amount, :order_id, NOW(), DATE_ADD(NOW(), INTERVAL :days DAY))', - { user_id: userId, plan, amount, order_id: orderId, days } - ) - } + if (rows.length > 0) { + // Extend existing active subscription + await conn.execute( + 'UPDATE subscriptions SET expire_time = DATE_ADD(expire_time, INTERVAL ? DAY), plan = ?, amount = amount + ? WHERE subscription_id = ?', + [days, plan, amount, rows[0].subscription_id] + ) + } else { + // Expire any stale rows, then insert new + await conn.execute( + 'UPDATE subscriptions SET status = 2 WHERE user_id = ? AND status = 1', + [userId] + ) + await conn.execute( + 'INSERT INTO subscriptions (user_id, plan, status, amount, order_id, start_time, expire_time) VALUES (?, ?, 1, ?, ?, NOW(), DATE_ADD(NOW(), INTERVAL ? DAY))', + [userId, plan, amount, orderId, days] + ) + } + }) } /** diff --git a/server/src/lib/settings-cache.js b/server/src/lib/settings-cache.js index 5c92242..13f577b 100644 --- a/server/src/lib/settings-cache.js +++ b/server/src/lib/settings-cache.js @@ -11,4 +11,9 @@ async function getSettings() { return cache } -module.exports = { getSettings } +function invalidateCache() { + cache = null + cacheTime = 0 +} + +module.exports = { getSettings, invalidateCache } diff --git a/server/src/routes/admin.js b/server/src/routes/admin.js index a2ea4c4..3fdc26b 100644 --- a/server/src/routes/admin.js +++ b/server/src/routes/admin.js @@ -2,6 +2,7 @@ const router = require('express').Router() const { ok, fail } = require('../lib/response') const { hashPassword, hashPasswordLegacy, verifyPassword, signAdmin } = require('../lib/auth') const { requireAdmin } = require('../middleware/auth') +const { invalidateCache } = require('../lib/settings-cache') const adminDao = require('../dao/admin.dao') const deviceDao = require('../dao/device.dao') const bindingDao = require('../dao/binding.dao') @@ -229,6 +230,7 @@ router.post('/settings', requireAdmin, wrap(async (req, res) => { if (!ALLOWED_KEYS.includes(key)) continue await settingsDao.update(key, req.body[key]) } + invalidateCache() res.json(ok({ message: 'success' })) })) diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index c8492d9..5c7d532 100644 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -52,7 +52,7 @@ router.post('/auth/refresh', wrap(async (req, res) => { return res.json(fail(1001, 'token_expired')) } const now = Math.floor(Date.now() / 1000) - const gracePeriod = 3 * 24 * 60 * 60 + const gracePeriod = 1 * 24 * 60 * 60 // 1 day if (now - payload.exp > gracePeriod) { return res.json(fail(1001, 'token_expired')) } diff --git a/server/src/routes/device.js b/server/src/routes/device.js index ded3973..38a830e 100644 --- a/server/src/routes/device.js +++ b/server/src/routes/device.js @@ -25,6 +25,9 @@ router.post('/device/bind', requireUser, wrap(async (req, res) => { const device = await bindingDao.findDeviceExists(deviceId) if (!device) return res.json(fail(1005, 'DEVICE_NOT_FOUND')) + // Cancel any stale pending bindings for this user before creating a new one + await bindingDao.cancelPending(req.user.user_id) + const bindToken = randomHex(8) await bindingDao.createPending(req.user.user_id, deviceId, bindToken) await logDao.write({ user_id: req.user.user_id, action: 'device_bind_request', detail: '申请绑定设备: ' + deviceId, ip: req.ip }) @@ -108,7 +111,7 @@ router.post('/device/command/result', requireUser, wrap(async (req, res) => { if (!commandId) return res.json(fail(2001, 'command_id required')) const cmd = await commandDao.findByIdForUser(commandId, req.user.user_id) if (!cmd) return res.json(fail(1006, 'device_not_bound')) - await commandDao.finish(commandId, success, JSON.stringify(req.body)) + await commandDao.finish(commandId, success, req.body) res.json(ok({ message: 'success' })) })) diff --git a/server/src/routes/firmware.js b/server/src/routes/firmware.js index 67be653..479861c 100644 --- a/server/src/routes/firmware.js +++ b/server/src/routes/firmware.js @@ -8,6 +8,12 @@ const logDao = require('../dao/log.dao') const wrap = fn => (req, res, next) => fn(req, res, next).catch(next) +// NOTE: Admin firmware routes use /admin/firmware paths but are mounted at /api/v1 +// (not under the /api/v1/admin router). This is intentional — firmware management is +// grouped in a single file alongside the user-facing /firmware/latest endpoint for +// cohesion, rather than splitting across the admin router and a separate user router. +// The requireAdmin middleware still protects these routes. + router.get('/admin/firmware', requireAdmin, wrap(async (req, res) => { const rows = await firmwareDao.list() res.json(ok({ records: rows, total: rows.length })) diff --git a/server/src/routes/subscription.js b/server/src/routes/subscription.js index 2951bc7..03a1ed7 100644 --- a/server/src/routes/subscription.js +++ b/server/src/routes/subscription.js @@ -2,6 +2,7 @@ const router = require('express').Router() const { ok, fail } = require('../lib/response') const { requireUser } = require('../middleware/auth') const { requireAdmin } = require('../middleware/auth') +const { getSettings } = require('../lib/settings-cache') const subscriptionDao = require('../dao/subscription.dao') const logDao = require('../dao/log.dao') @@ -64,8 +65,10 @@ router.post('/subscription/trial', requireUser, wrap(async (req, res) => { if (usedTrial) return res.json(fail(2001, '已使用过试用')) const activeSub = await subscriptionDao.findActive(req.user.user_id) if (activeSub) return res.json(fail(2001, '已有有效订阅')) - await subscriptionDao.createTrial(req.user.user_id) - res.json(ok({ status: 'active', plan: 'trial', remaining_days: 7 })) + const settings = await getSettings() + const trialDays = Number(settings.trial_days) || 7 + await subscriptionDao.createTrial(req.user.user_id, undefined, trialDays) + res.json(ok({ status: 'active', plan: 'trial', remaining_days: trialDays })) })) // Temporary: admin-only until payment integration