Skip to content

Commit f05b84e

Browse files
authored
Merge pull request #2039 from unclecode/fix/docker-ui-auth-gate
fix(docker): let dashboard/playground load when auth gate is active
2 parents a5edc3e + b851161 commit f05b84e

5 files changed

Lines changed: 140 additions & 24 deletions

File tree

‎deploy/docker/auth_gate.py‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
* a valid HS256 JWT minted by this server -> the token's own scope claim.
1616
1717
Public paths (the health check and the token-issuing endpoint) pass through.
18+
Public prefixes (the UI static shells) also pass through - they serve no data.
1819
On failure: HTTP 401 JSON, or WebSocket close 4401.
1920
On success: the validated principal is attached at scope["state"]["principal"]
2021
(readable downstream as request.state.principal) for scope/ownership checks.
@@ -38,18 +39,24 @@ def __init__(
3839
*,
3940
token_provider: Callable[[], str],
4041
public_paths: Iterable[str] = (),
42+
public_prefixes: Iterable[str] = (),
4143
):
4244
self.app = app
4345
self._token_provider = token_provider
4446
self.public_paths = set(public_paths)
47+
self.public_prefixes = tuple(public_prefixes)
4548

4649
# ─────────────────────────── ASGI entry ───────────────────────────
4750
async def __call__(self, scope, receive, send):
4851
if scope["type"] not in ("http", "websocket"):
4952
await self.app(scope, receive, send)
5053
return
5154

52-
if scope.get("path", "") in self.public_paths:
55+
path = scope.get("path", "")
56+
if path in self.public_paths:
57+
await self.app(scope, receive, send)
58+
return
59+
if self.public_prefixes and path.startswith(self.public_prefixes):
5360
await self.app(scope, receive, send)
5461
return
5562

‎deploy/docker/server.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,7 @@ def _current_api_token() -> str:
393393
AuthGateMiddleware,
394394
token_provider=_current_api_token,
395395
public_paths={HEALTH_PATH, "/token"},
396+
public_prefixes=_UI_PREFIXES,
396397
)
397398

398399
# ── request body-size limit (DoS) ─────────────────────────────────────

‎deploy/docker/static/monitor/index.html‎

Lines changed: 64 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,14 @@ <h1 class="text-lg font-medium flex items-center space-x-4">
9393
</h1>
9494

9595
<div class="ml-auto flex items-center space-x-4">
96+
<!-- Token bar -->
97+
<div class="flex items-center space-x-2">
98+
<input id="token-input" type="password" placeholder="API token"
99+
class="bg-dark border border-border rounded px-2 py-1 text-xs w-48">
100+
<button id="token-save" class="px-2 py-1 bg-primary text-dark rounded text-xs hover:bg-primarydim">Set</button>
101+
<button id="token-clear" class="px-2 py-1 border border-border rounded text-xs hover:bg-surface">Clear</button>
102+
<span id="token-status" class="text-xs"></span>
103+
</div>
96104
<!-- Connection Status -->
97105
<div class="flex items-center space-x-2">
98106
<div id="ws-status" class="flex items-center space-x-1">
@@ -318,6 +326,47 @@ <h2 class="text-sm font-medium mb-3 text-accent">Control Actions</h2>
318326
</main>
319327

320328
<script>
329+
// ========== Auth ==========
330+
function getToken() { return sessionStorage.getItem('crawl4ai_token') || ''; }
331+
332+
function authFetch(url, opts = {}) {
333+
const token = getToken();
334+
if (token) {
335+
opts.headers = { ...opts.headers, 'Authorization': 'Bearer ' + token };
336+
}
337+
return fetch(url, opts);
338+
}
339+
340+
(function initTokenBar() {
341+
const input = document.getElementById('token-input');
342+
const status = document.getElementById('token-status');
343+
const saved = sessionStorage.getItem('crawl4ai_token');
344+
if (saved) input.value = saved;
345+
346+
function applyToken() {
347+
const val = input.value.trim();
348+
if (!val) return;
349+
sessionStorage.setItem('crawl4ai_token', val);
350+
status.textContent = 'Token saved';
351+
status.className = 'text-xs text-green-400';
352+
setTimeout(() => { status.textContent = ''; }, 2000);
353+
// Reconnect WebSocket with new token
354+
if (websocket) { websocket.close(); }
355+
wsReconnectAttempts = 0;
356+
connectWebSocket();
357+
}
358+
359+
document.getElementById('token-save').addEventListener('click', applyToken);
360+
input.addEventListener('keydown', e => { if (e.key === 'Enter') applyToken(); });
361+
document.getElementById('token-clear').addEventListener('click', () => {
362+
sessionStorage.removeItem('crawl4ai_token');
363+
input.value = '';
364+
status.textContent = 'Token cleared';
365+
status.className = 'text-xs text-yellow-400';
366+
setTimeout(() => { status.textContent = ''; }, 2000);
367+
});
368+
})();
369+
321370
// ========== State Management ==========
322371
let autoRefresh = true;
323372
let refreshInterval;
@@ -368,7 +417,9 @@ <h2 class="text-sm font-medium mb-3 text-accent">Control Actions</h2>
368417
wsReconnectAttempts++;
369418

