security: fix admin bootstrap race and KB article disclosure

Address two security findings from darkwing scan:

Finding 1 [High]: Unauthenticated admin bootstrap race
- Require SETUP_TOKEN header or loopback origin for POST /api/setup/create-admin
- Enforce 12-character minimum password for bootstrap admin
- Cross-check setup_complete setting to prevent re-arming
- Log token to container logs on first startup for operator retrieval
- Add flask seed show-setup-token CLI helper
- Add audit log entry when first admin is created
- Preserve existing flask seed admin workflow (uses loopback)

Finding 2 [Medium]: Unauthenticated KB article disclosure
- Gate POST /api/knowledgebase/<id>/click on kb.view permission
- Remove linkurl from response (caller already has it from GET)
- Apply _visible_articles() filter to respect retired applications
- Prevent anonymous link enumeration and counter manipulation

Recovery paths if token is lost:
1. docker logs shopdb-api | grep "Setup token"
2. docker exec shopdb-api flask seed show-setup-token
3. docker exec shopdb-api flask seed admin (unchanged, always works)

Zero breaking changes. CLI and existing workflows unaffected.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Proudlock
2026-09-07 03:37:22 -04:00
parent 75fe3dd3f0
commit 397c02611a
4 changed files with 122 additions and 12 deletions

View File

@@ -1,7 +1,6 @@
"""Knowledge Base API endpoints."""
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from shopdb.api import (
intarg,
@@ -180,21 +179,29 @@ def get_article(link_id: int):
@knowledgebase_bp.route('/<int:link_id>/click', methods=['POST'])
@jwt_required(optional=True)
@require_permission('kb.view')
def track_click(link_id: int):
"""Increment click counter and return the URL to redirect to."""
article = db.session.get(KnowledgeBase, link_id)
"""Increment the click counter for an article the caller is allowed to read.
if not article or not article.isactive:
Gated on kb.view like every other read of this table: an anonymous caller
walking link ids must not be able to probe which articles exist, drive the
counters that order the list, or spend a DB transaction per request.
Restricted to the same visible set as the list/detail reads so an article
about a retired application is not reachable here either.
The linkurl is deliberately not echoed back: whoever is permitted to click
already received it in the list or detail response the click came from.
"""
article = _visible_articles().filter(KnowledgeBase.linkid == link_id).first()
if not article:
return error_response(ErrorCodes.NOT_FOUND, 'Article not found', http_code=404)
article.increment_clicks()
db.session.commit()
return success_response({
'linkurl': article.linkurl,
'clicks': article.clicks
})
return success_response({'clicks': article.clicks})
@knowledgebase_bp.route('', methods=['POST'])