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
这个提交包含在:
Guoguo
2026-05-05 02:33:25 -07:00
父节点 031678c03f
当前提交 2eb38195f1
修改 17 个文件,包含 147 行新增45 行删除
@@ -113,6 +113,7 @@ import { get } from '../utils/request'
import { formatDate as formatDateUtil } from '../utils/format'
export default {
name: 'DashboardView',
data() {
return {
stats: {},
+1
查看文件
@@ -129,6 +129,7 @@
import { get, post } from '../utils/request'
export default {
name: 'SettingsView',
data() {
return {
settings: {
@@ -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 + '日' })
})
},
+2 -2
查看文件
@@ -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)
}
+3 -1
查看文件
@@ -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({})
}
+29 -2
查看文件
@@ -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",
+1
查看文件
@@ -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"
}
+20
查看文件
@@ -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' })))
+1
查看文件
@@ -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
+13
查看文件
@@ -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<Array>} 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,
+29 -25
查看文件
@@ -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<Array>} 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<void>}
*/
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]
)
}
})
}
/**
+6 -1
查看文件
@@ -11,4 +11,9 @@ async function getSettings() {
return cache
}
module.exports = { getSettings }
function invalidateCache() {
cache = null
cacheTime = 0
}
module.exports = { getSettings, invalidateCache }
+2
查看文件
@@ -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' }))
}))
+1 -1
查看文件
@@ -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'))
}
+4 -1
查看文件
@@ -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' }))
}))
+6
查看文件
@@ -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 }))
+5 -2
查看文件
@@ -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