Skip to content

Commit 8b9682a

Browse files
committed
Harden runtime security boundaries
1 parent d40c9a2 commit 8b9682a

11 files changed

Lines changed: 502 additions & 80 deletions

File tree

config/config.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,9 @@ admin_api:
111111
token: ${ADMIN_API_TOKEN}
112112
trust_forward_auth: true
113113
forward_auth_header: X-authentik-username
114+
trusted_proxy_cidrs:
115+
- 127.0.0.1/32
116+
- ::1/128
114117
base_url: https://assistant.dzarlax.dev
115118

116119
# Artificial Analysis API — overlays Intelligence Index scores onto model capabilities.

internal/adminapi/adminapi_test.go

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,8 @@ func TestTemplatesParse(t *testing.T) {
4545
CatalogProvider: "openrouter",
4646
}
4747
cases := map[string]any{
48-
viewIndex: data,
49-
viewRouting: data.Routing, // routing view takes uiRouting directly
48+
viewIndex: data,
49+
viewRouting: data.Routing, // routing view takes uiRouting directly
5050
viewModelsBrowser: data,
5151
}
5252
for v, d := range cases {
@@ -135,6 +135,7 @@ func TestAuthForwardAuth(t *testing.T) {
135135
s := newTestServer(t)
136136
s.cfg.TrustForwardAuth = true
137137
s.cfg.ForwardAuthHeader = "X-authentik-username"
138+
s.cfg.TrustedProxyCIDRs = []string{"127.0.0.1/32", "::1/128"}
138139
mux := http.NewServeMux()
139140
s.registerRoutes(mux)
140141
srv := httptest.NewServer(mux)
@@ -164,6 +165,32 @@ func TestAuthForwardAuth(t *testing.T) {
164165
}
165166
}
166167

168+
func TestAuthForwardAuthRequiresTrustedProxy(t *testing.T) {
169+
s := newTestServer(t)
170+
s.cfg.TrustForwardAuth = true
171+
s.cfg.ForwardAuthHeader = "X-authentik-username"
172+
s.cfg.TrustedProxyCIDRs = []string{"10.0.0.0/8"}
173+
174+
called := false
175+
handler := s.requireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
176+
called = true
177+
w.WriteHeader(http.StatusOK)
178+
}))
179+
180+
req := httptest.NewRequest(http.MethodGet, "/routing", nil)
181+
req.RemoteAddr = "203.0.113.10:5555"
182+
req.Header.Set("X-authentik-username", "alice")
183+
rec := httptest.NewRecorder()
184+
handler.ServeHTTP(rec, req)
185+
186+
if called {
187+
t.Fatal("forward-auth handler should not be called for an untrusted remote")
188+
}
189+
if rec.Code != http.StatusUnauthorized {
190+
t.Fatalf("got %d, want %d", rec.Code, http.StatusUnauthorized)
191+
}
192+
}
193+
167194
// TestHealthz: always reachable.
168195
func TestHealthz(t *testing.T) {
169196
s := newTestServer(t)

internal/adminapi/auth.go

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ package adminapi
22

33
import (
44
"crypto/subtle"
5+
"log/slog"
6+
"net"
57
"net/http"
8+
"net/netip"
69
"strings"
710
)
811

@@ -23,7 +26,7 @@ const authCookieName = "admin_auth"
2326
func (s *Server) requireAuth(next http.Handler) http.Handler {
2427
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2528
// 1. Forward-auth (Authentik via Traefik).
26-
if s.cfg.TrustForwardAuth && r.Header.Get(s.cfg.ForwardAuthHeader) != "" {
29+
if s.cfg.TrustForwardAuth && r.Header.Get(s.cfg.ForwardAuthHeader) != "" && s.requestFromTrustedForwardAuthProxy(r) {
2730
next.ServeHTTP(w, r)
2831
return
2932
}
@@ -72,3 +75,33 @@ func (s *Server) tokenMatches(got string) bool {
7275
}
7376
return subtle.ConstantTimeCompare([]byte(got), []byte(s.cfg.Token)) == 1
7477
}
78+
79+
func (s *Server) requestFromTrustedForwardAuthProxy(r *http.Request) bool {
80+
if len(s.cfg.TrustedProxyCIDRs) == 0 {
81+
s.logger.Warn("forward-auth header ignored: no trusted proxy CIDRs configured")
82+
return false
83+
}
84+
host, _, err := net.SplitHostPort(r.RemoteAddr)
85+
if err != nil {
86+
host = r.RemoteAddr
87+
}
88+
addr, err := netip.ParseAddr(host)
89+
if err != nil {
90+
s.logger.Warn("forward-auth header ignored: invalid remote address", "remote_addr", r.RemoteAddr)
91+
return false
92+
}
93+
for _, raw := range s.cfg.TrustedProxyCIDRs {
94+
prefix, err := netip.ParsePrefix(strings.TrimSpace(raw))
95+
if err != nil {
96+
s.logger.Warn("forward-auth trusted proxy CIDR ignored", "cidr", raw, "err", err)
97+
continue
98+
}
99+
if prefix.Contains(addr) {
100+
return true
101+
}
102+
}
103+
s.logger.Warn("forward-auth header ignored: untrusted remote address",
104+
slog.String("remote_addr", r.RemoteAddr),
105+
)
106+
return false
107+
}

internal/adminapi/templates/chat.html

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -203,10 +203,67 @@
203203
// ============================================================
204204
// Markdown rendering + code copy buttons.
205205
// ============================================================
206+
function sanitizeMarkdownHTML(html) {
207+
var tpl = document.createElement('template');
208+
tpl.innerHTML = html || '';
209+
var allowedTags = {
210+
A: true, B: true, BLOCKQUOTE: true, BR: true, CODE: true, DEL: true,
211+
EM: true, H1: true, H2: true, H3: true, H4: true, H5: true, H6: true,
212+
HR: true, I: true, LI: true, OL: true, P: true, PRE: true, S: true,
213+
SPAN: true, STRONG: true, TABLE: true, TBODY: true, TD: true, TH: true,
214+
THEAD: true, TR: true, UL: true
215+
};
216+
var allowedAttrs = {
217+
A: { href: true, title: true },
218+
CODE: { class: true },
219+
SPAN: { class: true },
220+
TH: { align: true },
221+
TD: { align: true }
222+
};
223+
function safeURL(value) {
224+
try {
225+
var u = new URL(value, window.location.origin);
226+
return u.protocol === 'http:' || u.protocol === 'https:' || u.protocol === 'mailto:';
227+
} catch (_) {
228+
return false;
229+
}
230+
}
231+
function clean(node) {
232+
Array.from(node.childNodes).forEach(function(child) {
233+
if (child.nodeType === Node.TEXT_NODE) return;
234+
if (child.nodeType !== Node.ELEMENT_NODE) {
235+
child.remove();
236+
return;
237+
}
238+
if (!allowedTags[child.tagName]) {
239+
child.replaceWith(document.createTextNode(child.textContent || ''));
240+
return;
241+
}
242+
Array.from(child.attributes).forEach(function(attr) {
243+
var name = attr.name.toLowerCase();
244+
var tagAttrs = allowedAttrs[child.tagName] || {};
245+
if (!tagAttrs[name]) {
246+
child.removeAttribute(attr.name);
247+
return;
248+
}
249+
if (name === 'href' && !safeURL(attr.value)) {
250+
child.removeAttribute(attr.name);
251+
}
252+
});
253+
if (child.tagName === 'A' && child.getAttribute('href')) {
254+
child.setAttribute('rel', 'noopener noreferrer');
255+
child.setAttribute('target', '_blank');
256+
}
257+
clean(child);
258+
});
259+
}
260+
clean(tpl.content);
261+
return tpl.innerHTML;
262+
}
206263
function renderMarkdown(root) {
207264
(root || document).querySelectorAll('.chat-msg__body.md[data-md]').forEach(function(el) {
208265
if (el.dataset.rendered) return;
209-
el.innerHTML = marked.parse(el.dataset.md || '');
266+
el.innerHTML = sanitizeMarkdownHTML(marked.parse(el.dataset.md || ''));
210267
el.dataset.rendered = '1';
211268
hydrateCodeBlocks(el);
212269
});
@@ -513,7 +570,11 @@
513570
}
514571
} catch (err) {
515572
if (err.name !== 'AbortError') {
516-
bot.body.innerHTML = '<span class="chat-error">Error: ' + (err.message || err) + '</span>';
573+
bot.body.textContent = '';
574+
var errSpan = document.createElement('span');
575+
errSpan.className = 'chat-error';
576+
errSpan.textContent = 'Error: ' + (err.message || err);
577+
bot.body.appendChild(errSpan);
517578
} else {
518579
bot.body.innerHTML = '<span class="chat-error">Stopped.</span>';
519580
}
@@ -557,7 +618,7 @@
557618
var md = document.createElement('div');
558619
md.className = 'chat-msg__body md';
559620
md.dataset.md = final;
560-
md.innerHTML = marked.parse(final);
621+
md.innerHTML = sanitizeMarkdownHTML(marked.parse(final));
561622
md.dataset.rendered = '1';
562623
hydrateCodeBlocks(md);
563624
bot.body.replaceWith(md);
@@ -572,7 +633,11 @@
572633
} else if (ev.event === 'error') {
573634
try {
574635
var e = JSON.parse(ev.data);
575-
bot.body.innerHTML = '<span class="chat-error">Error: ' + (e.message || 'unknown') + '</span>';
636+
bot.body.textContent = '';
637+
var errEl = document.createElement('span');
638+
errEl.className = 'chat-error';
639+
errEl.textContent = 'Error: ' + (e.message || 'unknown');
640+
bot.body.appendChild(errEl);
576641
} catch (_) {
577642
bot.body.innerHTML = '<span class="chat-error">Error</span>';
578643
}

0 commit comments

Comments
 (0)