ifcurl/forgejo/custom/public/assets/viewer.html
Bruno Postle 8101e1afcf Fix four security vulnerabilities found in security review
1. Stored XSS in populateMetaPanel (viewer.js): IFC type and storey
   names from model data were interpolated unescaped into innerHTML.
   Added hesc() helper and applied it to all IFC-derived values.

2. sessionStorage CSS injection (viewer.html): viewerSnapshot value was
   used unsanitised in style.backgroundImage. Now validated to start
   with 'data:image/jpeg;base64,' before use.

3. Hostname-based SSRF bypass (service.py): _is_private_ip() only
   blocked literal IP addresses; hostnames resolving to private
   addresses bypassed the check. Now resolves all addresses via
   socket.getaddrinfo() and rejects if any resolve to a private range.
   Unresolvable hostnames are treated as private (fail-safe).

4. SSRF + bearer token exfiltration via BCF snapshot (bcf_api.py):
   get_snapshot() forwarded the caller's Authorization token to the
   preview service for an ifc:// URL stored in a Forgejo comment body
   (attacker-controlled). Now parses and validates the stored URL
   through _ssrf_check(), and uses the server-side token for the
   ifc:// host instead of the caller's credential.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 14:49:45 +01:00

296 lines
12 KiB
HTML

<!DOCTYPE html>
<!-- Copyright 2026 The Forgejo Authors. All rights reserved. -->
<!-- SPDX-License-Identifier: MIT -->
<html lang="en">
<head>
<meta charset="utf-8">
<title>IFC Viewer</title>
<link rel="icon" href="data:,">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #1c1c1e; color: #f0f0f0;
font-family: system-ui, sans-serif; overflow: hidden; }
#viewer { position: fixed; inset: 0; top: var(--toolbar-h, 72px); }
#toolbar {
position: fixed; top: 0; left: 0; right: 0; z-index: 10;
display: flex; flex-direction: column; gap: 6px;
padding: 8px 12px;
background: rgba(28,28,30,0.85); backdrop-filter: blur(8px);
border-bottom: 1px solid rgba(255,255,255,0.08);
}
.trow { display: flex; align-items: center; gap: 6px; }
/* Shared input style */
.ti {
font-size: 12px; color: #f0f0f0; font-family: monospace;
background: rgba(255,255,255,0.07); border: 1px solid rgba(255,255,255,0.12);
border-radius: 4px; padding: 3px 7px; outline: none; min-width: 0;
}
.ti:focus { border-color: rgba(255,255,255,0.3); }
.ti::placeholder { color: #505058; }
/* Row 1 */
#url-input { flex: 1; }
#fov-wrap {
display: flex; align-items: center; gap: 4px; flex-shrink: 0;
font-size: 11px; color: #a0a0a8;
}
#fov-input { width: 44px; text-align: right; }
/* Row 2 structured fields */
.field { display: flex; align-items: center; gap: 4px; min-width: 0; }
.field label { font-size: 10px; color: #808088; flex-shrink: 0; }
#repo-input { flex: 3; }
#ref-input { flex: 2; }
#path-input { flex: 2; }
#selector-input { flex: 1; }
/* Toolbar buttons */
.tb-btn {
font-size: 12px; color: #c0c0c8; cursor: pointer; flex-shrink: 0;
background: rgba(255,255,255,0.07); border: 1px solid rgba(255,255,255,0.12);
border-radius: 4px; padding: 3px 8px; white-space: nowrap;
}
.tb-btn:hover { background: rgba(255,255,255,0.14); }
.tb-btn.active { color: #a8c8ff; background: rgba(80,120,255,0.2);
border-color: rgba(80,120,255,0.5); }
#ref-badge {
font-size: 10px; color: #f0a050; font-family: monospace;
background: rgba(255,160,80,0.12); border: 1px solid rgba(255,160,80,0.3);
border-radius: 4px; padding: 2px 6px; white-space: nowrap; flex-shrink: 0;
cursor: default;
}
/* BCF panel */
#bcf-comment { flex: 3; }
#bcf-title { flex: 2; }
/* Status */
#status {
position: fixed; bottom: 12px; left: 50%;
transform: translateX(-50%); z-index: 10;
font-size: 13px; color: #a0a0a8;
background: rgba(28,28,30,0.85);
padding: 4px 12px; border-radius: 4px;
pointer-events: none;
}
#status:empty { display: none; }
#snapshot-overlay {
position: fixed; inset: 0; top: var(--toolbar-h, 72px); z-index: 4;
background: #1c1c1e center/contain no-repeat;
transition: opacity 0.5s ease;
}
body.drag-over::after {
content: "Drop ifc:// URL or .bcf file";
position: fixed; inset: 0; z-index: 100;
display: flex; align-items: center; justify-content: center;
font-size: 24px; color: #f0f0f0;
background: rgba(28,28,30,0.7);
pointer-events: none;
}
/* Model metadata panel */
#meta-panel {
position: fixed; bottom: 44px; left: 12px; z-index: 10;
background: rgba(28,28,30,0.92); backdrop-filter: blur(8px);
border: 1px solid rgba(255,255,255,0.12); border-radius: 6px;
padding: 8px 10px; min-width: 180px; max-width: 260px;
max-height: calc(100vh - 130px); overflow-y: auto;
}
#meta-panel h3 {
font-size: 10px; color: #606068;
text-transform: uppercase; letter-spacing: 0.06em;
margin-top: 10px; margin-bottom: 3px;
}
#meta-panel h3:first-child { margin-top: 0; }
.meta-row {
display: flex; align-items: baseline; gap: 4px;
margin-top: 2px; padding: 1px 3px; border-radius: 3px;
}
.meta-row.storey-row { cursor: pointer; }
.meta-row.storey-row:hover { background: rgba(255,255,255,0.07); }
.meta-row.storey-row.active { background: rgba(80,120,255,0.2);
color: #a8c8ff; }
.meta-name {
font-size: 11px; color: #d0d0d8; font-family: monospace;
flex: 1; min-width: 0; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap;
}
.meta-row.storey-row.active .meta-name { color: #a8c8ff; }
.meta-count { font-size: 10px; color: #808088; flex-shrink: 0; }
/* Query results panel */
#query-panel {
position: fixed; bottom: 44px; left: 50%; transform: translateX(-50%);
z-index: 10;
background: rgba(28,28,30,0.92); backdrop-filter: blur(8px);
border: 1px solid rgba(255,255,255,0.12); border-radius: 6px;
padding: 8px 10px; min-width: 320px; max-width: 640px;
max-height: 200px; overflow-y: auto;
}
#query-panel-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 6px;
}
#query-panel-header span {
font-size: 10px; color: #606068;
text-transform: uppercase; letter-spacing: 0.06em;
}
#query-table { width: 100%; border-collapse: collapse; font-size: 11px; }
#query-table th {
text-align: left; color: #808088; font-weight: normal;
padding: 1px 6px 4px 0; border-bottom: 1px solid rgba(255,255,255,0.08);
}
#query-table td {
padding: 2px 6px 2px 0; color: #f0f0f0; font-family: monospace;
border-bottom: 1px solid rgba(255,255,255,0.05);
max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* Element properties panel */
#props-panel {
position: fixed; bottom: 44px; right: 12px; z-index: 10;
background: rgba(28,28,30,0.92); backdrop-filter: blur(8px);
border: 1px solid rgba(255,255,255,0.12); border-radius: 6px;
padding: 8px 10px; min-width: 200px; max-width: 320px;
}
#props-panel-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 4px;
}
#props-panel-header span {
font-size: 10px; color: #606068;
text-transform: uppercase; letter-spacing: 0.06em;
}
#props-panel .prop-row {
display: flex; align-items: baseline; gap: 6px; margin-top: 3px;
}
#props-panel .prop-label {
font-size: 10px; color: #808088; flex-shrink: 0; width: 46px;
}
#props-panel .prop-value {
font-size: 12px; color: #f0f0f0; font-family: monospace;
flex: 1; word-break: break-all;
}
</style>
</head>
<body>
<script>
(function() {
try {
var snap = sessionStorage.getItem("viewerSnapshot");
if (!snap || !snap.startsWith("data:image/jpeg;base64,")) return;
if (!new URLSearchParams(location.search).get("url")) return;
var el = document.createElement("div");
el.id = "snapshot-overlay";
el.style.backgroundImage = "url(" + snap + ")";
document.body.appendChild(el);
} catch(_) {}
})();
</script>
<div id="toolbar">
<!-- Row 1: raw URL + FOV -->
<div class="trow">
<input id="url-input" class="ti" type="text"
placeholder="Paste ifc:// URL and press Enter, or fill fields below"
spellcheck="false">
<button id="copy-url-btn" class="tb-btn" title="Copy ifc:// URL to clipboard"></button>
<button id="clip-btn" class="tb-btn" title="Double-click model surface to add clipping plane">✂ clip</button>
<button id="clip-clear-btn" class="tb-btn" title="Remove all clipping planes" style="display:none">⊗ clips</button>
<button id="fit-btn" class="tb-btn" title="Fit model in view">fit</button>
<button id="meta-btn" class="tb-btn" title="Model overview: types and storeys"></button>
<button id="bcf-btn" class="tb-btn" title="Export current view as a BCF issue">BCF</button>
<button id="new-issue-btn" class="tb-btn" title="Open new issue with this view URL">Issue</button>
<div id="fov-wrap">
FOV <input id="fov-input" class="ti" type="number" min="10" max="120" step="1"
title="Field of view (degrees)">°
</div>
</div>
<!-- Row 2: structured fields -->
<div class="trow">
<div class="field" style="flex:3">
<label>repo</label>
<input id="repo-input" class="ti" type="text"
placeholder="host/org/repo or git@host/org/repo" spellcheck="false">
</div>
<div class="field" style="flex:2">
<label>ref</label>
<input id="ref-input" class="ti" type="text" list="ref-list"
placeholder="heads/main or commit hash" spellcheck="false">
<button id="ref-pick-btn" class="tb-btn" title="Browse branches and tags"></button>
</div>
<datalist id="ref-list"></datalist>
<span id="ref-badge" style="display:none" title="Pinned to this commit — not tracking branch tip"></span>
<button id="ref-switch-btn" class="tb-btn" style="display:none"></button>
<div class="field" style="flex:2">
<label>path</label>
<input id="path-input" class="ti" type="text"
placeholder="model.ifc" spellcheck="false">
</div>
<div class="field" style="flex:2">
<label>selector</label>
<input id="selector-input" class="ti" type="text"
placeholder="IfcWall (viewer: type names only)"
title="Type-name union: IfcWall, IfcWall+IfcSlab. Property/attribute filters are applied server-side only."
spellcheck="false">
</div>
<div class="field" id="visibility-field" style="display:none">
<label>show</label>
<select id="visibility-select" class="ti" title="How to display non-selected elements">
<option value="highlight">highlight</option>
<option value="ghost">ghost</option>
<option value="isolate">isolate</option>
<option value="clash">clash</option>
</select>
</div>
<div class="field" style="flex:2">
<label>query</label>
<input id="query-input" class="ti" type="text"
placeholder="Name or Pset_X.Property"
title="Direct IFC attribute: Name, Description. Property set (dot notation required): Pset_WallCommon.FireRating, Qto_WallBaseQuantities.NetVolume"
spellcheck="false">
</div>
</div>
<!-- Row 3: BCF export/import form (hidden until BCF button clicked) -->
<div id="bcf-row" class="trow" style="display:none">
<button id="bcf-import-btn" class="tb-btn" title="Import a .bcf viewpoint to restore camera and selection">Import .bcf</button>
<input type="file" id="bcf-import-input" accept=".bcf" style="display:none">
<div class="field" style="flex:2">
<label>title</label>
<input id="bcf-title" class="ti" type="text" placeholder="IFC View" spellcheck="false">
</div>
<div class="field" style="flex:3">
<label>comment</label>
<input id="bcf-comment" class="ti" type="text" placeholder="Optional note" spellcheck="false">
</div>
<button id="bcf-cancel-btn" class="tb-btn"></button>
<button id="bcf-export-btn" class="tb-btn">Export .bcf</button>
</div>
</div>
<div id="viewer"></div>
<div id="meta-panel" style="display:none"></div>
<div id="query-panel" style="display:none">
<div id="query-panel-header">
<span id="query-panel-title">query</span>
<button id="query-close-btn" class="tb-btn"></button>
</div>
<table id="query-table">
<thead><tr><th>GlobalId</th><th id="query-val-header-col">value</th></tr></thead>
<tbody id="query-tbody"></tbody>
</table>
</div>
<div id="status"></div>
<div id="props-panel" style="display:none">
<div id="props-panel-header">
<span>Element</span>
<button id="props-close-btn" class="tb-btn"></button>
</div>
<div class="prop-row">
<span class="prop-label">type</span>
<span id="props-type" class="prop-value"></span>
</div>
<div class="prop-row">
<span class="prop-label">name</span>
<span id="props-name" class="prop-value"></span>
</div>
<div class="prop-row">
<span class="prop-label">GlobalId</span>
<span id="props-guid" class="prop-value"></span>
<button id="props-copy-btn" class="tb-btn" title="Copy GlobalId to clipboard"></button>
</div>
</div>
<script type="module" src="./viewer.js"></script>
</body>
</html>