Skip to content

Commit e5f8c16

Browse files
authored
tile,config: serve redacted config export on the log index page (#8)
Add config.Config.Redacted(), a public-safe view of the running configuration built as an allowlist: only fields that are neither secrets, filesystem paths, nor internal bind addresses are copied across, so a newly added field is excluded by default rather than leaking by accident. Dropped: data_dir, all seed/key/TLS paths, every *_path field, and the acme/monitoring/metrics/sign-subtree listen addresses (only external_url values are exposed). cmd/cactus marshals cfg.Redacted() and hands the JSON to the tile server via WithConfigJSON; the read-path server stays decoupled from config and just serves the bytes at GET /config (404 when unset). The browser UI gains a collapsible Configuration panel that fetches and displays it. Tests: a leak guard fills every secret/path/listen field with a sentinel and asserts none survive redaction; tile tests cover /config enabled and disabled.
1 parent ff9c6bf commit e5f8c16

7 files changed

Lines changed: 309 additions & 4 deletions

File tree

cmd/cactus/main.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828
"syscall"
2929
"time"
3030

31+
"encoding/json"
3132
"encoding/pem"
3233
"strings"
3334

@@ -361,6 +362,13 @@ func run(cfg config.Config, logger *slog.Logger) error {
361362
MaxHeaderBytes: 16 * 1024,
362363
}
363364
tileSrv := tile.New(l, fsRoot).WithLandmarks(landmarkSeq)
365+
// Expose a redacted (no paths, no secrets) export of the running config
366+
// on the log's browser UI. Marshal failures are non-fatal: just skip it.
367+
if cfgJSON, err := json.MarshalIndent(cfg.Redacted(), "", " "); err != nil {
368+
logger.Warn("could not marshal redacted config for /config endpoint", "err", err)
369+
} else {
370+
tileSrv = tileSrv.WithConfigJSON(cfgJSON)
371+
}
364372
monMux := http.NewServeMux()
365373
monMux.HandleFunc("/ca-certificate", func(w http.ResponseWriter, r *http.Request) {
366374
w.Header().Set("Content-Type", "application/pem-certificate-chain")

config/config_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package config
22

33
import (
4+
"encoding/json"
45
"os"
56
"path/filepath"
7+
"strings"
68
"testing"
79
)
810

@@ -73,3 +75,69 @@ func TestValidationErrors(t *testing.T) {
7375
})
7476
}
7577
}
78+
79+
// TestRedactedOmitsSecretsAndPaths fills every secret/path-bearing field with a
80+
// recognizable sentinel, then asserts none of those sentinels survive into the
81+
// JSON of Redacted(). Because Redacted() is an allowlist, this also guards
82+
// against a future path/secret field being copied across by mistake.
83+
func TestRedactedOmitsSecretsAndPaths(t *testing.T) {
84+
const secret = "SENSITIVE-DO-NOT-LEAK"
85+
c := Config{
86+
DataDir: "/var/lib/" + secret,
87+
CACosigner: CosignerConfig{
88+
ID: "id-ca",
89+
Algorithm: "mldsa-44",
90+
SeedPath: "keys/" + secret,
91+
},
92+
ACME: ACMEConfig{
93+
Listen: secret + ":14000",
94+
ExternalURL: "https://example.test",
95+
TLSCert: "tls/" + secret + ".crt",
96+
TLSKey: "tls/" + secret + ".key",
97+
ChallengeMode: "auto-pass",
98+
},
99+
Monitoring: ListenerConfig{
100+
Listen: secret + ":14080",
101+
ExternalURL: "https://mon.test",
102+
},
103+
Metrics: MetricsConfig{Listen: secret + ":14090"},
104+
CACosignerQuorum: CACosignerQuorum{
105+
Mirrors: []MirrorEndpointConfig{{
106+
ID: "id-mirror",
107+
URL: "https://mirror.test",
108+
Algorithm: "mldsa-44",
109+
PublicKeyPath: "keys/" + secret,
110+
}},
111+
MinSignatures: 1,
112+
},
113+
Mirror: MirrorConfig{
114+
Enabled: true,
115+
CosignerID: "id-mirror-cosigner",
116+
Algorithm: "mldsa-44",
117+
SeedPath: "keys/" + secret,
118+
Upstream: UpstreamConfig{
119+
TileURL: "https://upstream.test",
120+
LogID: "id-log",
121+
CACosignerID: "id-upstream-ca",
122+
CACosignerKeyPath: "keys/" + secret,
123+
PollIntervalMS: 1000,
124+
},
125+
SignSubtreeListen: secret + ":14070",
126+
SignSubtreePath: "/sign-subtree",
127+
},
128+
}
129+
130+
out, err := json.Marshal(c.Redacted())
131+
if err != nil {
132+
t.Fatalf("marshal redacted config: %v", err)
133+
}
134+
if strings.Contains(string(out), secret) {
135+
t.Fatalf("redacted config leaked a secret/path sentinel:\n%s", out)
136+
}
137+
138+
// Sanity: a non-sensitive field still comes through, so the test would
139+
// actually catch a leak rather than passing on an empty result.
140+
if !strings.Contains(string(out), "https://example.test") {
141+
t.Fatalf("redacted config dropped a public field; got:\n%s", out)
142+
}
143+
}