370419
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
371-
const wsUrl = `${protocol}//${window.location.host}/monitor/ws`;
420+
let wsUrl = `${protocol}//${window.location.host}/monitor/ws`;
421+
const wsToken = getToken();
422+
if (wsToken) wsUrl += `?token=${encodeURIComponent(wsToken)}`;
372423

373424
websocket = new WebSocket(wsUrl);
374425

@@ -643,7 +694,7 @@ <h2 class="text-sm font-medium mb-3 text-accent">Control Actions</h2>
643694

644695
async function fetchHealth() {
645696
try {
646-
const res = await fetch('/monitor/health');
697+
const res = await authFetch('/monitor/health');
647698
const data = await res.json();
648699

649700
// Container metrics
@@ -691,7 +742,7 @@ <h2 class="text-sm font-medium mb-3 text-accent">Control Actions</h2>
691742
async function fetchRequests() {
692743
try {
693744
const filter = document.getElementById('filter-requests')?.value || 'all';
694-
const res = await fetch(`/monitor/requests?status=${filter}&limit=50`);
745+
const res = await authFetch(`/monitor/requests?status=${filter}&limit=50`);
695746
const data = await res.json();
696747

697748
// Active requests
@@ -735,7 +786,7 @@ <h2 class="text-sm font-medium mb-3 text-accent">Control Actions</h2>
735786

736787
async function fetchBrowsers() {
737788
try {
738-
const res = await fetch('/monitor/browsers');
789+
const res = await authFetch('/monitor/browsers');
739790
const data = await res.json();
740791

741792
document.getElementById('browser-count').textContent = data.summary.total_count;
@@ -775,7 +826,7 @@ <h2 class="text-sm font-medium mb-3 text-accent">Control Actions</h2>
775826

776827
async function fetchJanitorLog() {
777828
try {
778-
const res = await fetch('/monitor/logs/janitor?limit=100');
829+
const res = await authFetch('/monitor/logs/janitor?limit=100');
779830
const data = await res.json();
780831

781832
const logEl = document.getElementById('janitor-log');
@@ -803,7 +854,7 @@ <h2 class="text-sm font-medium mb-3 text-accent">Control Actions</h2>
803854

804855
async function fetchErrors() {
805856
try {
806-
const res = await fetch('/monitor/logs/errors?limit=100');
857+
const res = await authFetch('/monitor/logs/errors?limit=100');
807858
const data = await res.json();
808859

809860
const logEl = document.getElementById('errors-log');
@@ -830,7 +881,7 @@ <h2 class="text-sm font-medium mb-3 text-accent">Control Actions</h2>
830881

831882
async function fetchEndpointStats() {
832883
try {
833-
const res = await fetch('/monitor/endpoints/stats');
884+
const res = await authFetch('/monitor/endpoints/stats');
834885
const data = await res.json();
835886

836887
const tbody = document.getElementById('endpoints-table-body');
@@ -861,7 +912,7 @@ <h2 class="text-sm font-medium mb-3 text-accent">Control Actions</h2>
861912
async function fetchTimeline() {
862913
try {
863914
const metric = document.getElementById('timeline-metric').value;
864-
const res = await fetch(`/monitor/timeline?metric=${metric}`);
915+
const res = await authFetch(`/monitor/timeline?metric=${metric}`);
865916
const data = await res.json();
866917

867918
drawTimeline(data, metric);
@@ -968,7 +1019,7 @@ <h2 class="text-sm font-medium mb-3 text-accent">Control Actions</h2>
9681019
if (!confirm(`Kill browser ${sig}?`)) return;
9691020

9701021
try {
971-
const res = await fetch('/monitor/actions/kill_browser', {
1022+
const res = await authFetch('/monitor/actions/kill_browser', {
9721023
method: 'POST',
9731024
headers: {'Content-Type': 'application/json'},
9741025
body: JSON.stringify({sig})
@@ -986,7 +1037,7 @@ <h2 class="text-sm font-medium mb-3 text-accent">Control Actions</h2>
9861037
if (!confirm(`Restart browser ${sig}?`)) return;
9871038

9881039
try {
989-
const res = await fetch('/monitor/actions/restart_browser', {
1040+
const res = await authFetch('/monitor/actions/restart_browser', {
9901041
method: 'POST',
9911042
headers: {'Content-Type': 'application/json'},
9921043
body: JSON.stringify({sig})
@@ -1004,7 +1055,7 @@ <h2 class="text-sm font-medium mb-3 text-accent">Control Actions</h2>
10041055
if (!confirm('Force cleanup all cold pool browsers?')) return;
10051056

10061057
try {
1007-
const res = await fetch('/monitor/actions/cleanup', {method: 'POST'});
1058+
const res = await authFetch('/monitor/actions/cleanup', {method: 'POST'});
10081059
const data = await res.json();
10091060

10101061
showActionStatus(`✅ Killed ${data.killed_browsers} browsers`, true);
@@ -1018,7 +1069,7 @@ <h2 class="text-sm font-medium mb-3 text-accent">Control Actions</h2>
10181069
if (!confirm('Restart permanent browser? This will briefly interrupt service.')) return;
10191070

10201071
try {
1021-
const res = await fetch('/monitor/actions/restart_browser', {
1072+
const res = await authFetch('/monitor/actions/restart_browser', {
10221073
method: 'POST',
10231074
headers: {'Content-Type': 'application/json'},
10241075
body: JSON.stringify({sig: 'permanent'})
@@ -1036,7 +1087,7 @@ <h2 class="text-sm font-medium mb-3 text-accent">Control Actions</h2>
10361087
if (!confirm('Reset all endpoint statistics?')) return;
10371088

10381089
try {
1039-
const res = await fetch('/monitor/stats/reset', {method: 'POST'});
1090+
const res = await authFetch('/monitor/stats/reset', {method: 'POST'});
10401091
const data = await res.json();
10411092

10421093
showActionStatus('✅ Stats reset', true);

‎deploy/docker/static/playground/index.html‎

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,14 @@ <h1 class="text-lg font-medium flex items-center space-x-4">
172172
</h1>
173173

174174
<div class="ml-auto flex items-center space-x-4">
175+
<!-- Token bar -->
176+
<div id="token-bar" class="flex items-center space-x-2">
177+
<input id="token-input" type="password" placeholder="API token"
178+
class="bg-dark border border-border rounded px-2 py-1 text-xs w-48">
179+
<button id="token-save" class="px-2 py-1 bg-primary text-dark rounded text-xs hover:bg-primarydim">Set</button>
180+
<button onclick="clearToken()" class="px-2 py-1 border border-border rounded text-xs hover:bg-surface">Clear</button>
181+
<span id="token-status" class="text-xs"></span>
182+
</div>
175183
<a href="/dashboard" class="text-xs text-secondary hover:text-primary underline">Monitor</a>
176184
<div class="flex space-x-2">
177185
<button id="play-tab"
@@ -402,6 +410,44 @@ <h2 class="font-medium text-accent">🔥 Stress Test</h2>
402410
</div>
403411

404412
<script>
413+
// ================ AUTH ================
414+
const _savedToken = sessionStorage.getItem('crawl4ai_token');
415+
const _tokenBar = document.getElementById('token-bar');
416+
const _tokenInput = document.getElementById('token-input');
417+
const _tokenSave = document.getElementById('token-save');
418+
const _tokenStatus = document.getElementById('token-status');
419+
420+
function getToken() { return sessionStorage.getItem('crawl4ai_token') || ''; }
421+
422+
function authFetch(url, opts = {}) {
423+
const token = getToken();
424+
if (token) {
425+
opts.headers = { ...opts.headers, 'Authorization': 'Bearer ' + token };
426+
}
427+
return fetch(url, opts);
428+
}
429+
430+
function applyToken() {
431+
const val = _tokenInput.value.trim();
432+
if (!val) return;
433+
sessionStorage.setItem('crawl4ai_token', val);
434+
_tokenStatus.textContent = 'Token saved';
435+
_tokenStatus.className = 'text-xs text-green-400';
436+
setTimeout(() => { _tokenStatus.textContent = ''; }, 2000);
437+
}
438+
439+
function clearToken() {
440+
sessionStorage.removeItem('crawl4ai_token');
441+
_tokenInput.value = '';
442+
_tokenStatus.textContent = 'Token cleared';
443+
_tokenStatus.className = 'text-xs text-yellow-400';
444+
setTimeout(() => { _tokenStatus.textContent = ''; }, 2000);
445+
}
446+
447+
if (_savedToken) _tokenInput.value = _savedToken;
448+
_tokenSave.addEventListener('click', applyToken);
449+
_tokenInput.addEventListener('keydown', e => { if (e.key === 'Enter') applyToken(); });
450+
405451
// Tab switching
406452
document.querySelectorAll('.tab-btn').forEach(btn => {
407453
btn.addEventListener('click', () => {
@@ -517,7 +563,7 @@ <h2 class="font-medium text-accent">🔥 Stress Test</h2>
517563
const code = cm.getValue().trim();
518564
if (!code) return {};
519565

520-
const res = await fetch('/config/dump', {
566+
const res = await authFetch('/config/dump', {
521567
method: 'POST',
522568
headers: { 'Content-Type': 'application/json' },
523569
body: JSON.stringify({ code }),
@@ -691,7 +737,7 @@ <h2 class="font-medium text-accent">🔥 Stress Test</h2>
691737
// Get the question from the LLM-specific input
692738
const question = document.getElementById('llm-question').value.trim() || "What is this page about?";
693739

694-
response = await fetch(`${api}/${encodedUrl}?q=${encodeURIComponent(question)}`, {
740+
response = await authFetch(`${api}/${encodedUrl}?q=${encodeURIComponent(question)}`, {
695741
method: 'GET',
696742
headers: { 'Accept': 'application/json' }
697743
});
@@ -707,7 +753,7 @@ <h2 class="font-medium text-accent">🔥 Stress Test</h2>
707753
forceHighlightElement(document.querySelector('#response-content code'));
708754
} else if (endpoint === 'crawl_stream' || useStreamOverride) {
709755
// Stream processing - now handled directly by /crawl endpoint
710-
response = await fetch(api, {
756+
response = await authFetch(api, {
711757
method: 'POST',
712758
headers: { 'Content-Type': 'application/json' },
713759
body: JSON.stringify(payload)
@@ -746,7 +792,7 @@ <h2 class="font-medium text-accent">🔥 Stress Test</h2>
746792
forceHighlightElement(document.querySelector('#response-content code'));
747793
} else {
748794
// Regular request (handles /crawl and /md)
749-
response = await fetch(api, {
795+
response = await authFetch(api, {
750796
method: 'POST',
751797
headers: { 'Content-Type': 'application/json' },
752798
body: JSON.stringify(payload)
@@ -835,7 +881,7 @@ <h2 class="font-medium text-accent">🔥 Stress Test</h2>
835881

836882
try {
837883
if (useStream) {
838-
const response = await fetch(api, {
884+
const response = await authFetch(api, {
839885
method: 'POST',
840886
headers: { 'Content-Type': 'application/json' },
841887
body: JSON.stringify(payload)
@@ -859,7 +905,7 @@ <h2 class="font-medium text-accent">🔥 Stress Test</h2>
859905

860906
memory = maxMem;
861907
} else {
862-
const response = await fetch(api, {
908+
const response = await authFetch(api, {
863909
method: 'POST',
864910
headers: { 'Content-Type': 'application/json' },
865911
body: JSON.stringify(payload)

‎deploy/docker/tests/test_security_default_posture.py‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
asserts the secure-by-default end state:
77
88
- every mutating + read endpoint, static mount, and MCP transport requires
9-
auth out of the box (R1); only the health check is public,
9+
auth out of the box (R1); only the health check and UI shell pages
10+
(dashboard/playground) are public,
1011
- the token endpoint will not freely mint credentials when no api_token /
1112
secret is configured (R1),
1213
- strong security headers + a strict CSP are present on every response even
@@ -68,11 +69,12 @@
6869
("get", "/monitor/timeline", None),
6970
# MCP transport (must be gated; today it is open AND launders credentials)
7071
("get", "/mcp/schema", None),
71-
# static mounts (dashboard / playground served unauthenticated today)
72-
("get", "/dashboard/", None),
73-
("get", "/playground/", None),
7472
]
7573

74+
# UI shell pages that are intentionally public — they serve only static
75+
# HTML/CSS/JS and contain no data. All data routes behind them remain gated.
76+
PUBLIC_UI_PATHS = ["/dashboard/", "/playground/"]
77+
7678

7779
def _call(client, method, path, body):
7880
fn = getattr(client, method)
@@ -91,6 +93,15 @@ def test_health_check_is_public(self, stock_client):
9193
r = stock_client.get(HEALTH)
9294
assert r.status_code == 200
9395

96+
@pytest.mark.parametrize("path", PUBLIC_UI_PATHS, ids=PUBLIC_UI_PATHS)
97+
def test_ui_shell_is_public(self, stock_client, path):
98+
"""UI shells serve static HTML only — no data — so they load without auth."""
99+
r = stock_client.get(path)
100+
assert r.status_code == 200, (
101+
f"GET {path} returned {r.status_code}; expected 200. "
102+
f"UI shell pages must be publicly accessible."
103+
)
104+
94105
@pytest.mark.parametrize(
95106
"method,path,body",
96107
PROTECTED_ENDPOINTS,

0 commit comments

Comments
 (0)