Draft: the > character that broke authentik brand CSS (EN+ZH)
Deploy / build (push) Successful in 23s
Deploy / build (push) Successful in 23s
Held unpublished (draft: true). Records the u003e escaping bug: authentik renders > in branding_custom_css as the literal text u003e, so any child combinator produces an invalid selector that silently matches nothing. Includes the cssRules-based debugging order and the character safety probe. Docs: not yet recorded in project-state.md
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
---
|
||||
title: "The Character That Silently Broke My authentik CSS"
|
||||
description: "My authentik custom CSS looked correct, matched the right elements, and did nothing. The cause was a single > character that authentik escapes into invalid text."
|
||||
pubDate: 2026-09-20
|
||||
category: devops
|
||||
tags: ["authentik", "css", "self-hosting", "debugging", "browser"]
|
||||
ogImage: /og/authentik-css-greater-than-bug.png
|
||||
banner: /banners/authentik-css-greater-than-bug.png
|
||||
draft: true
|
||||
---
|
||||
|
||||
I spent an afternoon on a CSS rule that should have taken thirty seconds.
|
||||
|
||||
I wanted to hide one line in the authentik login page footer — the
|
||||
hardcoded "Powered by authentik" credit. The rule I wrote has worked in
|
||||
every other project I've touched:
|
||||
|
||||
```css
|
||||
ul.pf-c-list > li:last-child {
|
||||
display: none !important;
|
||||
}
|
||||
```
|
||||
|
||||
It did nothing. Not "it looked slightly off" — the element stayed
|
||||
fully visible. What follows is the four wrong answers I chased, the one
|
||||
correct answer, and the debugging move I should have made first.
|
||||
|
||||
## Why this matters beyond one footer line
|
||||
|
||||
If you self-host authentik and have ever pasted CSS into
|
||||
**System → Brands → Custom CSS** and seen zero effect, you have probably
|
||||
concluded you did something wrong. You almost certainly didn't. The
|
||||
stylesheet is accepted, stored, served to the browser, and parsed — and
|
||||
then silently fails, with no error in any log you can reach.
|
||||
|
||||
That is the worst kind of bug: no feedback loop. This post gives you the
|
||||
loop back.
|
||||
|
||||
## Wrong answer #1: it's shadow DOM, so CSS can't reach it
|
||||
|
||||
My first assumption. Modern web components often hide their markup
|
||||
behind a shadow root, and normal document CSS cannot cross that
|
||||
boundary. The authentik login page is rendered by web components — I had
|
||||
seen `<ak-flow-executor>` and `<ak-drawer>` in the page source — so this
|
||||
felt obviously right.
|
||||
|
||||
I read the component definition out of the shipped bundle to confirm:
|
||||
|
||||
```js
|
||||
var oe = class extends L {
|
||||
createRenderRoot() { return this }
|
||||
render() { ... }
|
||||
}
|
||||
```
|
||||
|
||||
`createRenderRoot(){ return this }` means **no shadow root** — the
|
||||
component renders into the light DOM. Ordinary CSS reaches it just fine.
|
||||
|
||||
Wrong answer. Moving on.
|
||||
|
||||
## Wrong answer #2: the CSS isn't being injected at all
|
||||
|
||||
Next theory: my CSS never made it into the page. I grepped the served
|
||||
HTML for a distinctive class from my rule:
|
||||
|
||||
```
|
||||
<style data-id="brand-css">.ak-login-container{ padding-top: 16vh; ... }
|
||||
```
|
||||
|
||||
It was there, first try. authentik injects brand CSS as a `<style
|
||||
data-id="brand-css">` block in the document head. Injection was working.
|
||||
|
||||
Wrong answer.
|
||||
|
||||
## Wrong answer #3: specificity — PatternFly is winning
|
||||
|
||||
Plausible. authentik's UI is built on PatternFly, which ships opinionated
|
||||
list styles. My rule used `!important`, but `!important` only wins within
|
||||
the same cascade layer — and if PatternFly's rule were also `!important`
|
||||
in a later layer, mine would lose.
|
||||
|
||||
This is where I stopped guessing and started measuring. I loaded the page
|
||||
in a headless browser and asked the DOM directly:
|
||||
|
||||
```js
|
||||
const host = document.querySelector('ak-brand-links');
|
||||
const li = host.querySelector('li[data-kind="text"]');
|
||||
return {
|
||||
display: getComputedStyle(li).display,
|
||||
matchesDataKind: li.matches('ul.pf-c-list > li[data-kind="text"]'),
|
||||
matchesLastChild: li.matches('ul.pf-c-list.pf-m-inline > li:last-child'),
|
||||
};
|
||||
```
|
||||
|
||||
The answer:
|
||||
|
||||
```
|
||||
display: "list-item" ← not hidden
|
||||
matchesDataKind: true ← my selector IS correct
|
||||
matchesLastChild: true ← and so is this one
|
||||
```
|
||||
|
||||
The selectors matched the element. The CSS still didn't apply. That
|
||||
combination is only possible if the stylesheet the browser parsed no
|
||||
longer contains the rule I wrote.
|
||||
|
||||
## Wrong answer #4: (there wasn't one — I read the parsed CSS)
|
||||
|
||||
So I read back what the browser's **CSS parser** had actually
|
||||
registered, not what the page source said:
|
||||
|
||||
```js
|
||||
for (const sheet of document.styleSheets) {
|
||||
if (sheet.ownerNode.getAttribute('data-id') === 'brand-css') {
|
||||
for (const rule of sheet.cssRules) console.log(rule.cssText);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```
|
||||
"ul.pf-c-list u003e li[data-kind=\"text\"], ul.pf-c-list.pf-m-inline u003e li:last-child { display: none !important; }"
|
||||
```
|
||||
|
||||
There it is. **`u003e`.**
|
||||
|
||||
The `>` character — written correctly in my CSS, stored correctly in the
|
||||
database, returned correctly by the API — was rendered into the HTML as
|
||||
the literal text `u003e`. Not the `>` character. The six characters
|
||||
`u`, `0`, `0`, `3`, `e`.
|
||||
|
||||
So the browser tried to parse this selector:
|
||||
|
||||
```
|
||||
ul.pf-c-list u003e li[data-kind="text"]
|
||||
```
|
||||
|
||||
`u003e` is not a combinator. The selector is invalid. An invalid selector
|
||||
in a comma-separated list is discarded, so the rule never existed as far
|
||||
as the browser was concerned — while `matches()` on the *correct*
|
||||
selector string still returned `true`, which is why the element looked
|
||||
like it matched something.
|
||||
|
||||
## The fix
|
||||
|
||||
Remove every child combinator from authentik brand CSS. Use a descendant
|
||||
selector instead — a single space instead of `>`:
|
||||
|
||||
```css
|
||||
/* BROKEN — the > becomes literal text "u003e" and the
|
||||
selector is discarded */
|
||||
ul.pf-c-list > li[data-kind="text"] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* WORKS — descendant combinator passes through intact */
|
||||
ul.pf-c-list li[data-kind="text"] {
|
||||
display: none !important;
|
||||
}
|
||||
```
|
||||
|
||||
That is the whole fix. One character deleted.
|
||||
|
||||
### Where the escaping comes from
|
||||
|
||||
`\u003E` is how JSON/JavaScript encodes `>`. authentik's Django templates
|
||||
render the brand config as a JavaScript object literal, and a HTML/JS
|
||||
escaper is being applied to the `branding_custom_css` string. Backslash
|
||||
and the `u` get separated somewhere in that path, so the browser receives
|
||||
`u003e` — the escape without its backslash — instead of `>`.
|
||||
|
||||
The stored value is correct. The API response is correct. Only the
|
||||
rendered page is wrong:
|
||||
|
||||
```bash
|
||||
# What the API returns — correct
|
||||
curl -s -H "Authorization: Bearer $TOKEN" \
|
||||
"https://auth.hoelee.com/api/v3/core/brands/" \
|
||||
| python -c "import sys,json;print([b['branding_custom_css'] for b in json.load(sys.stdin)['results']][0])"
|
||||
# → ul.pf-c-list > li[data-kind="text"] { ... } ← > is intact here
|
||||
|
||||
# What the browser receives — corrupted
|
||||
curl -sL https://auth.hoelee.com/ | grep -o 'ul.pf-c-list[^;]*'
|
||||
# → ul.pf-c-list \u003E li[data-kind="text"] ← escaped
|
||||
```
|
||||
|
||||
This is why the bug is nearly unreachable by searching: anyone debugging
|
||||
via the API, the database, or the admin UI sees a perfectly correct
|
||||
stylesheet.
|
||||
|
||||
### What survived and what didn't
|
||||
|
||||
I probed which characters get mangled, to know what else to avoid:
|
||||
|
||||
| Character | Renders as | Safe? |
|
||||
|---|---|---|
|
||||
| `>` | `u003e` | ❌ breaks the selector |
|
||||
| `;` | `;` | ✅ |
|
||||
| `"` | `"` | ✅ |
|
||||
| `{` `}` `:` | unchanged | ✅ |
|
||||
|
||||
Only the child combinator is affected in practice, because `>` is the
|
||||
only one of these that appears in a CSS selector — the others only appear
|
||||
in declarations, which are preserved.
|
||||
|
||||
## Verifying the fix in a real browser
|
||||
|
||||
Do not verify by re-reading the page source — that was the trap. Ask the
|
||||
browser for the element's geometry:
|
||||
|
||||
```js
|
||||
const li = document.querySelector('li[data-kind="text"]');
|
||||
return {
|
||||
display: getComputedStyle(li).display,
|
||||
visible: li.getBoundingClientRect().height > 0,
|
||||
};
|
||||
```
|
||||
|
||||
```
|
||||
display: "none"
|
||||
visible: false
|
||||
```
|
||||
|
||||
The element is gone. That is a real measurement, not an inference.
|
||||
|
||||
## What I'd do differently
|
||||
|
||||
**When a selector matches but the styles don't apply, read
|
||||
`styleSheets[i].cssRules` immediately.** Everything before that step was
|
||||
speculation I could have skipped. The parsed rule list is the
|
||||
browser's ground truth — it tells you in one line whether the rule you
|
||||
*wrote* is the rule that *exists*.
|
||||
|
||||
The specific ordering I'd use next time:
|
||||
|
||||
1. Does the element exist and match? → `element.matches(selector)`
|
||||
2. Is the rule present in the parsed stylesheet? → `cssRules`
|
||||
3. Only then consider specificity, layers, and `!important`
|
||||
|
||||
I did those in the opposite order, which is why it took an afternoon.
|
||||
|
||||
The second lesson is narrower but worth writing down: **escaping bugs
|
||||
live between the layer that stores data and the layer that renders it.**
|
||||
Check the value at both ends before you check anything else. I looked at
|
||||
the database, then at the API, and concluded the CSS was fine. The bug
|
||||
was in the third place I looked.
|
||||
|
||||
## The result
|
||||
|
||||
One character deleted, one footer line hidden, and a debugging rule
|
||||
that has already paid for itself: in the same session it took me three
|
||||
minutes to find a similar mismatch in a different rule, because I went
|
||||
straight to `cssRules` instead of guessing.
|
||||
|
||||
If you self-host authentik and your brand CSS has ever silently done
|
||||
nothing — check for a `>` first.
|
||||
|
||||
---
|
||||
|
||||
## Want this for your business?
|
||||
|
||||
If you need self-hosted SSO, a hardened login page, or someone to debug
|
||||
the infrastructure you already run, that's the work I do.
|
||||
|
||||
- **WhatsApp:** [011-797 2969](https://wa.me/60127972969) — tap to chat
|
||||
- **Email:** [[email protected]](mailto:[email protected]?subject=Self-hosted%20SSO%20enquiry)
|
||||
- **Website:** [hoelee.com](https://www.hoelee.com)
|
||||
|
||||
I set up authentik single sign-on, self-hosted Docker stacks, reverse
|
||||
proxies and tunnels for small businesses in Malaysia — and I write up
|
||||
what I learn while doing it.
|
||||
@@ -0,0 +1,253 @@
|
||||
---
|
||||
title: "一个字符让我的 authentik CSS 静默失效"
|
||||
description: "我的 authentik 自定义 CSS 看起来完全正确,也匹配到了目标元素,却毫无效果。原因是一个 > 字符被 authentik 转义成了无效文本。"
|
||||
pubDate: 2026-09-20
|
||||
category: devops
|
||||
tags: ["authentik", "css", "self-hosting", "debugging", "browser"]
|
||||
ogImage: /og/authentik-css-greater-than-bug.png
|
||||
banner: /banners/authentik-css-greater-than-bug.png
|
||||
draft: true
|
||||
---
|
||||
|
||||
我花了一个下午去查一条本该三十秒解决的 CSS 规则。
|
||||
|
||||
我想隐藏 authentik 登录页脚里的一行——硬编码的「Powered by
|
||||
authentik」署名。这条规则在我做过的其他项目里从来都是有效的:
|
||||
|
||||
```css
|
||||
ul.pf-c-list > li:last-child {
|
||||
display: none !important;
|
||||
}
|
||||
```
|
||||
|
||||
它毫无作用。不是「看起来有点不对」——那个元素依然完整地显示着。下面
|
||||
是我追过的四个错误答案、唯一正确的答案,以及我本该第一个就做的调试
|
||||
动作。
|
||||
|
||||
## 为什么这不只是一行页脚的问题
|
||||
|
||||
如果你自托管 authentik,曾经把 CSS 粘进
|
||||
**System → Brands → Custom CSS** 却完全没效果,那你多半以为自己哪里
|
||||
写错了。你几乎肯定没写错。样式表被接收、被存储、被送到浏览器、也被
|
||||
解析了——然后静默失效,你够得着的任何日志里都没有报错。
|
||||
|
||||
这是最糟糕的一类 bug:没有反馈回路。这篇文章把这条回路还给你。
|
||||
|
||||
## 错误答案 #1:这是 shadow DOM,CSS 进不去
|
||||
|
||||
我的第一个假设。现代 Web Component 常常把标记藏在 shadow root 后面,
|
||||
普通的文档 CSS 无法跨越那道边界。authentik 的登录页确实由
|
||||
Web Component 渲染——我在页面源码里见过 `<ak-flow-executor>` 和
|
||||
`<ak-drawer>`——所以这看起来显然是答案。
|
||||
|
||||
我从打包好的 bundle 里把组件定义读出来确认:
|
||||
|
||||
```js
|
||||
var oe = class extends L {
|
||||
createRenderRoot() { return this }
|
||||
render() { ... }
|
||||
}
|
||||
```
|
||||
|
||||
`createRenderRoot(){ return this }` 意味着**没有 shadow root**——组件
|
||||
直接渲染进 light DOM。普通 CSS 完全可以作用于它。
|
||||
|
||||
错误答案。继续。
|
||||
|
||||
## 错误答案 #2:CSS 根本没被注入
|
||||
|
||||
下一个理论:我的 CSS 压根没进到页面里。我在返回的 HTML 里搜了一个
|
||||
自己规则里的特征类名:
|
||||
|
||||
```
|
||||
<style data-id="brand-css">.ak-login-container{ padding-top: 16vh; ... }
|
||||
```
|
||||
|
||||
它就在那里,一次就搜到了。authentik 把品牌 CSS 作为
|
||||
`<style data-id="brand-css">` 块注入到文档头部。注入是正常的。
|
||||
|
||||
错误答案。
|
||||
|
||||
## 错误答案 #3:优先级问题——PatternFly 赢了
|
||||
|
||||
听起来合理。authentik 的界面基于 PatternFly,它带有风格强势的列表
|
||||
样式。我的规则用了 `!important`,但 `!important` 只在同一个级联层
|
||||
(layer)内有效——如果 PatternFly 的规则也是 `!important` 且在更靠
|
||||
后的层里,我的就会输。
|
||||
|
||||
到这里我停止猜测,开始实测。我用无头浏览器加载页面,直接问 DOM:
|
||||
|
||||
```js
|
||||
const host = document.querySelector('ak-brand-links');
|
||||
const li = host.querySelector('li[data-kind="text"]');
|
||||
return {
|
||||
display: getComputedStyle(li).display,
|
||||
matchesDataKind: li.matches('ul.pf-c-list > li[data-kind="text"]'),
|
||||
matchesLastChild: li.matches('ul.pf-c-list.pf-m-inline > li:last-child'),
|
||||
};
|
||||
```
|
||||
|
||||
返回结果:
|
||||
|
||||
```
|
||||
display: "list-item" ← 没有被隐藏
|
||||
matchesDataKind: true ← 我的选择器是正确的
|
||||
matchesLastChild: true ← 这条也正确
|
||||
```
|
||||
|
||||
选择器匹配到了元素,CSS 却没生效。这种组合只有在一种情况下可能:
|
||||
浏览器解析到的那张样式表里,已经没有我写的那条规则了。
|
||||
|
||||
## 错误答案 #4:(没有了——我直接读解析后的 CSS)
|
||||
|
||||
于是我读回浏览器 **CSS 解析器**实际登记的规则,而不是页面源码里
|
||||
写了什么:
|
||||
|
||||
```js
|
||||
for (const sheet of document.styleSheets) {
|
||||
if (sheet.ownerNode.getAttribute('data-id') === 'brand-css') {
|
||||
for (const rule of sheet.cssRules) console.log(rule.cssText);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```
|
||||
"ul.pf-c-list u003e li[data-kind=\"text\"], ul.pf-c-list.pf-m-inline u003e li:last-child { display: none !important; }"
|
||||
```
|
||||
|
||||
找到了。**`u003e`。**
|
||||
|
||||
`>` 字符——在我的 CSS 里写对了、在数据库里存对了、API 也返回对了
|
||||
——被渲染进 HTML 时变成了字面文本 `u003e`。不是 `>` 字符,而是
|
||||
`u`、`0`、`0`、`3`、`e` 这六个字符。
|
||||
|
||||
于是浏览器试着解析这个选择器:
|
||||
|
||||
```
|
||||
ul.pf-c-list u003e li[data-kind="text"]
|
||||
```
|
||||
|
||||
`u003e` 不是组合器。这个选择器是无效的。在逗号分隔的选择器列表里,
|
||||
无效的那一条会被整条丢弃,所以对浏览器而言这条规则根本不存在
|
||||
——而对*正确*的选择器字符串调用 `matches()` 依然返回 `true`,这就
|
||||
是为什么那个元素看起来像是匹配上了什么。
|
||||
|
||||
## 修复方式
|
||||
|
||||
把 authentik 品牌 CSS 里所有的子代组合器(`>`)删掉,改用后代选择器
|
||||
——把 `>` 换成空格:
|
||||
|
||||
```css
|
||||
/* 失效 —— > 会变成字面文本 "u003e",整条规则被丢弃 */
|
||||
ul.pf-c-list > li[data-kind="text"] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* 有效 —— 后代组合器可以原样通过 */
|
||||
ul.pf-c-list li[data-kind="text"] {
|
||||
display: none !important;
|
||||
}
|
||||
```
|
||||
|
||||
这就是全部修复——删掉一个字符。
|
||||
|
||||
### 这个转义是从哪来的
|
||||
|
||||
`\u003E` 是 JSON/JavaScript 对 `>` 的编码方式。authentik 的 Django
|
||||
模板把品牌配置渲染成一个 JavaScript 对象字面量,而在这个过程里,
|
||||
某个 HTML/JS 转义器被作用到了 `branding_custom_css` 字符串上。反斜杠
|
||||
和 `u` 在这个路径的某处被拆开了,于是浏览器收到的是 `u003e`
|
||||
——没有反斜杠的转义序列——而不是 `>`。
|
||||
|
||||
存进去的值是对的。API 的返回也是对的。只有渲染出来的页面是错的:
|
||||
|
||||
```bash
|
||||
# API 返回的内容 —— 正确
|
||||
curl -s -H "Authorization: Bearer $TOKEN" \
|
||||
"https://auth.hoelee.com/api/v3/core/brands/" \
|
||||
| python -c "import sys,json;print([b['branding_custom_css'] for b in json.load(sys.stdin)['results']][0])"
|
||||
# → ul.pf-c-list > li[data-kind="text"] { ... } ← > 在这里是完整的
|
||||
|
||||
# 浏览器实际收到的 —— 已损坏
|
||||
curl -sL https://auth.hoelee.com/ | grep -o 'ul.pf-c-list[^;]*'
|
||||
# → ul.pf-c-list \u003E li[data-kind="text"] ← 被转义了
|
||||
```
|
||||
|
||||
这就是为什么这个 bug 几乎搜不到:任何人通过 API、数据库或后台界面
|
||||
去调试,看到的都是一张完全正确的样式表。
|
||||
|
||||
### 哪些字符能活下来,哪些不能
|
||||
|
||||
我探测了哪些字符会被破坏,以便知道还需要避开什么:
|
||||
|
||||
| 字符 | 渲染为 | 是否安全 |
|
||||
|---|---|---|
|
||||
| `>` | `u003e` | ❌ 选择器失效 |
|
||||
| `;` | `;` | ✅ |
|
||||
| `"` | `"` | ✅ |
|
||||
| `{` `}` `:` | 不变 | ✅ |
|
||||
|
||||
实际上只有子代组合器会受影响,因为 `>` 是这些字符里唯一出现在 CSS
|
||||
*选择器*中的——其余几个只出现在声明里,而声明会被完整保留。
|
||||
|
||||
## 在真实浏览器里验证修复
|
||||
|
||||
不要通过重读页面源码来验证——那正是陷阱。直接问浏览器元素的几何信息:
|
||||
|
||||
```js
|
||||
const li = document.querySelector('li[data-kind="text"]');
|
||||
return {
|
||||
display: getComputedStyle(li).display,
|
||||
visible: li.getBoundingClientRect().height > 0,
|
||||
};
|
||||
```
|
||||
|
||||
```
|
||||
display: "none"
|
||||
visible: false
|
||||
```
|
||||
|
||||
元素已经消失了。这是真实测量,不是推断。
|
||||
|
||||
## 我会怎么做不同
|
||||
|
||||
**当一个选择器匹配得上、样式却不生效时,立刻去读
|
||||
`styleSheets[i].cssRules`。** 这一步之前的全部尝试都只是猜测,本来
|
||||
可以完全跳过。解析后的规则列表是浏览器的基准事实——它用一行就能
|
||||
告诉你,你*写下*的规则是否就是*存在*的那条规则。
|
||||
|
||||
下次我会用的具体顺序:
|
||||
|
||||
1. 元素存在且匹配吗? → `element.matches(selector)`
|
||||
2. 规则存在于解析后的样式表里吗? → `cssRules`
|
||||
3. 这之后才去考虑优先级、级联层和 `!important`
|
||||
|
||||
我把这三步做了个反序,这就是为什么花掉了一个下午。
|
||||
|
||||
第二条教训更窄,但值得记下来:**转义类 bug 长在「存数据的层」和
|
||||
「渲染数据的层」之间。** 在做任何其他检查之前,先把这两端的值都看
|
||||
一遍。我看了数据库,又看了 API,然后断定 CSS 没问题。而 bug 在我
|
||||
去看的第三个地方。
|
||||
|
||||
## 结果
|
||||
|
||||
删掉一个字符,隐藏一行页脚,得到一条已经回本的调试规则:在同一次
|
||||
会话里,我用三分钟就在另一条规则里找到了类似的不匹配——因为我直接
|
||||
去了 `cssRules`,而不是继续猜。
|
||||
|
||||
如果你自托管 authentik,而品牌 CSS 曾经静默失效过——先检查有没有
|
||||
`>`。
|
||||
|
||||
---
|
||||
|
||||
## 需要为你的业务做这件事吗?
|
||||
|
||||
如果你需要自托管 SSO、加固过的登录页,或者需要有人来排查你现有的
|
||||
基础设施,这正是我在做的工作。
|
||||
|
||||
- **WhatsApp:** [011-797 2969](https://wa.me/60127972969) —— 点击对话
|
||||
- **Email:** [[email protected]](mailto:[email protected]?subject=Self-hosted%20SSO%20enquiry)
|
||||
- **网站:** [hoelee.com](https://www.hoelee.com)
|
||||
|
||||
我为马来西亚的中小企业搭建 authentik 单点登录、自托管 Docker 环境、
|
||||
反向代理与隧道——并把过程中学到的东西写下来。
|
||||
Reference in New Issue
Block a user