config/redacted.go

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
package config
2+
3+
// RedactedConfig is a public-safe view of Config, suitable for serving on the
4+
// log's HTML index page. It is built by an *allowlist*: only fields that are
5+
// neither secrets nor filesystem paths are copied across. A newly added field
6+
// is therefore excluded by default — it has to be added here explicitly to be
7+
// exposed — so a future secret can't leak by accident. Everything path-like is
8+
// intentionally absent: data_dir, ca_cosigner.seed_path, acme.tls_cert /
9+
// tls_key, every *.public_key_path / ca_cosigner_key_path, mirror.seed_path.
10+
//
11+
// Internal bind addresses (acme.listen, monitoring.listen, metrics.listen,
12+
// mirror.sign_subtree_listen) are also dropped; only the public external_url
13+
// values are exposed. sign_subtree_path is an HTTP route, not a bind address
14+
// or filesystem path, so it is kept. The metrics section has nothing left to
15+
// expose once its listen address is dropped, so it is omitted entirely.
16+
type RedactedConfig struct {
17+
Log LogConfig `json:"log"`
18+
CACosigner RedactedCosigner `json:"ca_cosigner"`
19+
CACosignerQuorum RedactedQuorum `json:"ca_cosigner_quorum"`
20+
ACME RedactedACME `json:"acme"`
21+
Monitoring RedactedListener `json:"monitoring"`
22+
Landmarks LandmarkConfig `json:"landmarks"`
23+
Mirror RedactedMirror `json:"mirror"`
24+
LogLevel string `json:"log_level"`
25+
}
26+
27+
// RedactedCosigner drops CosignerConfig.SeedPath.
28+
type RedactedCosigner struct {
29+
ID string `json:"id"`
30+
Algorithm string `json:"algorithm"`
31+
}
32+
33+
// RedactedACME drops ACMEConfig.TLSCert, TLSKey, and the internal Listen
34+
// address.
35+
type RedactedACME struct {
36+
ExternalURL string `json:"external_url"`
37+
ChallengeMode string `json:"challenge_mode"`
38+
}
39+
40+
// RedactedListener drops ListenerConfig.Listen (the internal bind address).
41+
type RedactedListener struct {
42+
ExternalURL string `json:"external_url"`
43+
}
44+
45+
// RedactedQuorum mirrors CACosignerQuorum but with redacted endpoints.
46+
type RedactedQuorum struct {
47+
Mirrors []RedactedMirrorEndpoint `json:"mirrors"`
48+
MinSignatures int `json:"min_signatures"`
49+
RequestTimeoutMS int `json:"request_timeout_ms"`
50+
BestEffortAfterMinimum bool `json:"best_effort_after_minimum"`
51+
MirrorRetryDeadlineMS int `json:"mirror_retry_deadline_ms"`
52+
}
53+
54+
// RedactedMirrorEndpoint drops MirrorEndpointConfig.PublicKeyPath.
55+
type RedactedMirrorEndpoint struct {
56+
ID string `json:"id"`
57+
URL string `json:"url"`
58+
Algorithm string `json:"algorithm"`
59+
}
60+
61+
// RedactedMirror drops MirrorConfig.SeedPath and the internal
62+
// SignSubtreeListen bind address.
63+
type RedactedMirror struct {
64+
Enabled bool `json:"enabled"`
65+
CosignerID string `json:"cosigner_id"`
66+
Algorithm string `json:"algorithm"`
67+
Upstream RedactedUpstream `json:"upstream"`
68+
SignSubtreePath string `json:"sign_subtree_path"`
69+
RequireCASignatureOnSubtree bool `json:"require_ca_signature_on_subtree"`
70+
}
71+
72+
// RedactedUpstream drops UpstreamConfig.CACosignerKeyPath.
73+
type RedactedUpstream struct {
74+
TileURL string `json:"tile_url"`
75+
LogID string `json:"log_id"`
76+
CACosignerID string `json:"ca_cosigner_id"`
77+
PollIntervalMS int `json:"poll_interval_ms"`
78+
}
79+
80+
// Redacted returns the public-safe view of c. See RedactedConfig.
81+
func (c Config) Redacted() RedactedConfig {
82+
rc := RedactedConfig{
83+
Log: c.Log,
84+
CACosigner: RedactedCosigner{ID: c.CACosigner.ID, Algorithm: c.CACosigner.Algorithm},
85+
CACosignerQuorum: RedactedQuorum{
86+
MinSignatures: c.CACosignerQuorum.MinSignatures,
87+
RequestTimeoutMS: c.CACosignerQuorum.RequestTimeoutMS,
88+
BestEffortAfterMinimum: c.CACosignerQuorum.BestEffortAfterMinimum,
89+
MirrorRetryDeadlineMS: c.CACosignerQuorum.MirrorRetryDeadlineMS,
90+
},
91+
ACME: RedactedACME{
92+
ExternalURL: c.ACME.ExternalURL,
93+
ChallengeMode: c.ACME.ChallengeMode,
94+
},
95+
Monitoring: RedactedListener{ExternalURL: c.Monitoring.ExternalURL},
96+
Landmarks: c.Landmarks,
97+
Mirror: RedactedMirror{
98+
Enabled: c.Mirror.Enabled,
99+
CosignerID: c.Mirror.CosignerID,
100+
Algorithm: c.Mirror.Algorithm,
101+
SignSubtreePath: c.Mirror.SignSubtreePath,
102+
RequireCASignatureOnSubtree: c.Mirror.RequireCASignatureOnSubtree,
103+
Upstream: RedactedUpstream{
104+
TileURL: c.Mirror.Upstream.TileURL,
105+
LogID: c.Mirror.Upstream.LogID,
106+
CACosignerID: c.Mirror.Upstream.CACosignerID,
107+
PollIntervalMS: c.Mirror.Upstream.PollIntervalMS,
108+
},
109+
},
110+
LogLevel: c.LogLevel,
111+
}
112+
for _, m := range c.CACosignerQuorum.Mirrors {
113+
rc.CACosignerQuorum.Mirrors = append(rc.CACosignerQuorum.Mirrors, RedactedMirrorEndpoint{
114+
ID: m.ID,
115+
URL: m.URL,
116+
Algorithm: m.Algorithm,
117+
})
118+
}
119+
return rc
120+
}

