fix(together): close JWT legacy-fallback impersonation hole
When JWT is present but strict member check fails, verify the body username belongs to the JWT user before allowing legacy access. JWT-less clients (no Authorization header) are unaffected. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c1173e5625
commit
96f22b6a82
180
server.js
180
server.js
|
|
@ -715,6 +715,13 @@ async function initDB() {
|
||||||
`ALTER TABLE users ADD COLUMN IF NOT EXISTS plan VARCHAR(20) NOT NULL DEFAULT 'free'`,
|
`ALTER TABLE users ADD COLUMN IF NOT EXISTS plan VARCHAR(20) NOT NULL DEFAULT 'free'`,
|
||||||
`ALTER TABLE users ADD COLUMN IF NOT EXISTS stripe_customer_id TEXT`,
|
`ALTER TABLE users ADD COLUMN IF NOT EXISTS stripe_customer_id TEXT`,
|
||||||
`ALTER TABLE users ADD COLUMN IF NOT EXISTS stripe_subscription_id TEXT`,
|
`ALTER TABLE users ADD COLUMN IF NOT EXISTS stripe_subscription_id TEXT`,
|
||||||
|
|
||||||
|
// Together: メンバーと Posimai ログインユーザーの紐付け(JWT 強化用・既存 PK は維持)
|
||||||
|
`ALTER TABLE together_members ADD COLUMN IF NOT EXISTS user_id VARCHAR(50)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_together_members_user_id ON together_members(user_id) WHERE user_id IS NOT NULL`,
|
||||||
|
// username = users.user_id または users.name と一致する行のみ自動紐付け(冪等)
|
||||||
|
`UPDATE together_members tm SET user_id = u.user_id FROM users u
|
||||||
|
WHERE tm.user_id IS NULL AND (tm.username = u.user_id OR tm.username = u.name)`,
|
||||||
];
|
];
|
||||||
for (const sql of migrations) {
|
for (const sql of migrations) {
|
||||||
await pool.query(sql).catch(e => console.warn('[DB] Migration warning:', e.message));
|
await pool.query(sql).catch(e => console.warn('[DB] Migration warning:', e.message));
|
||||||
|
|
@ -730,6 +737,117 @@ async function initDB() {
|
||||||
console.log('[DB] Schema ready. Users:', Object.values(KEY_MAP).join(', '));
|
console.log('[DB] Schema ready. Users:', Object.values(KEY_MAP).join(', '));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Together: 任意 JWT(Authorization のみ。GET の query key は使わない)────────
|
||||||
|
function getOptionalJwtUserIdFromHeader(req) {
|
||||||
|
const auth = req.headers.authorization || '';
|
||||||
|
if (!auth.toLowerCase().startsWith('bearer ')) return null;
|
||||||
|
const token = auth.substring(7).trim();
|
||||||
|
if (!token || token.startsWith('pk_') || !token.includes('.')) return null;
|
||||||
|
try {
|
||||||
|
const payload = jwt.verify(token, JWT_SECRET);
|
||||||
|
return payload.userId || null;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTogetherJwtUserId(req) {
|
||||||
|
if (process.env.TOGETHER_DISABLE_JWT === '1' || /^true$/i.test(process.env.TOGETHER_DISABLE_JWT || '')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return getOptionalJwtUserIdFromHeader(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Together メンバー確認。JWT なしのときは従来どおり username のみ(完全互換)。
|
||||||
|
* JWT ありのときは user_id 一致、または未紐付けメンバーで username が JWT ユーザーの user_id/name と一致する場合は厳格一致。
|
||||||
|
* それ以外の未紐付けニックネームは従来クエリで許可し warn ログ(運用互換)。
|
||||||
|
*/
|
||||||
|
async function togetherEnsureMember(pool, res, groupId, username, jwtUserId) {
|
||||||
|
if (!username || typeof username !== 'string') {
|
||||||
|
res.status(400).json({ error: 'username が不正です' });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const gidNum = parseInt(String(groupId), 10);
|
||||||
|
if (Number.isNaN(gidNum)) {
|
||||||
|
res.status(400).json({ error: 'invalid groupId' });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (jwtUserId) {
|
||||||
|
const strict = await pool.query(
|
||||||
|
`SELECT 1 FROM together_members m
|
||||||
|
WHERE m.group_id = $1 AND (
|
||||||
|
m.user_id = $2
|
||||||
|
OR (
|
||||||
|
(m.user_id IS NULL OR btrim(COALESCE(m.user_id, '')) = '')
|
||||||
|
AND m.username = $3
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM users u
|
||||||
|
WHERE u.user_id = $2 AND (u.user_id = $3 OR u.name = $3)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)`,
|
||||||
|
[gidNum, jwtUserId, username]
|
||||||
|
);
|
||||||
|
if (strict.rows.length > 0) return true;
|
||||||
|
|
||||||
|
const legacy = await pool.query(
|
||||||
|
'SELECT 1 FROM together_members WHERE group_id=$1 AND username=$2',
|
||||||
|
[gidNum, username]
|
||||||
|
);
|
||||||
|
if (legacy.rows.length > 0) {
|
||||||
|
// JWT ユーザーが送信した username の本人であることを確認(なりすまし防止)
|
||||||
|
const userCheck = await pool.query(
|
||||||
|
'SELECT 1 FROM users WHERE user_id=$1 AND (user_id=$2 OR name=$2)',
|
||||||
|
[jwtUserId, username]
|
||||||
|
);
|
||||||
|
if (userCheck.rows.length === 0) {
|
||||||
|
res.status(403).json({ error: 'グループのメンバーではありません' });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
console.warn('[Together] legacy path used user=%s username=%s group=%s', jwtUserId, username, gidNum);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
res.status(403).json({ error: 'グループのメンバーではありません' });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const legacyOnly = await pool.query(
|
||||||
|
'SELECT 1 FROM together_members WHERE group_id=$1 AND username=$2',
|
||||||
|
[gidNum, username]
|
||||||
|
);
|
||||||
|
if (legacyOnly.rows.length === 0) {
|
||||||
|
res.status(403).json({ error: 'グループのメンバーではありません' });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Together] togetherEnsureMember', e.message);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** share_id 経由で group を解決し、username がそのグループのメンバーか(JWT 強化込み) */
|
||||||
|
async function togetherEnsureMemberForShare(pool, res, shareId, username, jwtUserId) {
|
||||||
|
if (!username || typeof username !== 'string') {
|
||||||
|
res.status(400).json({ error: 'username が不正です' });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const g = await pool.query('SELECT group_id FROM together_shares WHERE id=$1', [shareId]);
|
||||||
|
if (g.rows.length === 0) {
|
||||||
|
res.status(404).json({ error: '見つかりません' });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return togetherEnsureMember(pool, res, g.rows[0].group_id, username, jwtUserId);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Together] togetherEnsureMemberForShare', e.message);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── ルーター ──────────────────────────────
|
// ── ルーター ──────────────────────────────
|
||||||
function buildRouter() {
|
function buildRouter() {
|
||||||
const r = express.Router();
|
const r = express.Router();
|
||||||
|
|
@ -2485,12 +2603,9 @@ ${excerpt}
|
||||||
r.get('/together/members/:groupId', async (req, res) => {
|
r.get('/together/members/:groupId', async (req, res) => {
|
||||||
const username = req.query.u;
|
const username = req.query.u;
|
||||||
if (!username) return res.status(400).json({ error: 'u (username) は必須です' });
|
if (!username) return res.status(400).json({ error: 'u (username) は必須です' });
|
||||||
|
const jwtUserId = getTogetherJwtUserId(req);
|
||||||
try {
|
try {
|
||||||
const memberCheck = await pool.query(
|
if (!(await togetherEnsureMember(pool, res, req.params.groupId, username, jwtUserId))) return;
|
||||||
'SELECT 1 FROM together_members WHERE group_id=$1 AND username=$2',
|
|
||||||
[req.params.groupId, username]
|
|
||||||
);
|
|
||||||
if (memberCheck.rows.length === 0) return res.status(403).json({ error: 'グループのメンバーではありません' });
|
|
||||||
|
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
'SELECT username, joined_at FROM together_members WHERE group_id=$1 ORDER BY joined_at',
|
'SELECT username, joined_at FROM together_members WHERE group_id=$1 ORDER BY joined_at',
|
||||||
|
|
@ -2509,16 +2624,12 @@ ${excerpt}
|
||||||
if (url) {
|
if (url) {
|
||||||
if (!isSsrfSafe(url)) return res.status(400).json({ error: 'url は http/https のみ有効です' });
|
if (!isSsrfSafe(url)) return res.status(400).json({ error: 'url は http/https のみ有効です' });
|
||||||
}
|
}
|
||||||
|
const jwtUserId = getTogetherJwtUserId(req);
|
||||||
try {
|
try {
|
||||||
const grpCheck = await pool.query('SELECT id FROM together_groups WHERE id=$1', [group_id]);
|
const grpCheck = await pool.query('SELECT id FROM together_groups WHERE id=$1', [group_id]);
|
||||||
if (grpCheck.rows.length === 0) return res.status(404).json({ error: 'グループが見つかりません' });
|
if (grpCheck.rows.length === 0) return res.status(404).json({ error: 'グループが見つかりません' });
|
||||||
|
|
||||||
// グループメンバーであることを確認(非メンバーの投稿を防止)
|
if (!(await togetherEnsureMember(pool, res, group_id, shared_by, jwtUserId))) return;
|
||||||
const memberCheck = await pool.query(
|
|
||||||
'SELECT 1 FROM together_members WHERE group_id=$1 AND username=$2',
|
|
||||||
[group_id, shared_by]
|
|
||||||
);
|
|
||||||
if (memberCheck.rows.length === 0) return res.status(403).json({ error: 'グループのメンバーではありません' });
|
|
||||||
|
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`INSERT INTO together_shares (group_id, shared_by, url, title, message, tags)
|
`INSERT INTO together_shares (group_id, shared_by, url, title, message, tags)
|
||||||
|
|
@ -2540,7 +2651,10 @@ ${excerpt}
|
||||||
r.delete('/together/share/:id', async (req, res) => {
|
r.delete('/together/share/:id', async (req, res) => {
|
||||||
const { username } = req.body || {};
|
const { username } = req.body || {};
|
||||||
if (!username) return res.status(400).json({ error: 'username は必須です' });
|
if (!username) return res.status(400).json({ error: 'username は必須です' });
|
||||||
|
const jwtUserId = getTogetherJwtUserId(req);
|
||||||
try {
|
try {
|
||||||
|
if (!(await togetherEnsureMemberForShare(pool, res, req.params.id, username, jwtUserId))) return;
|
||||||
|
|
||||||
// shared_by が一致する行のみ削除(なければ 403)
|
// shared_by が一致する行のみ削除(なければ 403)
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
'DELETE FROM together_shares WHERE id=$1 AND shared_by=$2 RETURNING id',
|
'DELETE FROM together_shares WHERE id=$1 AND shared_by=$2 RETURNING id',
|
||||||
|
|
@ -2559,12 +2673,9 @@ ${excerpt}
|
||||||
r.get('/together/feed/:groupId', async (req, res) => {
|
r.get('/together/feed/:groupId', async (req, res) => {
|
||||||
const username = req.query.u;
|
const username = req.query.u;
|
||||||
if (!username) return res.status(400).json({ error: 'u (username) は必須です' });
|
if (!username) return res.status(400).json({ error: 'u (username) は必須です' });
|
||||||
|
const jwtUserId = getTogetherJwtUserId(req);
|
||||||
try {
|
try {
|
||||||
const memberCheck = await pool.query(
|
if (!(await togetherEnsureMember(pool, res, req.params.groupId, username, jwtUserId))) return;
|
||||||
'SELECT 1 FROM together_members WHERE group_id=$1 AND username=$2',
|
|
||||||
[req.params.groupId, username]
|
|
||||||
);
|
|
||||||
if (memberCheck.rows.length === 0) return res.status(403).json({ error: 'グループのメンバーではありません' });
|
|
||||||
|
|
||||||
const limit = Math.min(parseInt(req.query.limit) || 20, 50);
|
const limit = Math.min(parseInt(req.query.limit) || 20, 50);
|
||||||
const cursor = req.query.cursor;
|
const cursor = req.query.cursor;
|
||||||
|
|
@ -2603,12 +2714,9 @@ ${excerpt}
|
||||||
r.get('/together/article/:shareId', async (req, res) => {
|
r.get('/together/article/:shareId', async (req, res) => {
|
||||||
const username = req.query.u;
|
const username = req.query.u;
|
||||||
if (!username) return res.status(400).json({ error: 'u (username) は必須です' });
|
if (!username) return res.status(400).json({ error: 'u (username) は必須です' });
|
||||||
|
const jwtUserId = getTogetherJwtUserId(req);
|
||||||
try {
|
try {
|
||||||
const memberCheck = await pool.query(
|
if (!(await togetherEnsureMemberForShare(pool, res, req.params.shareId, username, jwtUserId))) return;
|
||||||
'SELECT 1 FROM together_members m JOIN together_shares s ON s.group_id=m.group_id WHERE s.id=$1 AND m.username=$2',
|
|
||||||
[req.params.shareId, username]
|
|
||||||
);
|
|
||||||
if (memberCheck.rows.length === 0) return res.status(403).json({ error: 'グループのメンバーではありません' });
|
|
||||||
|
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
'SELECT id, title, url, full_content, summary, archive_status, shared_at FROM together_shares WHERE id=$1',
|
'SELECT id, title, url, full_content, summary, archive_status, shared_at FROM together_shares WHERE id=$1',
|
||||||
|
|
@ -2626,13 +2734,9 @@ ${excerpt}
|
||||||
const { share_id, username, type = 'like' } = req.body || {};
|
const { share_id, username, type = 'like' } = req.body || {};
|
||||||
if (!share_id || !username) return res.status(400).json({ error: 'share_id と username は必須です' });
|
if (!share_id || !username) return res.status(400).json({ error: 'share_id と username は必須です' });
|
||||||
if (!['like', 'star', 'fire', 'read'].includes(type)) return res.status(400).json({ error: 'type は like/star/fire/read のみ有効です' });
|
if (!['like', 'star', 'fire', 'read'].includes(type)) return res.status(400).json({ error: 'type は like/star/fire/read のみ有効です' });
|
||||||
|
const jwtUserId = getTogetherJwtUserId(req);
|
||||||
try {
|
try {
|
||||||
// share の group に対してメンバーであることを確認
|
if (!(await togetherEnsureMemberForShare(pool, res, share_id, username, jwtUserId))) return;
|
||||||
const memberCheck = await pool.query(
|
|
||||||
'SELECT 1 FROM together_members m JOIN together_shares s ON s.group_id=m.group_id WHERE s.id=$1 AND m.username=$2',
|
|
||||||
[share_id, username]
|
|
||||||
);
|
|
||||||
if (memberCheck.rows.length === 0) return res.status(403).json({ error: 'グループのメンバーではありません' });
|
|
||||||
|
|
||||||
const existing = await pool.query(
|
const existing = await pool.query(
|
||||||
'SELECT 1 FROM together_reactions WHERE share_id=$1 AND username=$2 AND type=$3',
|
'SELECT 1 FROM together_reactions WHERE share_id=$1 AND username=$2 AND type=$3',
|
||||||
|
|
@ -2661,12 +2765,9 @@ ${excerpt}
|
||||||
r.get('/together/comments/:shareId', async (req, res) => {
|
r.get('/together/comments/:shareId', async (req, res) => {
|
||||||
const username = req.query.u;
|
const username = req.query.u;
|
||||||
if (!username) return res.status(400).json({ error: 'u (username) は必須です' });
|
if (!username) return res.status(400).json({ error: 'u (username) は必須です' });
|
||||||
|
const jwtUserId = getTogetherJwtUserId(req);
|
||||||
try {
|
try {
|
||||||
const memberCheck = await pool.query(
|
if (!(await togetherEnsureMemberForShare(pool, res, req.params.shareId, username, jwtUserId))) return;
|
||||||
'SELECT 1 FROM together_members m JOIN together_shares s ON s.group_id=m.group_id WHERE s.id=$1 AND m.username=$2',
|
|
||||||
[req.params.shareId, username]
|
|
||||||
);
|
|
||||||
if (memberCheck.rows.length === 0) return res.status(403).json({ error: 'グループのメンバーではありません' });
|
|
||||||
|
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
'SELECT * FROM together_comments WHERE share_id=$1 ORDER BY created_at',
|
'SELECT * FROM together_comments WHERE share_id=$1 ORDER BY created_at',
|
||||||
|
|
@ -2684,13 +2785,9 @@ ${excerpt}
|
||||||
if (!share_id || !username || !body?.trim()) {
|
if (!share_id || !username || !body?.trim()) {
|
||||||
return res.status(400).json({ error: 'share_id, username, body は必須です' });
|
return res.status(400).json({ error: 'share_id, username, body は必須です' });
|
||||||
}
|
}
|
||||||
|
const jwtUserId = getTogetherJwtUserId(req);
|
||||||
try {
|
try {
|
||||||
// share の group に対してメンバーであることを確認
|
if (!(await togetherEnsureMemberForShare(pool, res, share_id, username, jwtUserId))) return;
|
||||||
const memberCheck = await pool.query(
|
|
||||||
'SELECT 1 FROM together_members m JOIN together_shares s ON s.group_id=m.group_id WHERE s.id=$1 AND m.username=$2',
|
|
||||||
[share_id, username]
|
|
||||||
);
|
|
||||||
if (memberCheck.rows.length === 0) return res.status(403).json({ error: 'グループのメンバーではありません' });
|
|
||||||
|
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
'INSERT INTO together_comments (share_id, username, body) VALUES ($1, $2, $3) RETURNING *',
|
'INSERT INTO together_comments (share_id, username, body) VALUES ($1, $2, $3) RETURNING *',
|
||||||
|
|
@ -2707,11 +2804,8 @@ ${excerpt}
|
||||||
r.get('/together/search/:groupId', async (req, res) => {
|
r.get('/together/search/:groupId', async (req, res) => {
|
||||||
const { q = '', tag = '', u: username = '' } = req.query;
|
const { q = '', tag = '', u: username = '' } = req.query;
|
||||||
if (!username) return res.status(400).json({ error: 'u (username) は必須です' });
|
if (!username) return res.status(400).json({ error: 'u (username) は必須です' });
|
||||||
const memberOk = await pool.query(
|
const jwtUserId = getTogetherJwtUserId(req);
|
||||||
'SELECT 1 FROM together_members WHERE group_id=$1 AND username=$2',
|
if (!(await togetherEnsureMember(pool, res, req.params.groupId, username, jwtUserId))) return;
|
||||||
[req.params.groupId, username]
|
|
||||||
).then(r => r.rows.length > 0).catch(() => false);
|
|
||||||
if (!memberOk) return res.status(403).json({ error: 'グループのメンバーではありません' });
|
|
||||||
if (!q && !tag) return res.status(400).json({ error: 'q または tag が必要です' });
|
if (!q && !tag) return res.status(400).json({ error: 'q または tag が必要です' });
|
||||||
try {
|
try {
|
||||||
const keyword = q ? `%${q}%` : '';
|
const keyword = q ? `%${q}%` : '';
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue