kb topic search + client-ip port strip + application notes html
This commit is contained in:
@@ -20,6 +20,7 @@
|
|||||||
"@fullcalendar/daygrid": "^6.1.20",
|
"@fullcalendar/daygrid": "^6.1.20",
|
||||||
"@fullcalendar/vue3": "^6.1.20",
|
"@fullcalendar/vue3": "^6.1.20",
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
|
"dompurify": "^3.4.11",
|
||||||
"jsbarcode": "^3.12.3",
|
"jsbarcode": "^3.12.3",
|
||||||
"jspdf": "^4.2.1",
|
"jspdf": "^4.2.1",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
|
|||||||
31
frontend/src/utils/sanitizeHtml.js
Normal file
31
frontend/src/utils/sanitizeHtml.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// Safe rendering of user-authored notes HTML (e.g. Application Notes, which the
|
||||||
|
// form advertises as "HTML supported"). DOMPurify strips scripts, event
|
||||||
|
// handlers, and any active/unsafe content; we allow only basic formatting +
|
||||||
|
// links. Never v-html raw notes without this.
|
||||||
|
import DOMPurify from 'dompurify'
|
||||||
|
|
||||||
|
// Force every surviving link to open safely: new tab + no window.opener handle.
|
||||||
|
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||||||
|
if (node.tagName === 'A' && node.getAttribute('href')) {
|
||||||
|
node.setAttribute('target', '_blank')
|
||||||
|
node.setAttribute('rel', 'noopener noreferrer')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const ALLOWED_TAGS = [
|
||||||
|
'p', 'br', 'hr', 'b', 'strong', 'i', 'em', 'u', 's', 'span', 'div',
|
||||||
|
'a', 'ul', 'ol', 'li', 'blockquote', 'code', 'pre',
|
||||||
|
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||||
|
'table', 'thead', 'tbody', 'tr', 'th', 'td',
|
||||||
|
]
|
||||||
|
const ALLOWED_ATTR = ['href', 'title', 'target', 'rel']
|
||||||
|
|
||||||
|
// Return a sanitized HTML string safe to bind with v-html. Empty in -> empty out.
|
||||||
|
export function sanitizeNotesHtml(html) {
|
||||||
|
if (!html) return ''
|
||||||
|
return DOMPurify.sanitize(String(html), {
|
||||||
|
ALLOWED_TAGS,
|
||||||
|
ALLOWED_ATTR,
|
||||||
|
ALLOW_DATA_ATTR: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -79,7 +79,9 @@
|
|||||||
<!-- Application Notes -->
|
<!-- Application Notes -->
|
||||||
<div class="section-card" v-if="app.applicationnotes">
|
<div class="section-card" v-if="app.applicationnotes">
|
||||||
<h3 class="section-title">Application Notes</h3>
|
<h3 class="section-title">Application Notes</h3>
|
||||||
<div class="notes-text">{{ app.applicationnotes }}</div>
|
<!-- Notes are authored as HTML (form says "HTML supported");
|
||||||
|
render sanitized so scripts/handlers cannot slip in. -->
|
||||||
|
<div class="notes-text" v-html="sanitizedNotes"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Versions -->
|
<!-- Versions -->
|
||||||
@@ -160,10 +162,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { applicationsApi } from '@/api'
|
import { applicationsApi } from '@/api'
|
||||||
import { getContactEmailDomain } from '@/utils/siteSettings'
|
import { getContactEmailDomain } from '@/utils/siteSettings'
|
||||||
|
import { sanitizeNotesHtml } from '@/utils/sanitizeHtml'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
@@ -173,6 +176,9 @@ const versions = ref([])
|
|||||||
const installedOn = ref([])
|
const installedOn = ref([])
|
||||||
const contactEmailDomain = ref('')
|
const contactEmailDomain = ref('')
|
||||||
|
|
||||||
|
// Application notes are HTML; sanitize before binding with v-html.
|
||||||
|
const sanitizedNotes = computed(() => sanitizeNotesHtml(app.value?.applicationnotes))
|
||||||
|
|
||||||
// Build sso@domain for a contact. Assumes contact.sso and domain are set.
|
// Build sso@domain for a contact. Assumes contact.sso and domain are set.
|
||||||
function contactEmail(contact) {
|
function contactEmail(contact) {
|
||||||
return `${contact.sso}@${contactEmailDomain.value}`
|
return `${contact.sso}@${contactEmailDomain.value}`
|
||||||
@@ -418,7 +424,17 @@ function kbKeywords(keywords) {
|
|||||||
|
|
||||||
/* Notes styling - rendered as escaped plain text, preserve author line breaks */
|
/* Notes styling - rendered as escaped plain text, preserve author line breaks */
|
||||||
.notes-text {
|
.notes-text {
|
||||||
white-space: pre-wrap;
|
/* Rendered HTML (v-html): block tags handle spacing, so no pre-wrap. */
|
||||||
|
white-space: normal;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
.notes-text :first-child { margin-top: 0; }
|
||||||
|
.notes-text :last-child { margin-bottom: 0; }
|
||||||
|
.notes-text p { margin: 0 0 0.6rem; }
|
||||||
|
.notes-text ul,
|
||||||
|
.notes-text ol { margin: 0 0 0.6rem 1.4rem; }
|
||||||
|
.notes-text a { color: var(--link); }
|
||||||
|
.notes-text code,
|
||||||
|
.notes-text pre { background: var(--bg); border-radius: 4px; padding: 0.1rem 0.3rem; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -29,12 +29,19 @@ def list_articles():
|
|||||||
|
|
||||||
query = KnowledgeBase.query.filter_by(isactive=True)
|
query = KnowledgeBase.query.filter_by(isactive=True)
|
||||||
|
|
||||||
# Search
|
# Search: title, keywords, and the topic (its Application's name). The topic
|
||||||
|
# is matched via an appid subquery instead of a join so it does not collide
|
||||||
|
# with the sort=='topic' join below; articles with no app just miss that
|
||||||
|
# clause and still match on title/keywords.
|
||||||
if search := request.args.get('search'):
|
if search := request.args.get('search'):
|
||||||
|
like = f'%{search}%'
|
||||||
|
topic_appids = db.session.query(Application.appid).filter(
|
||||||
|
Application.appname.ilike(like))
|
||||||
query = query.filter(
|
query = query.filter(
|
||||||
db.or_(
|
db.or_(
|
||||||
KnowledgeBase.shortdescription.ilike(f'%{search}%'),
|
KnowledgeBase.shortdescription.ilike(like),
|
||||||
KnowledgeBase.keywords.ilike(f'%{search}%')
|
KnowledgeBase.keywords.ilike(like),
|
||||||
|
KnowledgeBase.appid.in_(topic_appids)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -26,11 +26,10 @@ LOCKOUT_MINUTES = 15
|
|||||||
|
|
||||||
|
|
||||||
def _login_ip():
|
def _login_ip():
|
||||||
"""Caller IP for rate limiting, honoring the first X-Forwarded-For hop."""
|
"""Caller IP for rate limiting, port stripped so the key is per-host, not
|
||||||
forwarded = request.headers.get('X-Forwarded-For')
|
per-connection (ARR forwards clientip:port with an ephemeral port)."""
|
||||||
if forwarded:
|
from shopdb.utils.clientip import client_ip
|
||||||
return forwarded.split(',')[0].strip()
|
return client_ip(request) or 'unknown'
|
||||||
return request.remote_addr or 'unknown'
|
|
||||||
|
|
||||||
|
|
||||||
def _login_ratelimited():
|
def _login_ratelimited():
|
||||||
|
|||||||
@@ -17,11 +17,10 @@ dashboarddefaults_bp = Blueprint('dashboarddefaults', __name__)
|
|||||||
|
|
||||||
|
|
||||||
def _request_ip():
|
def _request_ip():
|
||||||
"""Caller IP, honoring a single proxy hop via X-Forwarded-For."""
|
"""Caller IP for the IP fallback, port stripped so it matches a stored
|
||||||
forwarded = request.headers.get('X-Forwarded-For')
|
(portless) DashboardDefault.ipaddress. ARR forwards clientip:port."""
|
||||||
if forwarded:
|
from shopdb.utils.clientip import client_ip
|
||||||
return forwarded.split(',')[0].strip()
|
return client_ip(request)
|
||||||
return request.remote_addr
|
|
||||||
|
|
||||||
|
|
||||||
def _serialize(default):
|
def _serialize(default):
|
||||||
|
|||||||
@@ -117,10 +117,9 @@ class AuditLog(db.Model):
|
|||||||
useragent = None
|
useragent = None
|
||||||
|
|
||||||
if request_obj:
|
if request_obj:
|
||||||
# Handle proxy forwarding
|
# First-hop client IP, port stripped (ARR forwards clientip:port).
|
||||||
ipaddress = request_obj.headers.get('X-Forwarded-For', request_obj.remote_addr)
|
from shopdb.utils.clientip import client_ip
|
||||||
if ipaddress and ',' in ipaddress:
|
ipaddress = client_ip(request_obj)
|
||||||
ipaddress = ipaddress.split(',')[0].strip()
|
|
||||||
useragent = request_obj.headers.get('User-Agent', '')[:255]
|
useragent = request_obj.headers.get('User-Agent', '')[:255]
|
||||||
|
|
||||||
entry = cls(
|
entry = cls(
|
||||||
|
|||||||
39
shopdb/utils/clientip.py
Normal file
39
shopdb/utils/clientip.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"""Client IP extraction behind a reverse proxy.
|
||||||
|
|
||||||
|
Prod runs behind IIS ARR, which sets X-Forwarded-For to "clientip:port" (and
|
||||||
|
appends a hop per proxy). The trailing source port is ephemeral - it changes
|
||||||
|
every connection - so it must be stripped before the value is stored (audit),
|
||||||
|
matched (dashboard IP fallback), or used as a rate-limit key (login). Left in,
|
||||||
|
it makes every request look like a different client.
|
||||||
|
|
||||||
|
Port stripping is IPv6-aware: a bracketed [2001:db8::1]:443 loses its port; a
|
||||||
|
bare 2001:db8::1 (many colons, no brackets) is returned untouched.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def strip_port(ipaddress):
|
||||||
|
"""Return ipaddress without a trailing :port. IPv6-safe. None/empty-safe."""
|
||||||
|
if not ipaddress:
|
||||||
|
return ipaddress
|
||||||
|
ipaddress = ipaddress.strip()
|
||||||
|
if ipaddress.startswith('['):
|
||||||
|
# [IPv6] or [IPv6]:port -> the address inside the brackets
|
||||||
|
return ipaddress[1:].split(']', 1)[0]
|
||||||
|
if ipaddress.count(':') == 1:
|
||||||
|
# exactly one colon = IPv4:port (a bare IPv6 has several colons)
|
||||||
|
return ipaddress.split(':', 1)[0]
|
||||||
|
return ipaddress
|
||||||
|
|
||||||
|
|
||||||
|
def client_ip(request_obj):
|
||||||
|
"""First-hop client IP behind one proxy, port stripped.
|
||||||
|
|
||||||
|
Honors the first X-Forwarded-For hop, else remote_addr. Returns None only
|
||||||
|
when neither is present (callers that need a non-null key coerce it).
|
||||||
|
"""
|
||||||
|
forwarded = request_obj.headers.get('X-Forwarded-For')
|
||||||
|
if forwarded:
|
||||||
|
raw = forwarded.split(',')[0].strip()
|
||||||
|
else:
|
||||||
|
raw = request_obj.remote_addr
|
||||||
|
return strip_port(raw)
|
||||||
45
tests/test_core/test_clientip.py
Normal file
45
tests/test_core/test_clientip.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"""strip_port / client_ip normalization (ARR forwards clientip:port)."""
|
||||||
|
|
||||||
|
from shopdb.utils.clientip import strip_port, client_ip
|
||||||
|
|
||||||
|
|
||||||
|
def test_strip_ipv4_port():
|
||||||
|
assert strip_port('10.134.48.20:52344') == '10.134.48.20'
|
||||||
|
|
||||||
|
|
||||||
|
def test_ipv4_without_port_untouched():
|
||||||
|
assert strip_port('10.134.48.20') == '10.134.48.20'
|
||||||
|
|
||||||
|
|
||||||
|
def test_bare_ipv6_untouched():
|
||||||
|
assert strip_port('2001:db8::1') == '2001:db8::1'
|
||||||
|
|
||||||
|
|
||||||
|
def test_bracketed_ipv6_with_port():
|
||||||
|
assert strip_port('[2001:db8::1]:443') == '2001:db8::1'
|
||||||
|
|
||||||
|
|
||||||
|
def test_bracketed_ipv6_without_port():
|
||||||
|
assert strip_port('[2001:db8::1]') == '2001:db8::1'
|
||||||
|
|
||||||
|
|
||||||
|
def test_none_and_empty_safe():
|
||||||
|
assert strip_port(None) is None
|
||||||
|
assert strip_port('') == ''
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeRequest:
|
||||||
|
def __init__(self, headers=None, remote_addr=None):
|
||||||
|
self.headers = headers or {}
|
||||||
|
self.remote_addr = remote_addr
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_ip_prefers_forwarded_first_hop_stripped():
|
||||||
|
request_obj = _FakeRequest(headers={'X-Forwarded-For': '10.0.0.5:51000, 10.0.0.1'},
|
||||||
|
remote_addr='127.0.0.1')
|
||||||
|
assert client_ip(request_obj) == '10.0.0.5'
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_ip_falls_back_to_remote_addr_stripped():
|
||||||
|
request_obj = _FakeRequest(headers={}, remote_addr='192.168.1.9:60000')
|
||||||
|
assert client_ip(request_obj) == '192.168.1.9'
|
||||||
@@ -32,3 +32,36 @@ def test_create_requires_shortdescription(client, db, auth_headers):
|
|||||||
json={'linkurl': 'https://kb.example/x'},
|
json={'linkurl': 'https://kb.example/x'},
|
||||||
headers=auth_headers)
|
headers=auth_headers)
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_matches_topic_appname(client, db, auth_headers):
|
||||||
|
"""Searching a topic (Application name) returns every article under it, not
|
||||||
|
just the one whose title/keywords happen to contain the word.
|
||||||
|
|
||||||
|
Regression: KB list search omitted the topic, so a topic-only match (e.g.
|
||||||
|
'Spotfire' as the app) surfaced a single article instead of all of them.
|
||||||
|
"""
|
||||||
|
from shopdb.core.models import Application
|
||||||
|
|
||||||
|
app_row = Application(appname='Spotfire')
|
||||||
|
db.session.add(app_row)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
# Two articles whose title/keywords do NOT contain 'Spotfire' - only the
|
||||||
|
# topic (appid) ties them to it - plus one that does mention it directly.
|
||||||
|
for payload in [
|
||||||
|
{'shortdescription': 'Dashboard login steps', 'keywords': 'login',
|
||||||
|
'linkurl': 'https://kb.example/a', 'appid': app_row.appid},
|
||||||
|
{'shortdescription': 'Export a visualization', 'keywords': 'export',
|
||||||
|
'linkurl': 'https://kb.example/b', 'appid': app_row.appid},
|
||||||
|
{'shortdescription': 'Spotfire install guide', 'keywords': 'install',
|
||||||
|
'linkurl': 'https://kb.example/c', 'appid': app_row.appid},
|
||||||
|
]:
|
||||||
|
resp = client.post('/api/knowledgebase', json=payload, headers=auth_headers)
|
||||||
|
assert resp.status_code == 201, resp.get_json()
|
||||||
|
|
||||||
|
listing = client.get('/api/knowledgebase?search=Spotfire', headers=auth_headers)
|
||||||
|
assert listing.status_code == 200
|
||||||
|
urls = {a['linkurl'] for a in listing.get_json()['data']}
|
||||||
|
assert {'https://kb.example/a', 'https://kb.example/b',
|
||||||
|
'https://kb.example/c'} <= urls
|
||||||
|
|||||||
Reference in New Issue
Block a user