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
这个提交包含在:
@@ -113,6 +113,7 @@ import { get } from '../utils/request'
|
||||
import { formatDate as formatDateUtil } from '../utils/format'
|
||||
|
||||
export default {
|
||||
name: 'DashboardView',
|
||||
data() {
|
||||
return {
|
||||
stats: {},
|
||||
|
||||
@@ -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] || '会员' })
|
||||
|
||||
// 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)
|
||||
this.setData({
|
||||
planName: planMap[plan] || '会员',
|
||||
expiryDate: y + '年' + m + '月' + d + '日'
|
||||
self.setData({ expiryDate: y + '年' + m + '月' + d + '日' })
|
||||
})
|
||||
},
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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' })))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
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]
|
||||
)
|
||||
if (active) {
|
||||
if (rows.length > 0) {
|
||||
// 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 }
|
||||
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 query(
|
||||
'UPDATE subscriptions SET status = 2 WHERE user_id = :user_id AND status = 1',
|
||||
{ user_id: userId }
|
||||
await conn.execute(
|
||||
'UPDATE subscriptions SET status = 2 WHERE user_id = ? AND status = 1',
|
||||
[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 }
|
||||
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]
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,4 +11,9 @@ async function getSettings() {
|
||||
return cache
|
||||
}
|
||||
|
||||
module.exports = { getSettings }
|
||||
function invalidateCache() {
|
||||
cache = null
|
||||
cacheTime = 0
|
||||
}
|
||||
|
||||
module.exports = { getSettings, invalidateCache }
|
||||
|
||||
@@ -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' }))
|
||||
}))
|
||||
|
||||
|
||||
@@ -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'))
|
||||
}
|
||||
|
||||
@@ -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' }))
|
||||
}))
|
||||
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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
|
||||
|
||||
在新工单中引用
屏蔽一个用户