tile/app.js

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,25 @@ async function loadCheckpoint() {
101101
}
102102
}
103103

104+
// ---- config -------------------------------------------------------------
105+
106+
// Fetch the redacted config export (see config.Config.Redacted) and show it in
107+
// the Configuration panel. The endpoint is optional, so hide the panel if it
108+
// isn't served or the fetch fails.
109+
async function loadConfig() {
110+
const panel = $("configpanel");
111+
if (!panel) return;
112+
try {
113+
const r = await fetch("config", { cache: "no-store" });
114+
if (!r.ok) { panel.style.display = "none"; return; }
115+
const text = await r.text();
116+
$("configout").textContent = text;
117+
panel.style.display = "";
118+
} catch {
119+
panel.style.display = "none";
120+
}
121+
}
122+
104123
// ---- tile browser -------------------------------------------------------
105124

106125
function tileChip(level, index, width) {
@@ -815,6 +834,7 @@ function init() {
815834

816835
populateLevels(); // seed the dropdown before the first checkpoint loads
817836
loadCheckpoint();
837+
loadConfig();
818838
}
819839

820840
// Auto-run in the browser; stay inert under Bun (which sets the test flag
@@ -824,7 +844,7 @@ if (typeof document !== "undefined" && !globalThis.__CACTUS_TEST__) init();
824844
if (typeof module !== "undefined" && module.exports) {
825845
module.exports = {
826846
TILE_W, formatTileIndex, tileURL, treeHeight, tileLevels, tileWidth,
827-
loadCheckpoint, renderTileLists, populateLevels, selectAndFetch, drillDown,
847+
loadCheckpoint, loadConfig, renderTileLists, populateLevels, selectAndFetch, drillDown,
828848
openEntry, fetchTile, renderHashTile, splitDataTile, renderEntriesTile, fetchEntry,
829849
DER, tagName, decodeOID, oidName, decodeDN, decodeSAN, hexPreview,
830850
previewInteger, formatTime, previewPrimitive, derNode, derForest,

tile/index.html

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,15 @@ <h2>Entry viewer</h2>
173173
</div>
174174
</div>
175175

176+
<div class="panel" id="configpanel" style="display:none">
177+
<details>
178+
<summary style="cursor:pointer"><span style="color:var(--accent)">Configuration</span></summary>
179+
<p class="hint" style="margin-top:.6rem">Redacted export of the running server
180+
configuration — filesystem paths and key material are omitted.</p>
181+
<div id="configout" class="out"></div>
182+
</details>
183+
</div>
184+
176185
<p class="note">cactus monitoring read-path · tlog-tiles layout. This is a test
177186
server; see the threat model before trusting anything here.</p>
178187
</div>

tile/server.go

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,10 @@ var appJS []byte
3232

3333
// Server is the read-path HTTP handler.
3434
type Server struct {
35-
log *log.Log
36-
fs storage.FS
37-
landmarks *landmark.Sequence // optional; nil disables /landmarks
35+
log *log.Log
36+
fs storage.FS
37+
landmarks *landmark.Sequence // optional; nil disables /landmarks
38+
configJSON []byte // optional; nil disables /config
3839
}
3940

4041
// New returns a Server backed by l and fs.
@@ -49,20 +50,34 @@ func (s *Server) WithLandmarks(seq *landmark.Sequence) *Server {
4950
return s
5051
}
5152

53+
// WithConfigJSON attaches a redacted, public-safe JSON export of the running
54+
// configuration (see config.Config.Redacted) so the server exposes it at
55+
// /config and the browser UI can display it. The bytes are served verbatim,
56+
// so the caller is responsible for the redaction; passing nil leaves /config
57+
// disabled.
58+
func (s *Server) WithConfigJSON(j []byte) *Server {
59+
s.configJSON = j
60+
return s
61+
}
62+
5263
// Handler returns the HTTP handler. Routes:
5364
//
5465
// GET / — browser UI (index.html)
5566
// GET /app.js — browser UI logic
5667
// GET /checkpoint — latest signed note
5768
// GET /tile/<L>/<NNN..> — hash tiles (c2sp tlog-tiles)
5869
// GET /tile/entries/<NNN..> — entry (data) tiles (c2sp tlog-tiles)
70+
// GET /config — redacted config JSON (only if WithConfigJSON)
5971
// GET /landmarks — §6.3.1 landmark list (only if WithLandmarks)
6072
func (s *Server) Handler() http.Handler {
6173
mux := http.NewServeMux()
6274
mux.HandleFunc("GET /{$}", s.handleIndex)
6375
mux.HandleFunc("GET /app.js", s.handleAppJS)
6476
mux.HandleFunc("GET /checkpoint", s.handleCheckpoint)
6577
mux.HandleFunc("GET /tile/", s.handleTile)
78+
if s.configJSON != nil {
79+
mux.HandleFunc("GET /config", s.handleConfig)
80+
}
6681
if s.landmarks != nil {
6782
mux.Handle("GET /landmarks", s.landmarks.Handler())
6883
mux.Handle("HEAD /landmarks", s.landmarks.Handler())
@@ -93,6 +108,12 @@ func (s *Server) handleCheckpoint(w http.ResponseWriter, r *http.Request) {
93108
w.Write(cp.SignedNote)
94109
}
95110

111+
func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
112+
w.Header().Set("Content-Type", "application/json; charset=utf-8")
113+
w.Header().Set("Cache-Control", "no-cache, max-age=0")
114+
w.Write(s.configJSON)
115+
}
116+
96117
func (s *Server) handleTile(w http.ResponseWriter, r *http.Request) {
97118
// Path format (c2sp tlog-tiles): /tile/<L>/NNN[.p/W] for hash tiles,
98119
// /tile/entries/NNN[.p/W] for entry (data) tiles.

0 commit comments

Comments
 (0)