audit 2c: what only the stand could answer, measured and written down
test / test (push) Canceled after 0s
test / test (push) Canceled after 0s
All 2026-10-15; each written beside the code or text it settles. - A-43: /usr/sbin/postlog is -rwxr-xr-x root:root in the image (postfix 3.7.11-0+deb12u1; dpkg-statoverride lists postdrop and postqueue only): Debian does not install it set-gid, so it is no use to dmarc-ingest (cmd/panel/dmarc_ingest.go). - C-X1: OpenSSL 3.0.22 in the image prints the same public key from `x509 -pubkey` and `pkey -pubout` for RSA-2048, P-256, P-384 and a SEC1 P-256 key - cert-reload's same_pair holds for certbot's ECDSA keys. - C-K5, D-guide:933/:233: on the live server (Docker 29.6.1, userland proxy on 0.0.0.0 and ::, no IPv6 on the network) an outside IPv4 client and the host's own public IPv4 address are logged as themselves; the host's IPv6 address and 127.0.0.1 arrive as 172.18.0.1. Over IPv6 every client is the gateway: one level-1 budget, an application IP list cannot name them, rspamd sees the gateway. Guide paragraph under level 1, notes at the milter and allow-list, comments at the anvil setup and authip.go, and a Security entry in the CHANGELOG (an allow-list naming the gateway would admit every IPv6 client). - D-guide:61: a published port nothing listens on (a throwaway container, -p 20999:25) is refused from outside over IPv4 and from the host's public IPv4, accepted and closed through 127.0.0.1 - Docker's proxy, which IPv6 goes through too (not probed over IPv6 for this). - C-M8: the live installation has received one DMARC report, of one record: nothing to set maxReportRecords by yet - said at the constant. - D-S7, D-arch:516, D-arch:532: a new database filled through the store to 120 days at 1 900 messages a day (228 000 messages, 1.37 million lines, 464 MB) on the test server (2 cores): the longest write is the line prune after a 30-day gap, 1.9 s for 342 000 lines (0.4 s with nothing to delete); the send-log delete 0.6 s; 2 000 lines of a tailer pass 18 ms; VACUUM INTO 1.7 s; a second connection writing every 50 ms waited 1.8 s at most, no SQLITE_BUSY - inside busy_timeout's 5 s. architecture.md's "about a second per million lines" was five times too fast; its "six to ten lines of 200 bytes" is 6.0 lines of 146 bytes on the live data. - D-B16: which favicon is fetched, by the probe server's log: Edge 155 the SVG, the desktop app's embedded Chromium 152 the PNG, with or without sizes="any" - layout.html's "vector wins where supported, raster catches the rest" holds; Firefox and Safari not measured. - D-arch:121: the allow/deny trial again, in rspamd/rspamd:3.11.1-3 with today's multimap.conf: every mix of allow and deny entries matching one message ended reject; a changed map was used about 10 s later. - D-sec:424: rspamd runs as _rspamd, uid 11333; the panel is uid 999. Stand: go vet and go test ./... pass on the server (24 packages); make e2e exit 0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,14 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
||||
case, owners may be separated by blanks or continue on the next line, and
|
||||
a key written twice makes the check defer, as it makes Postfix refuse the
|
||||
whole table (assumptions B-PS1, B-PS4).
|
||||
- **Over IPv6 every client looks the same to SelfPost** - documented, not
|
||||
changed. The compose network has no IPv6, so Docker's proxy carries an
|
||||
IPv6 connection and the container sees the network's gateway
|
||||
(`172.18.0.1` by default), measured on a live server: all IPv6 clients
|
||||
share one level-1 rate limit, and an application's client IP list cannot
|
||||
name them - do not put the gateway's address on such a list, or every
|
||||
IPv6 client passes it. IPv4 clients keep their own address (guide, "Rate
|
||||
limiting - level 1"; assumptions C-K5).
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
@@ -41,7 +41,11 @@ reload() {
|
||||
}
|
||||
|
||||
# same_pair says whether a certificate file and a private key file belong
|
||||
# together.
|
||||
# together, by comparing the public key each one prints. openssl-x509(1) and
|
||||
# openssl-pkey(1) do not say the two print it byte for byte alike; measured
|
||||
# in the image, 2026-10-15, OpenSSL 3.0.22: identical for RSA-2048, P-256 and
|
||||
# P-384 keys, and for a P-256 key written in SEC1 form ("EC PRIVATE KEY") -
|
||||
# certbot 2.0 and later issue ECDSA keys by default (assumptions C-X1).
|
||||
same_pair() {
|
||||
cert_pub=$(openssl x509 -noout -pubkey -in "$1" 2>/dev/null) || return 1
|
||||
key_pub=$(openssl pkey -pubout -in "$2" 2>/dev/null) || return 1
|
||||
|
||||
@@ -288,6 +288,10 @@ postconf -e \
|
||||
# smtpd_client_event_limit_exceptions (default $mynetworks; Debian's debconf
|
||||
# default, debian/templates "postfix/mynetworks", is loopback only).
|
||||
# anvil_rate_time_unit is one global window for every anvil limit (postconf(5)).
|
||||
# "Client IP" is the address the container sees: an IPv4 client's own, but
|
||||
# for a client over IPv6 the compose network's gateway, through Docker's
|
||||
# proxy - measured on a live server, 2026-10-15, Docker 29.6.1 (assumptions
|
||||
# C-K5; guide, "Rate limiting - level 1").
|
||||
postconf -e \
|
||||
"smtpd_client_message_rate_limit=${RATE_MSGS}" \
|
||||
"anvil_rate_time_unit=${RATE_WINDOW}s"
|
||||
|
||||
@@ -57,8 +57,11 @@ func runDMARCIngestMode() error {
|
||||
// src/global/pipe_command.c takes from stdout and stderr together. postlog is
|
||||
// no use here: pipe(8) starts the command without the group that may write to
|
||||
// Postfix's log socket (src/util/set_ugid.c, set_ugid(): setgroups() to the
|
||||
// user's one group). Whether Debian's postlog is set-gid, which would bring
|
||||
// the group with it, is not recorded - assumptions A-43.
|
||||
// user's one group). Nor does postlog bring the group with it: upstream
|
||||
// installs it set-gid, Debian's package does not - measured in the image,
|
||||
// 2026-10-15: /usr/sbin/postlog is -rwxr-xr-x root:root (postfix
|
||||
// 3.7.11-0+deb12u1), and dpkg-statoverride lists only postdrop and
|
||||
// postqueue (assumptions A-43).
|
||||
func logRejected(reason error) {
|
||||
fmt.Fprintln(os.Stderr, "dropped: "+logSafe(strings.TrimPrefix(reason.Error(), dmarc.ErrRejected.Error()+": ")))
|
||||
}
|
||||
|
||||
@@ -16,7 +16,10 @@
|
||||
# outweighs, which a refusal by the deny list still overrides. (rspamd
|
||||
# 3.11.1, src/plugins/lua/multimap.lua: every rule is registered as a
|
||||
# "normal" symbol, one with an action with the "passthrough" augmentation;
|
||||
# no "prefilter" setting is read.)
|
||||
# no "prefilter" setting is read. Measured with this file in a throwaway
|
||||
# rspamd/rspamd:3.11.1-3, 2026-10-15: every mix of an allow and a deny entry
|
||||
# matching one message - address, domain, IP - came out reject, and a
|
||||
# changed map file was in use about ten seconds later.)
|
||||
#
|
||||
# A sender rule looks at the envelope sender only, extract_from = "smtp":
|
||||
# by default multimap.lua takes the header From: when the envelope sender is
|
||||
|
||||
+14
-5
@@ -144,7 +144,11 @@ smtp/inet` removes Debian's stock smtpd). Outbound delivery still uses the
|
||||
deliberately not an `accept` action: with two pre-results rspamd keeps the
|
||||
one set first, and deny then won or lost by rule order (seen on rspamd
|
||||
3.11, date not recorded). A refusal by the deny list overrides any score,
|
||||
so deny wins whichever entries match. A sender entry is matched against
|
||||
so deny wins whichever entries match - measured in a throwaway
|
||||
`rspamd/rspamd:3.11.1-3` with this file, 2026-10-15: the same address or
|
||||
IP on both lists, an allowed address with a denied domain or IP, and an
|
||||
allowed IP with a denied address were all `reject` (assumptions
|
||||
D-arch:121). A sender entry is matched against
|
||||
the envelope sender only (`extract_from = "smtp"`; by default rspamd takes
|
||||
a bounce's `From:` header instead - rspamd 3.11.1,
|
||||
`src/plugins/lua/multimap.lua`, the `from` callback).
|
||||
@@ -584,8 +588,12 @@ reads them:
|
||||
send-log row carries is deleted (`PruneSendLogLines`), at start and after
|
||||
every six-hourly send-log sweep. That removes the lines of rows the sweep
|
||||
deleted and the lines that never had a row. The send log is asked once per
|
||||
queue id; the statement still walks the table, about a second per million
|
||||
lines, with the connection taken meanwhile.
|
||||
queue id; the statement still walks the table, with the connection taken
|
||||
meanwhile. Measured on the test server (2 cores, 2026-10-15) with 1.37
|
||||
million lines, 120 days at 1 900 messages a day: 0.4 s when there is
|
||||
nothing to delete, 1.9 s to delete 30 days' lines (342 000) after a gap -
|
||||
under `busy_timeout`'s 5 s, and the longest a second process's write waited
|
||||
meanwhile was 1.8 s (assumptions D-S7, D-arch:516).
|
||||
- **Which lines are a row's.** Postfix reuses a queue id once the message has
|
||||
left the queue, and a row outlives that by months. `SendLogLines` gives a row
|
||||
the lines of its queue id dated from one hour before the row was written to
|
||||
@@ -599,8 +607,9 @@ reads them:
|
||||
catching up at start it writes a line only if the same line is not there.
|
||||
- **Not stored:** what the tailer never read — the tail of a file rotated away
|
||||
while the panel was down.
|
||||
- **Size:** six to ten lines of about 200 bytes per message — more than the row
|
||||
itself. The table is in the database and therefore in a full backup, which
|
||||
- **Size:** about six lines of about 150 bytes per message — more than the row
|
||||
itself (the live installation, 2026-10-15: 6.0 lines per queue id, 146 bytes
|
||||
on average, over its first 18 lines; assumptions D-arch:532). The table is in the database and therefore in a full backup, which
|
||||
`log/` is not ([security.md](security.md) § Backup and domain export).
|
||||
|
||||
**Two one-shot reads** sit beside the follow loop and are unrelated to it, both
|
||||
|
||||
@@ -1026,7 +1026,7 @@ those after it move up one patch number each.
|
||||
per document, reviewed. Two register proposals were outdated by this
|
||||
stage's fixes (a bounce's `From:` line, `Origin: null`) and not used; the
|
||||
nginx renewal needed `--webroot` in the loop, which the register missed.
|
||||
- [ ] **What only the stand can answer** (A-43, C-K5, C-M8, C-X1, D-S7,
|
||||
- [x] **What only the stand can answer** (A-43, C-K5, C-M8, C-X1, D-S7,
|
||||
D-B16 and the document claims marked so): postlog's set-gid in the
|
||||
image; the client address Postfix sees through Docker's port publishing
|
||||
over IPv4 and IPv6; the largest DMARC report received; the public keys
|
||||
@@ -1034,6 +1034,19 @@ those after it move up one patch number each.
|
||||
against busy_timeout at the retention limit; the favicon each browser
|
||||
takes. Each measured, dated and written beside the code it settles.
|
||||
- **Opus**
|
||||
*Done 2026-10-15,* each written beside what it settles: postlog is not
|
||||
set-gid in Debian's package (A-43); Docker's proxy hands every IPv6
|
||||
client and the host's loopback over as the gateway, IPv4 keeps the
|
||||
client (C-K5; a new guide paragraph, and a Security note: an allow-list
|
||||
naming the gateway admits every IPv6 client); one DMARC report of one
|
||||
record so far, too little to judge the cap (C-M8); OpenSSL 3.0.22 prints
|
||||
one public key alike for RSA and ECDSA (C-X1); the longest write, the
|
||||
line prune, 1.9 s at 1 900 messages a day after a 30-day gap, under
|
||||
busy_timeout (D-S7, D-arch:516); about 6 lines of 146 bytes a message
|
||||
(D-arch:532); Edge takes the SVG favicon, the desktop app's browser the
|
||||
PNG (D-B16); a published port nothing listens on is refused over IPv4,
|
||||
accepted by the proxy otherwise (D-guide:61); deny beats allow in every
|
||||
mix on rspamd 3.11.1-3 (D-arch:121); rspamd runs as uid 11333 (D-sec:424).
|
||||
- [ ] Docs and wording: help topic sender-check and guide § Who an
|
||||
application may send as (mockup first), security.md § Mail path,
|
||||
CHANGELOG. - **Sonnet**
|
||||
|
||||
+27
-6
@@ -64,8 +64,14 @@ changed (see the CHANGELOG, *Removed*).
|
||||
**25** is published even when `INBOUND_RELAY_ENABLE=false`; Postfix does not
|
||||
listen on port 25 until you set **`INBOUND_RELAY_ENABLE=true`** and/or
|
||||
**`DMARC_REPORTS_ENABLE=true`** (see [Inbound relay](#inbound-relay) and
|
||||
[DMARC reports](#dmarc-reports)). Harmless extra publishes can look like open
|
||||
ports in external scans.
|
||||
[DMARC reports](#dmarc-reports)). A scan over IPv4 sees such a port closed -
|
||||
the connection is refused. Over IPv6 the connection goes through Docker's
|
||||
proxy (the host runs `docker-proxy` on `::` for every published port), which
|
||||
accepts it and then closes it, so a scanner may report it open. Measured on a
|
||||
live server, 2026-10-15, Docker 29.6.1, with a published port nothing
|
||||
listened on: refused from outside over IPv4 and from the host's public IPv4
|
||||
address; accepted and closed through `127.0.0.1`, the same proxy. IPv6 itself
|
||||
was not probed for this.
|
||||
|
||||
### Local trial
|
||||
|
||||
@@ -230,7 +236,7 @@ expected to set; defaults match the code exactly.
|
||||
| `SEND_LOG_RETENTION_DAYS` | Initial default for how many days of send-log history are kept before the background sweep deletes rows, and with them the `mail.log` lines kept for each message (1–2 KB a message) — the main driver of `/data` growth over time and of the size of a full backup. After the first panel start, change retention under **Server › Settings** (global role); the env value is only used to seed SQLite when the setting has never been saved. | `90` | `.env` |
|
||||
| `PANEL_SESSION_IDLE_DAYS` | Sliding idle timeout for the panel login session, in days. There is no absolute cap: a user who keeps coming back stays signed in indefinitely. | `7` | `.env` |
|
||||
| `SELFPOST_DNS_RESOLVERS` | Comma-separated recursive resolvers the panel's PTR/SPF/DKIM/DMARC checks query directly (so they report what the internet sees, not what this host's stub resolver synthesises). | `1.1.1.1:53`, `8.8.8.8:53`, `9.9.9.9:53` when unset | `.env` |
|
||||
| `TRUSTED_PROXY_CIDR` | Comma-separated CIDRs (bare IPs allowed) of reverse proxies allowed to supply `X-Forwarded-For` for login, setup, and account-change rate-limiting. **Leave unset unless you know the exact address of your reverse proxy.** A wrong value lets a client spoof its rate-limit key by sending a forged `X-Forwarded-For` header — the panel trusts the last hop only when the TCP peer matches one of these CIDRs. With Apache on the host (the default), the panel sees the proxy's connections come from the Docker network's gateway, e.g. `172.18.0.1` (`docker network inspect` shows it). | *(empty — XFF ignored)* | `.env` |
|
||||
| `TRUSTED_PROXY_CIDR` | Comma-separated CIDRs (bare IPs allowed) of reverse proxies allowed to supply `X-Forwarded-For` for login, setup, and account-change rate-limiting. **Leave unset unless you know the exact address of your reverse proxy.** A wrong value lets a client spoof its rate-limit key by sending a forged `X-Forwarded-For` header — the panel trusts the last hop only when the TCP peer matches one of these CIDRs. With Apache on the host (the default), the panel sees the proxy's connections come from the Docker network's gateway, e.g. `172.18.0.1` (`docker network inspect` shows it; measured 2026-10-15: the panel's `127.0.0.1:8080` is published through Docker's proxy, and a connection to such a port from the host arrives from the gateway). | *(empty — XFF ignored)* | `.env` |
|
||||
|
||||
TLS certificate paths (`TLS_CERT_FILE`, `TLS_KEY_FILE`) are fixed in
|
||||
[deploy/docker-compose.yml](../deploy/docker-compose.yml) to match the `./certs`
|
||||
@@ -669,7 +675,18 @@ Level 1 is always on, configured via `.env`:
|
||||
- `RATE_LIMIT_WINDOW_SECONDS` → Postfix `anvil_rate_time_unit`
|
||||
|
||||
This is an anvil limit per client IP and listener. It keeps working even if the
|
||||
journal-milter (level 2) is down. There is no per-IP bypass except the container's own loopback.
|
||||
journal-milter (level 2) is down. There is no per-IP bypass except the
|
||||
container's own loopback.
|
||||
|
||||
**The client IP is the real one over IPv4 only.** Docker's port publishing
|
||||
keeps an IPv4 client's address, but the compose network has no IPv6, so a
|
||||
client that reaches the server over IPv6 is carried by Docker's proxy and
|
||||
arrives from the network's gateway (`172.18.0.1` by default) - and so does a
|
||||
connection from the host to `127.0.0.1`. All such clients share one level-1
|
||||
budget, an application's client IP list cannot name them, and the spam
|
||||
filter sees the gateway. Measured on a live server, 2026-10-15, Docker
|
||||
29.6.1: an outside IPv4 client and the host's own public IPv4 address logged
|
||||
as themselves, the host's IPv6 address and `127.0.0.1` as `172.18.0.1`.
|
||||
|
||||
### Full backup and restore
|
||||
|
||||
@@ -985,8 +1002,9 @@ the same way as the nginx/Caddy fragments:
|
||||
docker compose -f docker-compose.yml -f antispam/docker-compose.antispam.yml up -d
|
||||
```
|
||||
|
||||
The milter sees the real client IP, HELO and PTR — unlike the upstream, which
|
||||
only sees SelfPost. Default action is fail-open (`accept`) so a down sidecar
|
||||
The milter sees the real client IP (over IPv4; see [Rate limiting — level
|
||||
1](#rate-limiting--level-1-ip-backstop) for IPv6), HELO and PTR — unlike the
|
||||
upstream, which only sees SelfPost. Default action is fail-open (`accept`) so a down sidecar
|
||||
does not block backup-MX; set `INBOUND_ANTISPAM_MILTER_ACTION=tempfail` to
|
||||
refuse with a temporary error instead, so the sending server tries again.
|
||||
|
||||
@@ -1283,6 +1301,9 @@ application: when enabled, list one or more client IPs that may **submit** mail
|
||||
as that application; a session from any other address is refused with a 4xx at
|
||||
`MAIL FROM`. When disabled, any client IP is allowed. This is independent of
|
||||
rate limits, and it takes exact addresses only — CIDR ranges are not parsed.
|
||||
An application that connects over IPv6 is seen from the Docker network's
|
||||
gateway, not its own address (see [Rate limiting — level
|
||||
1](#rate-limiting--level-1-ip-backstop)).
|
||||
|
||||
It is **not an authentication boundary.** The SASL login itself still succeeds;
|
||||
the check runs in the journal-milter, on the same fail-open path as level 2
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
|
||||
| from-header-binding | Hold a message's `From:` header against the application that sends it | shipped `2.4.0` | 9/9 | [plans/from-header-binding.md](plans/from-header-binding.md) |
|
||||
| panel-redesign | Panel redesign (accepted mockups, design contract) | shipped `2.0.0` | 27/27 | [plans/panel-redesign.md](plans/panel-redesign.md) |
|
||||
| schema-squash | Squash SQLite migrations into a 2.x baseline | built in panel-redesign (stage 1) | — | [plans/panel-redesign.md](plans/panel-redesign.md) |
|
||||
| audit-2026-10-12 | Follow-ups of the 2026-10-12 full-tree code audit, seven stages | in progress (stage 0 shipped `2.4.2`, stages 1+2 `2.5.0`, 2b `2.5.1`; next stage 2c → `2.5.2`) | 45/81 | [audit-2026-10-12.md](audit-2026-10-12.md) |
|
||||
| audit-2026-10-12 | Follow-ups of the 2026-10-12 full-tree code audit, seven stages | in progress (stage 0 shipped `2.4.2`, stages 1+2 `2.5.0`, 2b `2.5.1`; next stage 2c → `2.5.2`) | 46/81 | [audit-2026-10-12.md](audit-2026-10-12.md) |
|
||||
|
||||
**Recommended order** (not binding; owner, 2026-10-09: feature order follows
|
||||
development convenience): **panel-redesign** shipped as `2.0.0` (2026-10-11) and
|
||||
|
||||
+3
-1
@@ -452,7 +452,9 @@ deferred item from the roadmap.
|
||||
DKIM to pass.
|
||||
- **The filter lists are files readable by anyone on the host.**
|
||||
`/data/antispam` is 0755 and its six files 0644, unlike the rest of `/data`:
|
||||
the filter runs under another user in another container. They hold sender
|
||||
the filter runs under another user in another container (rspamd as
|
||||
`_rspamd`, uid 11333, in `rspamd/rspamd:3.11.1-3`; the panel is uid 999 -
|
||||
measured on a live server, 2026-10-15). They hold sender
|
||||
addresses, domains and networks - who the operator blocks and whom he lets
|
||||
through - but no message data. The inbound journal itself is in the
|
||||
database, 0640. Trigger to revisit: a shared host, where the sidecar should
|
||||
|
||||
@@ -84,7 +84,10 @@ type feedbackXML struct {
|
||||
// and eight megabytes of it (maxAggregateXML) fit some 160 000 empty records or
|
||||
// one name of eight megabytes - every one of them a row or a table cell later.
|
||||
// A real report has tens of records, a few thousand from the largest
|
||||
// receivers, and no field longer than a line.
|
||||
// receivers, and no field longer than a line - an estimate: the live
|
||||
// installation had received one report by 2026-10-15, of one record, which
|
||||
// says nothing yet about the largest (assumptions C-M8: keep this ten times
|
||||
// above the largest seen).
|
||||
const (
|
||||
maxReportRecords = 5000
|
||||
maxFieldLen = 255
|
||||
|
||||
@@ -11,7 +11,10 @@ import (
|
||||
// the connecting client IP. When the application has no IP restriction the
|
||||
// check passes. When it has one and the client's address is not known, the
|
||||
// check fails: an address list that an unknown address satisfies is no list.
|
||||
// Store errors are fail-open (see overLimit).
|
||||
// Store errors are fail-open (see overLimit). The address is the one Postfix
|
||||
// sees in the container: a client's own over IPv4, the Docker network's
|
||||
// gateway for every client over IPv6 (measured on a live server, 2026-10-15;
|
||||
// assumptions C-K5).
|
||||
func (s *session) authIPAllowed() bool {
|
||||
if s.login == "" {
|
||||
return true
|
||||
|
||||
@@ -148,7 +148,12 @@ func (s *Store) SendLogLines(row SendLogRow, lifetime time.Duration, n int) ([]S
|
||||
// received, whose row the journal milter has yet to write.
|
||||
//
|
||||
// The send log is asked once per queue id, not once per line: the ids are read
|
||||
// off the index, and a message is several lines.
|
||||
// off the index, and a message is several lines. It is the longest write the
|
||||
// panel makes: measured on the test server, 2026-10-15 (2 cores; 1.37
|
||||
// million lines, 120 days at 1 900 messages a day, timed through this
|
||||
// package), 1.9 s to delete 342 000 lines after a 30-day gap, 0.4 s with
|
||||
// nothing to delete - inside busy_timeout's 5 s (Open), which dmarc-ingest's
|
||||
// writes wait through: the longest waited 1.8 s (assumptions D-S7).
|
||||
func (s *Store) PruneSendLogLines(cutoff time.Time) (int64, error) {
|
||||
res, err := s.db.Exec(
|
||||
`DELETE FROM send_log_lines
|
||||
|
||||
@@ -22,7 +22,11 @@
|
||||
the two-line wordmark, which stops resolving below 32px. The PNG is declared
|
||||
first and the SVG second: a browser takes the last icon whose type it
|
||||
understands, so vector wins wherever it is supported and the raster catches
|
||||
the rest. */}}
|
||||
the rest. HTML links.html#rel-icon leaves "most appropriate" to the
|
||||
browser, so measured, 2026-10-15, by which file was requested: Edge 155
|
||||
(Chromium) takes the SVG, the Claude desktop app's embedded browser
|
||||
(Chromium 152) the PNG, and adding sizes="any" to the SVG changes
|
||||
neither. Firefox and Safari not measured (assumptions D-B16). */}}
|
||||
<link rel="icon" href="/static/favicon.png" sizes="32x32">
|
||||
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/static/bulma.min.css">
|
||||
|
||||
Reference in New Issue
Block a user