501:func (r *queryResolver) JobMetrics(ctx context.Context, id string, metrics []string, scopes []schema.MetricScope, resolution *int) ([]*model.JobMetricWithName, error) {
514:		cclog.Warn("Error while querying job for metrics")
518:	data, err := metricdispatch.LoadData(job, metrics, scopes, ctx, *resolution)
539:func (r *queryResolver) JobStats(ctx context.Context, id string, metrics []string) ([]*model.NamedStats, error) {
546:	data, err := metricdispatch.LoadJobStats(job, metrics, ctx)
564:func (r *queryResolver) ScopedJobStats(ctx context.Context, id string, metrics []string, scopes []schema.MetricScope) ([]*model.NamedStatsWithScope, error) {
571:	data, err := metricdispatch.LoadScopedJobStats(job, metrics, scopes, ctx)
646:// JobsStatistics is the resolver for the jobsStatistics field.
647:func (r *queryResolver) JobsStatistics(ctx context.Context, filter []*model.JobFilter, metrics []string, page *model.PageRequest, sortBy *model.SortByAggregate, groupBy *model.Aggregate, numDurationBins *string, numMetricBins *int) ([]*model.JobsStatistics, error) {
649:	var stats []*model.JobsStatistics
685:				var allStats []*model.JobsStatistics
686:				allStats, err = cache.getOrCompute(key, func() ([]*model.JobsStatistics, error) {
698:		stats = make([]*model.JobsStatistics, 0, 1)
699:		stats = append(stats, &model.JobsStatistics{})
749:			stats[0], err = r.Repo.AddMetricHistograms(ctx, filter, metrics, stats[0], numMetricBins)
762:func (r *queryResolver) JobsMetricStats(ctx context.Context, filter []*model.JobFilter, metrics []string) ([]*model.JobStats, error) {
778:		data, err := metricdispatch.LoadJobStats(job, metrics, ctx)
812:func (r *queryResolver) JobsFootprints(ctx context.Context, filter []*model.JobFilter, metrics []string) (*model.Footprints, error) {
814:	return r.jobsFootprints(ctx, filter, metrics)
823:func (r *queryResolver) NodeMetrics(ctx context.Context, cluster string, nodes []string, scopes []schema.MetricScope, metrics []string, from time.Time, to time.Time) ([]*model.NodeMetrics, error) {


736			metricHisto, err := r.jobsMetricStatisticsHistogram(ctx, m, filter, targetBinCount)
737			if err != nil {
738				cclog.Warnf("Error while loading job metric statistics histogram: %s", m)
739				continue
740			}
741			stat.HistMetrics = append(stat.HistMetrics, metricHisto)
742		}
743
744		cclog.Debugf("Timer AddMetricHistograms %s", time.Since(start))
745		return stat, nil
746	}
747
748	// jobsStatisticsHistogram generates a simple histogram by grouping on a column value.
749	//
750	// Used for histograms where the column value directly represents the bin (e.g., node count, core count).
751	// Unlike duration/metric histograms, this doesn't pre-initialize bins with zeros.
752	//
753	// Parameters:
754	//   - value: SQL expression that produces the histogram value, aliased as "value"
755	//   - filters: Job filters to apply
756	//
757	// Returns histogram points with Value (from column) and Count (number of jobs).
758	func (r *JobRepository) jobsStatisticsHistogram(
759		ctx context.Context,
760		value string,
761		filters []*model.JobFilter,
762	) ([]*model.HistoPoint, error) {
763		start := time.Now()
764		query, qerr := SecurityCheck(ctx,
765			sq.Select(value, "COUNT(*) AS count").From("job"))
766
767		if qerr != nil {
768			return nil, qerr
769		}
770
771		for _, f := range filters {
772			query = BuildWhereClause(f, query)
773		}
774
775		rows, err := query.GroupBy("value").RunWith(r.DB).QueryContext(ctx)

		metricHisto, err := r.jobsMetricStatisticsHistogram(ctx, m, filter, targetBinCount)
		if err != nil {
			cclog.Warnf("Error while loading job metric statistics histogram: %s", m)
			continue
		}
		stat.HistMetrics = append(stat.HistMetrics, metricHisto)
	}

	cclog.Debugf("Timer AddMetricHistograms %s", time.Since(start))
	return stat, nil
}

// jobsStatisticsHistogram generates a simple histogram by grouping on a column value.
//
// Used for histograms where the column value directly represents the bin (e.g., node count, core count).
// Unlike duration/metric histograms, this doesn't pre-initialize bins with zeros.
//
// Parameters:
//   - value: SQL expression that produces the histogram value, aliased as "value"
//   - filters: Job filters to apply
//
// Returns histogram points with Value (from column) and Count (number of jobs).
func (r *JobRepository) jobsStatisticsHistogram(
	ctx context.Context,
	value string,
	filters []*model.JobFilter,
) ([]*model.HistoPoint, error) {
	start := time.Now()
	query, qerr := SecurityCheck(ctx,
		sq.Select(value, "COUNT(*) AS count").From("job"))

	if qerr != nil {
		return nil, qerr
	}

	for _, f := range filters {
		query = BuildWhereClause(f, query)
	}

	rows, err := query.GroupBy("value").RunWith(r.DB).QueryContext(ctx)


I did a security review with Opus 4.7 . Fixes are already applied. Repeat the audit and check if anything severe was missed in the previous audit


710	// Limitations:
711	//   - Running jobs: Limited to 5000 jobs for performance
712	//   - Requires valid cluster configuration with metric peak values
713	//   - Uses footprint statistic (avg/max/min) configured per metric
714	func (r *JobRepository) AddMetricHistograms(
715		ctx context.Context,
716		filter []*model.JobFilter,
717		metrics []string,
718		stat *model.JobsStatistics,
719		targetBinCount *int,
720	) (*model.JobsStatistics, error) {
721		start := time.Now()
722
723		// Running Jobs Only: First query jobdata from sqlite, then query data and make bins
724		for _, f := range filter {
725			if f.State != nil {
726				if len(f.State) == 1 && f.State[0] == "running" {
727					stat.HistMetrics = r.runningJobsMetricStatisticsHistogram(ctx, metrics, filter, targetBinCount)
728					cclog.Debugf("Timer AddMetricHistograms %s", time.Since(start))
729					return stat, nil
730				}
731			}
732		}
733
734		// All other cases: Query and make bins in sqlite directly
735		for _, m := range metrics {
736			metricHisto, err := r.jobsMetricStatisticsHistogram(ctx, m, filter, targetBinCount)

// Limitations:
//   - Running jobs: Limited to 5000 jobs for performance
//   - Requires valid cluster configuration with metric peak values
//   - Uses footprint statistic (avg/max/min) configured per metric
func (r *JobRepository) AddMetricHistograms(
	ctx context.Context,
	filter []*model.JobFilter,
	metrics []string,
	stat *model.JobsStatistics,
	targetBinCount *int,
) (*model.JobsStatistics, error) {
	start := time.Now()

	// Running Jobs Only: First query jobdata from sqlite, then query data and make bins
	for _, f := range filter {
		if f.State != nil {
			if len(f.State) == 1 && f.State[0] == "running" {
				stat.HistMetrics = r.runningJobsMetricStatisticsHistogram(ctx, metrics, filter, targetBinCount)
				cclog.Debugf("Timer AddMetricHistograms %s", time.Since(start))
				return stat, nil
			}
		}
	}

	// All other cases: Query and make bins in sqlite directly
	for _, m := range metrics {
		metricHisto, err := r.jobsMetricStatisticsHistogram(ctx, m, filter, targetBinCount)


Both critical findings confirmed at the code level. Let me do a final SQLite PoC to confirm the metric-name injection actually breaks out (SQLite's double-quote-as-string-literal quirk).


Bash

cd /tmp && sqlite3 :memory: <<'EOF'
CREATE TABLE job(footprint TEXT, secret TEXT);
INSERT INTO job VALUES('{"cpu_avg":5}', 'TOP-SECRET-DATA');
-- Benign query shape: json_extract(footprint, "$.<metric>")
-- Inject metric = cpu_avg") UNION SELECT secret,1 FROM job--
SELECT json_extract(footprint, "$.cpu_avg") AS v, 1 FROM job
UNION SELECT secret,1 FROM job;
EOF

Confirm SQLite double-quote breakout exfiltration


5|1
TOP-SECRET-DATA|1
Shell cwd was reset to /Users/jan/prg/ClusterCockpit/cc-backend


Both criticals fully confirmed with working PoCs. Let me quickly verify the third finding (path traversal sink) before writing up.


sed -n '240,260p' pkg/metricstore/walCheckpoint.go; echo "=== DecodeLine cluster/host handling ==="; grep -n "cluster\|host\|Cluster\|Host" pkg/metricstore/lineprotocol.go | sed -n '1,25p'

Verify checkpoint path sink and ingestion validation


				ws = &walFileState{f: f, w: w, size: fileSize}
				hostFiles[hostDir] = ws
				return ws
			}

			processMsg := func(msg *WALMessage) {
				hostDir := path.Join(Keys.Checkpoints.RootDir, msg.Cluster, msg.Node)
				ws := getOrOpenWAL(hostDir)
				if ws == nil {
					return
				}

				// Enforce max WAL size: force-rotate before writing if limit is exceeded.
				// The in-memory store still holds the data; only crash-recovery coverage is lost.
				if maxSize := Keys.Checkpoints.MaxWALSize; maxSize > 0 && ws.size >= maxSize {
					cclog.Warnf("[METRICSTORE]> WAL: force-rotating %s (size %d >= limit %d)",
						hostDir, ws.size, maxSize)
					ws.w.Flush()
					ws.f.Close()
					walPath := path.Join(hostDir, "current.wal")
=== DecodeLine cluster/host handling ===
9://	<measurement>[,cluster=<c>][,hostname=<h>][,type=<t>][,type-id=<id>][,stype=<s>][,stype-id=<id>] value=<v> [<timestamp>]
12:// routing information (cluster, host) and optional sub-device selectors (type,
29:	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
30:	"github.com/ClusterCockpit/cc-lib/v2/nats"
31:	"github.com/ClusterCockpit/cc-lib/v2/schema"
32:	"github.com/ClusterCockpit/cc-line-protocol/v2/lineprotocol"
60:		clusterTag := sc.ClusterTag
69:						if err := DecodeLine(dec, ms, clusterTag); err != nil {
85:				if err := DecodeLine(dec, ms, clusterTag); err != nil {
180:// clusterDefault is used as the cluster name for lines that do not carry a
181:// "cluster" tag. Callers typically supply the ClusterTag value from the NATS
186://   - The Level pointer (host-level node in the metric tree) is cached across
187://     consecutive lines that share the same cluster+host pair to avoid
188://     repeated lock acquisitions on the root and cluster levels.
201:	clusterDefault string,
210:	// cluster and host. By using `WriteToLevel` (level = host), we do not need
211:	// to take the root- and cluster-level lock as often.
213:	prevCluster, prevHost := "", ""
233:		cluster, host := clusterDefault, ""
245:			case "cluster":
246:				if string(val) == prevCluster {
247:					cluster = prevCluster
249:					cluster = string(val)
252:			case "hostname", "host":
253:				if string(val) == prevHost {


All three top findings are now verified — two with working PoCs, the third by code inspection. Here's the audit result.

## Repeat security audit — verdict

**Yes, the previous (Opus 4.7) audit missed severe issues — including two that I'd rate higher than anything it fixed.** There's a clear pattern: the prior audit fixed *one instance* of a bug class but missed a *sibling code path* with the same (or worse) flaw.

- It fixed JWT algorithm/issuer confusion in `jwtCookieSession.go` — but missed the **sibling HMAC authenticator** `jwtSession.go`, which has an empty-key forgery bug.
- It added `validatePathComponent` for the `machine_state` REST handlers — but missed the **metric-ingestion path** that builds filesystem paths from the same kind of input.

---

### 🔴 CRITICAL 1 — Unauthenticated admin takeover via empty-key HMAC JWT (`internal/auth/jwtSession.go`)

`JWTSessionAuthenticator.Init()` (line 27) returns `nil` even when `CROSS_LOGIN_JWT_HS512_KEY` is unset, so the authenticator is **registered with `loginTokenKey == nil`** whenever the `jwts` config block exists (confirmed at `auth.go:233-238`). In `Login()` (line 61-66) the keyfunc returns that nil key for HS256/HS512 tokens. golang-jwt v5.3.1 happily verifies an HMAC signature against an empty key.

I confirmed with a PoC against the pinned v5.3.1:
```
forged token: eyJhbGciOiJIUzUxMiIs...
RESULT: FORGED TOKEN ACCEPTED sub=attacker roles=[admin]
```
`CanLogin` accepts any request with an `Authorization` header or `?login-token=`, and with the default `validate-user: false`, `getUserFromJWT` builds the user — **including roles — straight from the attacker's claims** (`jwtHelpers.go:125-137`).

**Exploit:** unauthenticated attacker forges `HS512({"sub":"x","roles":["admin"]})` signed with an empty key, sends it as `Authorization: Bearer …`, and receives a full admin session. With `validate-user: true` it still allows impersonation of any existing user. Precondition (JWT configured but no HS512 cross-login key) is a common deployment.

**Fix:** `Init()` must `return error` when the key is empty (so it isn't registered), and the keyfunc should reject `len(loginTokenKey)==0` — mirroring how `jwtCookieSession.go` already guards its prerequisites.

---

### 🔴 CRITICAL 2 — SQL injection via GraphQL metric name (`internal/repository/stats.go:959-969`, `jobQuery.go:343`)

Metric names from the GraphQL `[String!]` args (`jobsStatistics`/`jobsMetricStats` `metrics`, and `JobFilter.metricStats.metricName`) are interpolated **raw** into a `json_extract(footprint, "$.<name>")` SQL string. No whitelist exists anywhere in the chain (`metrics` → `AddMetricHistograms` → `jobsMetricStatisticsHistogram(ctx, m, …)`, verified). SQLite treats `"$.x"` as a string literal, so a `"` breaks out.

Confirmed exfiltration PoC in SQLite:
```
metric = cpu_avg") UNION SELECT secret,1 FROM job--
→ returns TOP-SECRET-DATA
```
Reachable by **any authenticated RoleUser**; `SecurityCheck` only adds a row predicate that `UNION`/`--` discards. Result: arbitrary SQL read across all tables (other users' jobs, `hpc_user` roles/emails).

**Fix:** whitelist metric names against the cluster's configured metric list, plus a strict `^[A-Za-z0-9_]+$` guard at the interpolation sites.

---

### 🟠 HIGH 3 — Arbitrary file write outside checkpoint root via metric ingestion (`pkg/metricstore/lineprotocol.go` → `walCheckpoint.go:246`, `checkpoint.go:251/347`)

`DecodeLine` reads `cluster`/`hostname` line-protocol tags with **no validation** (only the measurement name is whitelisted). They flow verbatim into `path.Join(RootDir, msg.Cluster, msg.Node)` and then `os.MkdirAll` + `os.OpenFile(O_CREATE|O_TRUNC)`. `path.Join` cleans but does **not** confine, so `cluster=../../../../var/spool/cron` escapes the root.

Reachable **unauthenticated via NATS** (`ReceiveNats`) and via `POST /api/write` (RoleAPI). On the next checkpoint flush the server creates/truncates files outside the archive root under the service UID → potential code execution depending on deployment. The fix already exists in-repo (`validatePathComponent`, added by the prior audit for `machine_state`) — it just wasn't applied at ingestion.

**Fix:** validate `cluster`/`host` in `DecodeLine` (reject `..`, `/`, `\`, restrict charset), and add a defense-in-depth root-prefix check at the sinks.

---

### Medium (worth fixing, not release-blocking)

- **No GraphQL complexity/depth limit + `items-per-page=-1` bypasses LIMIT** (`server.go:89`, `jobQuery.go:78`, REST `job.go:170`) — any authenticated user can force an unbounded DB + metric-store load (DoS). Negative values other than `-1` underflow `uint64`.
- **Missing security headers** (`server.go`) — no CSP, `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy` (only HSTS). Enables clickjacking and amplifies any XSS.
- **Stored XSS sink** `{@html job.metaData.message}` (`web/frontend/src/Job.root.svelte:240`, `JobFootprint.svelte:231`) — writable via `/api/jobs/edit_meta` (RoleAPI) or a hand-edited archive `meta.json`; executes in any viewer's browser. Combined with no CSP, unconstrained.
- **Identity values interpolated into SQL** in tag counting (`tags.go:316-333`) — `user.Username`/`user.Projects` concatenated into SQL; low-risk for local accounts but exploitable for OIDC/LDAP-provisioned names containing `"`.
- **CSRF**: state-changing session-auth endpoints rely solely on `SameSite=Lax` (no token / Origin check).

### Low
ORDER BY uses a fragile char *blacklist* not a column allow-list (`jobQuery.go` `toSnakeCase`); default JWT keypair only warns instead of being fatal (`main.go:217`); committed demo secrets/JWT; `AuthViaJWT` skips role validation (`jwt.go:114`); no local-password strength policy.

---

The two CRITICALs (and the HIGH) are the genuinely severe items the prior audit missed, and all three are the "same bug class, different file" pattern noted above — so the fixes are mostly mechanical extensions of what's already there.

Want me to implement the fixes? I'd suggest starting with the three top items (JWT Init guard, metric-name whitelist, ingestion path validation). I can do them as one branch with tests, or one commit per finding — your call.


Fix the three top items


I'll implement the three fixes. Let me read the remaining context I need for fixes 2 and 3.


895	// Returns MetricHistoPoints with metric name, unit, footprint stat type, and binned data.
896	//
897	// Algorithm:
898	//  1. Determine peak value from cluster configuration (filtered cluster or max across all)
899	//  2. Generate SQL that extracts footprint value, normalizes to [0,1], multiplies by bin count
900	//  3. Pre-initialize bins with min/max ranges based on peak value
901	//  4. Query database for counts per bin
902	//  5. Match results to pre-initialized bins
903	//
904	// Special handling: Values exactly equal to peak are forced into the last bin by multiplying
905	// peak by 0.999999999 to avoid creating an extra bin.
906	func (r *JobRepository) jobsMetricStatisticsHistogram(
907		ctx context.Context,
908		metric string,
909		filters []*model.JobFilter,
910		bins *int,
911	) (*model.MetricHistoPoints, error) {
912		// Peak value defines the upper bound for binning: values are distributed across
913		// bins from 0 to peak. First try to get peak from filtered cluster, otherwise
914		// scan all clusters to find the maximum peak value.
915		var metricConfig *schema.MetricConfig
916		var peak float64
917		var unit string
918		var footprintStat string
919
920		// Try to get metric config from filtered cluster
921		for _, f := range filters {
922			if f.Cluster != nil {
923				metricConfig = archive.GetMetricConfig(*f.Cluster.Eq, metric)
924				peak = metricConfig.Peak
925				unit = metricConfig.Unit.Prefix + metricConfig.Unit.Base
926				footprintStat = metricConfig.Footprint
927				cclog.Debugf("Cluster %s filter found with peak %f for %s", *f.Cluster.Eq, peak, metric)
928			}
929		}

// Returns MetricHistoPoints with metric name, unit, footprint stat type, and binned data.
//
// Algorithm:
//  1. Determine peak value from cluster configuration (filtered cluster or max across all)
//  2. Generate SQL that extracts footprint value, normalizes to [0,1], multiplies by bin count
//  3. Pre-initialize bins with min/max ranges based on peak value
//  4. Query database for counts per bin
//  5. Match results to pre-initialized bins
//
// Special handling: Values exactly equal to peak are forced into the last bin by multiplying
// peak by 0.999999999 to avoid creating an extra bin.
func (r *JobRepository) jobsMetricStatisticsHistogram(
	ctx context.Context,
	metric string,
	filters []*model.JobFilter,
	bins *int,
) (*model.MetricHistoPoints, error) {
	// Peak value defines the upper bound for binning: values are distributed across
	// bins from 0 to peak. First try to get peak from filtered cluster, otherwise
	// scan all clusters to find the maximum peak value.
	var metricConfig *schema.MetricConfig
	var peak float64
	var unit string
	var footprintStat string

	// Try to get metric config from filtered cluster
	for _, f := range filters {
		if f.Cluster != nil {
			metricConfig = archive.GetMetricConfig(*f.Cluster.Eq, metric)
			peak = metricConfig.Peak
			unit = metricConfig.Unit.Prefix + metricConfig.Unit.Base
			footprintStat = metricConfig.Footprint
			cclog.Debugf("Cluster %s filter found with peak %f for %s", *f.Cluster.Eq, peak, metric)
		}
	}


330				cclog.Debugf("No known named timeRange: startTime.range = %s", cond.Range)
331				return query
332			}
333			return query.Where(field+" >= ?", then)
334		} else {
335			return query
336		}
337	}
338
339	// buildFloatJSONCondition creates a filter on a numeric field within the footprint JSON column, using BETWEEN only if required.
340	func buildFloatJSONCondition(jsonField string, cond *model.FloatRange, query sq.SelectBuilder) sq.SelectBuilder {
341		query = query.Where("JSON_VALID(footprint)")
342		if cond.From > 0.0 && cond.To > 0.0 {
343			return query.Where("JSON_EXTRACT(footprint, \"$."+jsonField+"\") BETWEEN ? AND ?", cond.From, cond.To)
344		} else if cond.From > 0.0 && cond.To == 0.0 {
345			return query.Where("JSON_EXTRACT(footprint, \"$."+jsonField+"\") >= ?", cond.From)
346		} else if cond.From == 0.0 && cond.To > 0.0 {
347			return query.Where("JSON_EXTRACT(footprint, \"$."+jsonField+"\") <= ?", cond.To)
348		} else {
349			return query
350		}
351	}
352
353	// buildStringCondition creates filters for string fields supporting equality,
354	// inequality, prefix, suffix, substring, and IN list matching.

			cclog.Debugf("No known named timeRange: startTime.range = %s", cond.Range)
			return query
		}
		return query.Where(field+" >= ?", then)
	} else {
		return query
	}
}

// buildFloatJSONCondition creates a filter on a numeric field within the footprint JSON column, using BETWEEN only if required.
func buildFloatJSONCondition(jsonField string, cond *model.FloatRange, query sq.SelectBuilder) sq.SelectBuilder {
	query = query.Where("JSON_VALID(footprint)")
	if cond.From > 0.0 && cond.To > 0.0 {
		return query.Where("JSON_EXTRACT(footprint, \"$."+jsonField+"\") BETWEEN ? AND ?", cond.From, cond.To)
	} else if cond.From > 0.0 && cond.To == 0.0 {
		return query.Where("JSON_EXTRACT(footprint, \"$."+jsonField+"\") >= ?", cond.From)
	} else if cond.From == 0.0 && cond.To > 0.0 {
		return query.Where("JSON_EXTRACT(footprint, \"$."+jsonField+"\") <= ?", cond.To)
	} else {
		return query
	}
}

// buildStringCondition creates filters for string fields supporting equality,
// inequality, prefix, suffix, substring, and IN list matching.


190	//     prevType*/prevSubType* fields because batches typically repeat the same
191	//     sub-device identifiers.
192	//   - Timestamp parsing tries Second precision first; if that fails it retries
193	//     Millisecond, Microsecond, and Nanosecond in turn. A missing timestamp
194	//     falls back to time.Now().
195	//
196	// When the checkpoint format is "wal" each successfully decoded sample is also
197	// sent to WALMessages so the WAL staging goroutine can persist it durably
198	// before the next binary snapshot.
199	func DecodeLine(dec *lineprotocol.Decoder,
200		ms *MemoryStore,
201		clusterDefault string,
202	) error {
203		// Reduce allocations in loop:
204		t := time.Now()
205		metric := Metric{}
206		st := decodeStatePool.Get().(*decodeState)
207		defer decodeStatePool.Put(st)
208
209		// Optimize for the case where all lines in a "batch" are about the same
210		// cluster and host. By using `WriteToLevel` (level = host), we do not need
211		// to take the root- and cluster-level lock as often.
212		var lvl *Level = nil
213		prevCluster, prevHost := "", ""
214
215		var ok bool
216		for dec.Next() {
217			rawmeasurement, err := dec.Measurement()
218			if err != nil {
219				return err
220			}
221
222			// Needs to be copied because another call to dec.* would
223			// invalidate the returned slice.
224			st.metricBuf = append(st.metricBuf[:0], rawmeasurement...)
225
226			// The go compiler optimizes map[string(byteslice)] lookups:
227			metric.MetricConfig, ok = ms.Metrics[string(rawmeasurement)]
228			if !ok {
229				continue
230			}
231
232			st.typeBuf, st.subTypeBuf = st.typeBuf[:0], st.subTypeBuf[:0]
233			cluster, host := clusterDefault, ""
234			for {
235				key, val, err := dec.NextTag()
236				if err != nil {
237					return err
238				}
239				if key == nil {
240					break
241				}
242
243				// The go compiler optimizes string([]byte{...}) == "...":
244				switch string(key) {
245				case "cluster":
246					if string(val) == prevCluster {
247						cluster = prevCluster
248					} else {
249						cluster = string(val)
250						lvl = nil
251					}
252				case "hostname", "host":
253					if string(val) == prevHost {
254						host = prevHost
255					} else {
256						host = string(val)
257						lvl = nil
258					}
259				case "type":
260					if string(val) == "node" {
261						break
262					}
263
264					// We cannot be sure that the "type" tag comes before the "type-id" tag:
265					if len(st.typeBuf) == 0 {
266						st.typeBuf = append(st.typeBuf, val...)
267					} else {
268						st.typeBuf = reorder(st.typeBuf, val)
269					}
270				case "type-id":
271					st.typeBuf = append(st.typeBuf, val...)
272				case "stype":
273					// We cannot be sure that the "stype" tag comes before the "stype-id" tag:
274					if len(st.subTypeBuf) == 0 {
275						st.subTypeBuf = append(st.subTypeBuf, val...)
276					} else {
277						st.subTypeBuf = reorder(st.subTypeBuf, val)
278					}
279				case "stype-id":
280					st.subTypeBuf = append(st.subTypeBuf, val...)
281				default:
282				}
283			}
284
285			// If the cluster or host changed, the lvl was set to nil
286			if lvl == nil {
287				st.selector = st.selector[:2]
288				st.selector[0], st.selector[1] = cluster, host
289				lvl = ms.GetLevel(st.selector)
290				prevCluster, prevHost = cluster, host
291			}
292
293			// subtypes: cache []byte→string conversions; messages in a batch typically
294			// share the same type/stype so the hit rate is very high.
295			st.selector = st.selector[:0]
296			if len(st.typeBuf) > 0 {
297				if !bytes.Equal(st.typeBuf, st.prevTypeBytes) {
298					st.prevTypeBytes = append(st.prevTypeBytes[:0], st.typeBuf...)
299					st.prevTypeStr = string(st.typeBuf)
300				}
301				st.selector = append(st.selector, st.prevTypeStr)
302				if len(st.subTypeBuf) > 0 {
303					if !bytes.Equal(st.subTypeBuf, st.prevSubTypeBytes) {
304						st.prevSubTypeBytes = append(st.prevSubTypeBytes[:0], st.subTypeBuf...)
305						st.prevSubTypeStr = string(st.subTypeBuf)
306					}
307					st.selector = append(st.selector, st.prevSubTypeStr)
308				}
309			}
310
311			for {
312				key, val, err := dec.NextField()
313				if err != nil {
314					return err
315				}
316
317				if key == nil {
318					break
319				}

//     prevType*/prevSubType* fields because batches typically repeat the same
//     sub-device identifiers.
//   - Timestamp parsing tries Second precision first; if that fails it retries
//     Millisecond, Microsecond, and Nanosecond in turn. A missing timestamp
//     falls back to time.Now().
//
// When the checkpoint format is "wal" each successfully decoded sample is also
// sent to WALMessages so the WAL staging goroutine can persist it durably
// before the next binary snapshot.
func DecodeLine(dec *lineprotocol.Decoder,
	ms *MemoryStore,
	clusterDefault string,
) error {
	// Reduce allocations in loop:
	t := time.Now()
	metric := Metric{}
	st := decodeStatePool.Get().(*decodeState)
	defer decodeStatePool.Put(st)

	// Optimize for the case where all lines in a "batch" are about the same
	// cluster and host. By using `WriteToLevel` (level = host), we do not need
	// to take the root- and cluster-level lock as often.
	var lvl *Level = nil
	prevCluster, prevHost := "", ""

	var ok bool
	for dec.Next() {
		rawmeasurement, err := dec.Measurement()
		if err != nil {
			return err
		}

		// Needs to be copied because another call to dec.* would
		// invalidate the returned slice.
		st.metricBuf = append(st.metricBuf[:0], rawmeasurement...)

		// The go compiler optimizes map[string(byteslice)] lookups:
		metric.MetricConfig, ok = ms.Metrics[string(rawmeasurement)]
		if !ok {
			continue
		}

		st.typeBuf, st.subTypeBuf = st.typeBuf[:0], st.subTypeBuf[:0]
		cluster, host := clusterDefault, ""
		for {
			key, val, err := dec.NextTag()
			if err != nil {
				return err
			}
			if key == nil {
				break
			}

			// The go compiler optimizes string([]byte{...}) == "...":
			switch string(key) {
			case "cluster":
				if string(val) == prevCluster {
					cluster = prevCluster
				} else {
					cluster = string(val)
					lvl = nil
				}
			case "hostname", "host":
				if string(val) == prevHost {
					host = prevHost
				} else {
					host = string(val)
					lvl = nil
				}
			case "type":
				if string(val) == "node" {
					break
				}

				// We cannot be sure that the "type" tag comes before the "type-id" tag:
				if len(st.typeBuf) == 0 {
					st.typeBuf = append(st.typeBuf, val...)
				} else {
					st.typeBuf = reorder(st.typeBuf, val)
				}
			case "type-id":
				st.typeBuf = append(st.typeBuf, val...)
			case "stype":
				// We cannot be sure that the "stype" tag comes before the "stype-id" tag:
				if len(st.subTypeBuf) == 0 {
					st.subTypeBuf = append(st.subTypeBuf, val...)
				} else {
					st.subTypeBuf = reorder(st.subTypeBuf, val)
				}
			case "stype-id":
				st.subTypeBuf = append(st.subTypeBuf, val...)
			default:
			}
		}

		// If the cluster or host changed, the lvl was set to nil
		if lvl == nil {
			st.selector = st.selector[:2]
			st.selector[0], st.selector[1] = cluster, host
			lvl = ms.GetLevel(st.selector)
			prevCluster, prevHost = cluster, host
		}

		// subtypes: cache []byte→string conversions; messages in a batch typically
		// share the same type/stype so the hit rate is very high.
		st.selector = st.selector[:0]
		if len(st.typeBuf) > 0 {
			if !bytes.Equal(st.typeBuf, st.prevTypeBytes) {
				st.prevTypeBytes = append(st.prevTypeBytes[:0], st.typeBuf...)
				st.prevTypeStr = string(st.typeBuf)
			}
			st.selector = append(st.selector, st.prevTypeStr)
			if len(st.subTypeBuf) > 0 {
				if !bytes.Equal(st.subTypeBuf, st.prevSubTypeBytes) {
					st.prevSubTypeBytes = append(st.prevSubTypeBytes[:0], st.subTypeBuf...)
					st.prevSubTypeStr = string(st.subTypeBuf)
				}
				st.selector = append(st.selector, st.prevSubTypeStr)
			}
		}

		for {
			key, val, err := dec.NextField()
			if err != nil {
				return err
			}

			if key == nil {
				break
			}


319				}
320
321				if string(key) != "value" {
322					return fmt.Errorf("host %s: unknown field: '%s' (value: %#v)", host, string(key), val)
323				}
324
325				if val.Kind() == lineprotocol.Float {
326					metric.Value = schema.Float(val.FloatV())
327				} else if val.Kind() == lineprotocol.Int {
328					metric.Value = schema.Float(val.IntV())
329				} else if val.Kind() == lineprotocol.Uint {
330					metric.Value = schema.Float(val.UintV())
331				} else {
332					return fmt.Errorf("host %s: unsupported value type in message: %s", host, val.Kind().String())
333				}
334			}
335
336			if t, err = dec.Time(lineprotocol.Second, t); err != nil {
337				t = time.Now()
338				if t, err = dec.Time(lineprotocol.Millisecond, t); err != nil {
339					t = time.Now()
340					if t, err = dec.Time(lineprotocol.Microsecond, t); err != nil {
341						t = time.Now()
342						if t, err = dec.Time(lineprotocol.Nanosecond, t); err != nil {
343							return fmt.Errorf("host %s: timestamp : %#v with error : %#v", host, t, err.Error())
344						}
345					}
346				}
347			}
348
349			if err != nil {
350				return fmt.Errorf("host %s: timestamp : %#v with error : %#v", host, t, err.Error())
351			}
352
353			time := t.Unix()
354
355			if Keys.Checkpoints.FileFormat == "wal" {
356				msg := &WALMessage{
357					MetricName: string(st.metricBuf),
358					Cluster:    cluster,
359					Node:       host,
360					Selector:   append([]string{}, st.selector...),
361					Value:      metric.Value,
362					Timestamp:  time,
363				}
364				if !SendWALMessage(msg) {
365					// WAL shard channel full — metric is written to memory store but not WAL.
366					// Next binary snapshot will capture it.
367					if dropped := walDropped.Add(1); dropped%10000 == 1 {
368						cclog.Warnf("[METRICSTORE]> WAL shard channel full, dropped %d messages (data safe in memory, next snapshot will capture)", dropped)
369					}
370				}
371			}
372
373			if err := ms.WriteToLevel(lvl, st.selector, time, []Metric{metric}); err != nil {
374				cclog.Warnf("write error for host %s metric %s at ts %d: %s", host, string(st.metricBuf), time, err.Error())
375				continue
376			}
377		}
378		return nil
379	}
380

			}

			if string(key) != "value" {
				return fmt.Errorf("host %s: unknown field: '%s' (value: %#v)", host, string(key), val)
			}

			if val.Kind() == lineprotocol.Float {
				metric.Value = schema.Float(val.FloatV())
			} else if val.Kind() == lineprotocol.Int {
				metric.Value = schema.Float(val.IntV())
			} else if val.Kind() == lineprotocol.Uint {
				metric.Value = schema.Float(val.UintV())
			} else {
				return fmt.Errorf("host %s: unsupported value type in message: %s", host, val.Kind().String())
			}
		}

		if t, err = dec.Time(lineprotocol.Second, t); err != nil {
			t = time.Now()
			if t, err = dec.Time(lineprotocol.Millisecond, t); err != nil {
				t = time.Now()
				if t, err = dec.Time(lineprotocol.Microsecond, t); err != nil {
					t = time.Now()
					if t, err = dec.Time(lineprotocol.Nanosecond, t); err != nil {
						return fmt.Errorf("host %s: timestamp : %#v with error : %#v", host, t, err.Error())
					}
				}
			}
		}

		if err != nil {
			return fmt.Errorf("host %s: timestamp : %#v with error : %#v", host, t, err.Error())
		}

		time := t.Unix()

		if Keys.Checkpoints.FileFormat == "wal" {
			msg := &WALMessage{
				MetricName: string(st.metricBuf),
				Cluster:    cluster,
				Node:       host,
				Selector:   append([]string{}, st.selector...),
				Value:      metric.Value,
				Timestamp:  time,
			}
			if !SendWALMessage(msg) {
				// WAL shard channel full — metric is written to memory store but not WAL.
				// Next binary snapshot will capture it.
				if dropped := walDropped.Add(1); dropped%10000 == 1 {
					cclog.Warnf("[METRICSTORE]> WAL shard channel full, dropped %d messages (data safe in memory, next snapshot will capture)", dropped)
				}
			}
		}

		if err := ms.WriteToLevel(lvl, st.selector, time, []Metric{metric}); err != nil {
			cclog.Warnf("write error for host %s metric %s at ts %d: %s", host, string(st.metricBuf), time, err.Error())
			continue
		}
	}
	return nil
}


200	// decode reads JSON from r into val with strict validation that rejects unknown fields.
201	func decode(r io.Reader, val any) error {
202		dec := json.NewDecoder(r)
203		dec.DisallowUnknownFields()
204		return dec.Decode(val)
205	}
206
207	// validatePathComponent checks if a path component contains potentially malicious patterns
208	// that could be used for path traversal attacks. Returns an error if validation fails.
209	func validatePathComponent(component, componentName string) error {
210		if strings.Contains(component, "..") ||
211			strings.Contains(component, "/") ||
212			strings.Contains(component, "\\") {
213			return fmt.Errorf("invalid %s", componentName)
214		}
215		return nil
216	}
217
218	// editNotice godoc
219	// @summary     Update system notice
220	// @tags        Config
221	// @description Updates the notice.txt file content. Only admins are allowed. Content is limited to 10000 characters.
222	// @accept      mpfd
223	// @produce     plain
224	// @param       new-content formData string true "New notice content (max 10000 characters)"

// decode reads JSON from r into val with strict validation that rejects unknown fields.
func decode(r io.Reader, val any) error {
	dec := json.NewDecoder(r)
	dec.DisallowUnknownFields()
	return dec.Decode(val)
}

// validatePathComponent checks if a path component contains potentially malicious patterns
// that could be used for path traversal attacks. Returns an error if validation fails.
func validatePathComponent(component, componentName string) error {
	if strings.Contains(component, "..") ||
		strings.Contains(component, "/") ||
		strings.Contains(component, "\\") {
		return fmt.Errorf("invalid %s", componentName)
	}
	return nil
}

// editNotice godoc
// @summary     Update system notice
// @tags        Config
// @description Updates the notice.txt file content. Only admins are allowed. Content is limited to 10000 characters.
// @accept      mpfd
// @produce     plain
// @param       new-content formData string true "New notice content (max 10000 characters)"


Now I'll implement all three fixes. Starting with Fix 1 (JWT empty-key):


Edit

func (ja *JWTSessionAuthenticator) Init() error {
	if pubKey := os.Getenv("CROSS_LOGIN_JWT_HS512_KEY"); pubKey != "" {
		bytes, err := base64.StdEncoding.DecodeString(pubKey)
		if err != nil {
			cclog.Warn("Could not decode cross login JWT HS512 key")
			return err
		}
		ja.loginTokenKey = bytes
	}

	cclog.Info("JWT Session authenticator successfully registered")
	return nil
}

func (ja *JWTSessionAuthenticator) Init() error {
	pubKey := os.Getenv("CROSS_LOGIN_JWT_HS512_KEY")
	if pubKey == "" {
		// Without a configured key the HMAC verification below would run against
		// an empty key, which lets anyone forge a valid token. Refuse to register
		// the authenticator in that case so JWT session login is simply disabled.
		return errors.New("CROSS_LOGIN_JWT_HS512_KEY not set: JWT session login disabled")
	}

	bytes, err := base64.StdEncoding.DecodeString(pubKey)
	if err != nil {
		cclog.Warn("Could not decode cross login JWT HS512 key")
		return err
	}
	ja.loginTokenKey = bytes

	cclog.Info("JWT Session authenticator successfully registered")
	return nil
}


The file /Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/jwtSession.go has been updated successfully. (file state is current in your context — no need to Read it back)

func (ja *JWTSessionAuthenticator) Init() error {
	pubKey := os.Getenv("CROSS_LOGIN_JWT_HS512_KEY")
	if pubKey == "" {
		// Without a configured key the HMAC verification below would run against
		// an empty key, which lets anyone forge a valid token. Refuse to register
		// the authenticator in that case so JWT session login is simply disabled.
		return errors.New("CROSS_LOGIN_JWT_HS512_KEY not set: JWT session login disabled")
	}

	bytes, err := base64.StdEncoding.DecodeString(pubKey)
	if err != nil {
		cclog.Warn("Could not decode cross login JWT HS512 key")
		return err
	}
	ja.loginTokenKey = bytes

	cclog.Info("JWT Session authenticator successfully registered")
	return nil
}

// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

package auth

import (
	"encoding/base64"
	"errors"
	"fmt"
	"net/http"
	"os"
	"strings"

	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
	"github.com/ClusterCockpit/cc-lib/v2/schema"
	"github.com/golang-jwt/jwt/v5"
)

type JWTSessionAuthenticator struct {
	loginTokenKey []byte // HS256 key
}

var _ Authenticator = (*JWTSessionAuthenticator)(nil)

func (ja *JWTSessionAuthenticator) Init() error {
	if pubKey := os.Getenv("CROSS_LOGIN_JWT_HS512_KEY"); pubKey != "" {
		bytes, err := base64.StdEncoding.DecodeString(pubKey)
		if err != nil {
			cclog.Warn("Could not decode cross login JWT HS512 key")
			return err
		}
		ja.loginTokenKey = bytes
	}

	cclog.Info("JWT Session authenticator successfully registered")
	return nil
}

func (ja *JWTSessionAuthenticator) CanLogin(
	user *schema.User,
	username string,
	rw http.ResponseWriter,
	r *http.Request,
) (*schema.User, bool) {
	return user, r.Header.Get("Authorization") != "" ||
		r.URL.Query().Get("login-token") != ""
}

func (ja *JWTSessionAuthenticator) Login(
	user *schema.User,
	rw http.ResponseWriter,
	r *http.Request,
) (*schema.User, error) {
	rawtoken := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
	if rawtoken == "" {
		rawtoken = r.URL.Query().Get("login-token")
	}

	token, err := jwt.Parse(rawtoken, func(t *jwt.Token) (any, error) {
		if t.Method == jwt.SigningMethodHS256 || t.Method == jwt.SigningMethodHS512 {
			return ja.loginTokenKey, nil
		}
		return nil, fmt.Errorf("unkown signing method for login token: %s (known: HS256, HS512, EdDSA)", t.Method.Alg())
	})
	if err != nil {
		cclog.Warn("Error while parsing jwt token")
		return nil, err
	}

	if !token.Valid {
		cclog.Warn("jwt token claims are not valid")
		return nil, errors.New("jwt token claims are not valid")
	}

	claims := token.Claims.(jwt.MapClaims)

	// Use shared helper to get user from JWT claims
	user, err = getUserFromJWT(claims, Keys.JwtConfig.ValidateUser, schema.AuthSession, schema.AuthViaToken)
	if err != nil {
		return nil, err
	}

	// Sync or update user if configured
	if !Keys.JwtConfig.ValidateUser && (Keys.JwtConfig.SyncUserOnLogin || Keys.JwtConfig.UpdateUserOnLogin) {
		handleTokenUser(user)
	}

	return user, nil
}

-  if pubKey := os.Getenv("CROSS_LOGIN_JWT_HS512_KEY"); pubKey != "" {

-    bytes, err := base64.StdEncoding.DecodeString(pubKey)

-      cclog.Warn("Could not decode cross login JWT HS512 key")

+  pubKey := os.Getenv("CROSS_LOGIN_JWT_HS512_KEY")

+    // Without a configured key the HMAC verification below would run against

+    // an empty key, which lets anyone forge a valid token. Refuse to register

+    // the authenticator in that case so JWT session login is simply disabled.

+    return errors.New("CROSS_LOGIN_JWT_HS512_KEY not set: JWT session login disabled")

+  bytes, err := base64.StdEncoding.DecodeString(pubKey)

+    cclog.Warn("Could not decode cross login JWT HS512 key")


	token, err := jwt.Parse(rawtoken, func(t *jwt.Token) (any, error) {
		if t.Method == jwt.SigningMethodHS256 || t.Method == jwt.SigningMethodHS512 {
			return ja.loginTokenKey, nil
		}
		return nil, fmt.Errorf("unkown signing method for login token: %s (known: HS256, HS512, EdDSA)", t.Method.Alg())
	})

	token, err := jwt.Parse(rawtoken, func(t *jwt.Token) (any, error) {
		if t.Method == jwt.SigningMethodHS256 || t.Method == jwt.SigningMethodHS512 {
			// Defense in depth: an empty key would verify any HMAC signature.
			// Init() already refuses to register without a key, so this should
			// never trigger, but guard explicitly rather than trust the chain.
			if len(ja.loginTokenKey) == 0 {
				return nil, errors.New("HS login key not configured")
			}
			return ja.loginTokenKey, nil
		}
		return nil, fmt.Errorf("unkown signing method for login token: %s (known: HS256, HS512, EdDSA)", t.Method.Alg())
	})


	token, err := jwt.Parse(rawtoken, func(t *jwt.Token) (any, error) {
		if t.Method == jwt.SigningMethodHS256 || t.Method == jwt.SigningMethodHS512 {
			// Defense in depth: an empty key would verify any HMAC signature.
			// Init() already refuses to register without a key, so this should
			// never trigger, but guard explicitly rather than trust the chain.
			if len(ja.loginTokenKey) == 0 {
				return nil, errors.New("HS login key not configured")
			}
			return ja.loginTokenKey, nil
		}
		return nil, fmt.Errorf("unkown signing method for login token: %s (known: HS256, HS512, EdDSA)", t.Method.Alg())
	})

// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

package auth

import (
	"encoding/base64"
	"errors"
	"fmt"
	"net/http"
	"os"
	"strings"

	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
	"github.com/ClusterCockpit/cc-lib/v2/schema"
	"github.com/golang-jwt/jwt/v5"
)

type JWTSessionAuthenticator struct {
	loginTokenKey []byte // HS256 key
}

var _ Authenticator = (*JWTSessionAuthenticator)(nil)

func (ja *JWTSessionAuthenticator) Init() error {
	pubKey := os.Getenv("CROSS_LOGIN_JWT_HS512_KEY")
	if pubKey == "" {
		// Without a configured key the HMAC verification below would run against
		// an empty key, which lets anyone forge a valid token. Refuse to register
		// the authenticator in that case so JWT session login is simply disabled.
		return errors.New("CROSS_LOGIN_JWT_HS512_KEY not set: JWT session login disabled")
	}

	bytes, err := base64.StdEncoding.DecodeString(pubKey)
	if err != nil {
		cclog.Warn("Could not decode cross login JWT HS512 key")
		return err
	}
	ja.loginTokenKey = bytes

	cclog.Info("JWT Session authenticator successfully registered")
	return nil
}

func (ja *JWTSessionAuthenticator) CanLogin(
	user *schema.User,
	username string,
	rw http.ResponseWriter,
	r *http.Request,
) (*schema.User, bool) {
	return user, r.Header.Get("Authorization") != "" ||
		r.URL.Query().Get("login-token") != ""
}

func (ja *JWTSessionAuthenticator) Login(
	user *schema.User,
	rw http.ResponseWriter,
	r *http.Request,
) (*schema.User, error) {
	rawtoken := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
	if rawtoken == "" {
		rawtoken = r.URL.Query().Get("login-token")
	}

	token, err := jwt.Parse(rawtoken, func(t *jwt.Token) (any, error) {
		if t.Method == jwt.SigningMethodHS256 || t.Method == jwt.SigningMethodHS512 {
			return ja.loginTokenKey, nil
		}
		return nil, fmt.Errorf("unkown signing method for login token: %s (known: HS256, HS512, EdDSA)", t.Method.Alg())
	})
	if err != nil {
		cclog.Warn("Error while parsing jwt token")
		return nil, err
	}

	if !token.Valid {
		cclog.Warn("jwt token claims are not valid")
		return nil, errors.New("jwt token claims are not valid")
	}

	claims := token.Claims.(jwt.MapClaims)

	// Use shared helper to get user from JWT claims
	user, err = getUserFromJWT(claims, Keys.JwtConfig.ValidateUser, schema.AuthSession, schema.AuthViaToken)
	if err != nil {
		return nil, err
	}

	// Sync or update user if configured
	if !Keys.JwtConfig.ValidateUser && (Keys.JwtConfig.SyncUserOnLogin || Keys.JwtConfig.UpdateUserOnLogin) {
		handleTokenUser(user)
	}

	return user, nil
}

+      // Defense in depth: an empty key would verify any HMAC signature.

+      // Init() already refuses to register without a key, so this should

+      // never trigger, but guard explicitly rather than trust the chain.

+        return nil, errors.New("HS login key not configured")


Fix 1 done. Now Fix 2 (metric-name SQLi). Let me check imports and add a validation helper:


1	// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
2	// All rights reserved. This file is part of cc-backend.
3	// Use of this source code is governed by a MIT-style
4	// license that can be found in the LICENSE file.
5
6	// Package repository provides job query functionality with filtering, pagination,
7	// and security controls. This file contains the main query builders and security
8	// checks for job retrieval operations.
9	package repository
10
11	import (
12		"context"
13		"errors"
14		"fmt"
15		"regexp"
16		"strings"
17		"time"
18
19		"github.com/ClusterCockpit/cc-backend/internal/config"
20		"github.com/ClusterCockpit/cc-backend/internal/graph/model"
21		cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
22		"github.com/ClusterCockpit/cc-lib/v2/schema"
23		sq "github.com/Masterminds/squirrel"
24	)
25
26	const (
27		// Default initial capacity for job result slices
28		defaultJobsCapacity = 50
29	)
30

// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

// Package repository provides job query functionality with filtering, pagination,
// and security controls. This file contains the main query builders and security
// checks for job retrieval operations.
package repository

import (
	"context"
	"errors"
	"fmt"
	"regexp"
	"strings"
	"time"

	"github.com/ClusterCockpit/cc-backend/internal/config"
	"github.com/ClusterCockpit/cc-backend/internal/graph/model"
	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
	"github.com/ClusterCockpit/cc-lib/v2/schema"
	sq "github.com/Masterminds/squirrel"
)

const (
	// Default initial capacity for job result slices
	defaultJobsCapacity = 50
)


regexp already imported. Let me add the validation helper and apply it in `buildFloatJSONCondition`:


// buildFloatJSONCondition creates a filter on a numeric field within the footprint JSON column, using BETWEEN only if required.
func buildFloatJSONCondition(jsonField string, cond *model.FloatRange, query sq.SelectBuilder) sq.SelectBuilder {
	query = query.Where("JSON_VALID(footprint)")

// validMetricName guards metric/footprint names that are interpolated into the
// json_extract() path of footprint queries. SQLite treats double-quoted strings
// as string literals, so an unescaped name (e.g. containing a `"`) would allow
// SQL injection. Legitimate metric names only use these characters.
var validMetricName = regexp.MustCompile(`^[a-zA-Z0-9_]+$`)

// buildFloatJSONCondition creates a filter on a numeric field within the footprint JSON column, using BETWEEN only if required.
func buildFloatJSONCondition(jsonField string, cond *model.FloatRange, query sq.SelectBuilder) sq.SelectBuilder {
	if !validMetricName.MatchString(jsonField) {
		cclog.Warnf("buildFloatJSONCondition: rejecting invalid metric name %q", jsonField)
		return query.Where("0 = 1")
	}
	query = query.Where("JSON_VALID(footprint)")


The file /Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/jobQuery.go has been updated successfully. (file state is current in your context — no need to Read it back)

// buildFloatJSONCondition creates a filter on a numeric field within the footprint JSON column, using BETWEEN only if required.
func buildFloatJSONCondition(jsonField string, cond *model.FloatRange, query sq.SelectBuilder) sq.SelectBuilder {
	query = query.Where("JSON_VALID(footprint)")

// validMetricName guards metric/footprint names that are interpolated into the
// json_extract() path of footprint queries. SQLite treats double-quoted strings
// as string literals, so an unescaped name (e.g. containing a `"`) would allow
// SQL injection. Legitimate metric names only use these characters.
var validMetricName = regexp.MustCompile(`^[a-zA-Z0-9_]+$`)

// buildFloatJSONCondition creates a filter on a numeric field within the footprint JSON column, using BETWEEN only if required.
func buildFloatJSONCondition(jsonField string, cond *model.FloatRange, query sq.SelectBuilder) sq.SelectBuilder {
	if !validMetricName.MatchString(jsonField) {
		cclog.Warnf("buildFloatJSONCondition: rejecting invalid metric name %q", jsonField)
		return query.Where("0 = 1")
	}
	query = query.Where("JSON_VALID(footprint)")

+// validMetricName guards metric/footprint names that are interpolated into the

+// json_extract() path of footprint queries. SQLite treats double-quoted strings

+// as string literals, so an unescaped name (e.g. containing a `"`) would allow

+// SQL injection. Legitimate metric names only use these characters.

+var validMetricName = regexp.MustCompile(`^[a-zA-Z0-9_]+$`)

+  if !validMetricName.MatchString(jsonField) {

+    cclog.Warnf("buildFloatJSONCondition: rejecting invalid metric name %q", jsonField)

return query.Where("JSON_EXTRACT(footprint, \"$."+jsonField+"\") BETWEEN ? AND ?", cond.From, cond.To)


Now apply validation in `stats.go`'s `jobsMetricStatisticsHistogram` (before the SQL interpolation):


	ctx context.Context,
	metric string,
	filters []*model.JobFilter,
	bins *int,
) (*model.MetricHistoPoints, error) {
	// Peak value defines the upper bound for binning: values are distributed across

	ctx context.Context,
	metric string,
	filters []*model.JobFilter,
	bins *int,
) (*model.MetricHistoPoints, error) {
	// The metric name is interpolated into the json_extract() path of the SQL
	// below. SQLite parses double-quoted strings as literals, so reject anything
	// that is not a plain metric identifier to prevent SQL injection.
	if !validMetricName.MatchString(metric) {
		return nil, fmt.Errorf("invalid metric name: %q", metric)
	}

	// Peak value defines the upper bound for binning: values are distributed across


The file /Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/stats.go has been updated successfully. (file state is current in your context — no need to Read it back)

	ctx context.Context,
	metric string,
	filters []*model.JobFilter,
	bins *int,
) (*model.MetricHistoPoints, error) {
	// Peak value defines the upper bound for binning: values are distributed across

	ctx context.Context,
	metric string,
	filters []*model.JobFilter,
	bins *int,
) (*model.MetricHistoPoints, error) {
	// The metric name is interpolated into the json_extract() path of the SQL
	// below. SQLite parses double-quoted strings as literals, so reject anything
	// that is not a plain metric identifier to prevent SQL injection.
	if !validMetricName.MatchString(metric) {
		return nil, fmt.Errorf("invalid metric name: %q", metric)
	}

	// Peak value defines the upper bound for binning: values are distributed across

+  // The metric name is interpolated into the json_extract() path of the SQL

+  // below. SQLite parses double-quoted strings as literals, so reject anything

+  // that is not a plain metric identifier to prevent SQL injection.

+  if !validMetricName.MatchString(metric) {

+    return nil, fmt.Errorf("invalid metric name: %q", metric)

// bins from 0 to peak. First try to get peak from filtered cluster, otherwise

// scan all clusters to find the maximum peak value.


Let me verify there are no other metric-name interpolation sites and check the running-jobs path:


Bash

grep -n 'json_extract\|JSON_EXTRACT\|fmt.Sprintf' internal/repository/stats.go | grep -i 'footprint\|%s'

Find all metric interpolation sites in stats.go


166:		columns = append(columns, fmt.Sprintf(`CAST(ROUND(SUM(%s) / 3600) as int) as totalWalltime`, durationExpr))
178:		columns = append(columns, fmt.Sprintf(`CAST(ROUND(SUM(%s * job.num_nodes) / 3600) as int) as totalNodeHours`, durationExpr))
190:		columns = append(columns, fmt.Sprintf(`CAST(ROUND(SUM(%s * job.num_hwthreads) / 3600) as int) as totalCoreHours`, durationExpr))
202:		columns = append(columns, fmt.Sprintf(`CAST(ROUND(SUM(%s * job.num_acc) / 3600) as int) as totalAccHours`, durationExpr))
252:		query = query.OrderBy(fmt.Sprintf("%s DESC", sortBy))
967:		((case when json_extract(footprint, "$.%s") = %f then %f*0.999999999 else json_extract(footprint, "$.%s") end) / %f)
972:		fmt.Sprintf(`%s + 1 as bin`, binQuery),
976:	).Where(fmt.Sprintf(`json_extract(footprint, "$.%s") is not null and json_extract(footprint, "$.%s") <= %f`, (metric + "_" + footprintStat), (metric + "_" + footprintStat), peak))


grep -n 'func runningJobsMetricStatisticsHistogram' internal/repository/stats.go

Locate running jobs histogram func


(Bash completed with no output)


grep -rn 'func.*runningJobsMetricStatisticsHistogram' internal/repository/; echo "=== its interpolation sites ==="; grep -rln 'runningJobsMetricStatisticsHistogram' internal/repository/

Find running jobs histogram definition


internal/repository/stats.go:1063:func (r *JobRepository) runningJobsMetricStatisticsHistogram(
=== its interpolation sites ===
internal/repository/stats.go


1063	func (r *JobRepository) runningJobsMetricStatisticsHistogram(
1064		ctx context.Context,
1065		metrics []string,
1066		filters []*model.JobFilter,
1067		bins *int,
1068	) []*model.MetricHistoPoints {
1069		// Get Jobs
1070		jobs, err := r.QueryJobs(ctx, filters, &model.PageRequest{Page: 1, ItemsPerPage: 5000 + 1}, nil)
1071		if err != nil {
1072			cclog.Errorf("Error while querying jobs for footprint: %s", err)
1073			return nil
1074		}
1075		if len(jobs) > 5000 {
1076			cclog.Errorf("too many jobs matched (max: %d)", 5000)
1077			return nil
1078		}
1079
1080		// Get AVGs from metric repo
1081		avgs := make([][]schema.Float, len(metrics))
1082		for i := range avgs {
1083			avgs[i] = make([]schema.Float, 0, len(jobs))
1084		}
1085
1086		for _, job := range jobs {
1087			if job.MonitoringStatus == schema.MonitoringStatusDisabled || job.MonitoringStatus == schema.MonitoringStatusArchivingFailed {
1088				continue
1089			}
1090
1091			if err := metricdispatch.LoadAverages(job, metrics, avgs, ctx); err != nil {
1092				cclog.Errorf("Error while loading averages for histogram: %s", err)
1093				return nil
1094			}
1095		}
1096
1097		// Iterate metrics to fill endresult
1098		data := make([]*model.MetricHistoPoints, 0)
1099		for idx, metric := range metrics {
1100			// Get specific Peak or largest Peak
1101			var metricConfig *schema.MetricConfig
1102			var peak float64
1103			var unit string
1104
1105			for _, f := range filters {
1106				if f.Cluster != nil {
1107					metricConfig = archive.GetMetricConfig(*f.Cluster.Eq, metric)
1108					peak = metricConfig.Peak
1109					unit = metricConfig.Unit.Prefix + metricConfig.Unit.Base
1110				}
1111			}
1112
1113			if peak == 0.0 {
1114				for _, c := range archive.Clusters {
1115					for _, m := range c.MetricConfig {
1116						if m.Name == metric {
1117							if m.Peak > peak {
1118								peak = m.Peak
1119							}
1120							if unit == "" {
1121								unit = m.Unit.Prefix + m.Unit.Base
1122							}

func (r *JobRepository) runningJobsMetricStatisticsHistogram(
	ctx context.Context,
	metrics []string,
	filters []*model.JobFilter,
	bins *int,
) []*model.MetricHistoPoints {
	// Get Jobs
	jobs, err := r.QueryJobs(ctx, filters, &model.PageRequest{Page: 1, ItemsPerPage: 5000 + 1}, nil)
	if err != nil {
		cclog.Errorf("Error while querying jobs for footprint: %s", err)
		return nil
	}
	if len(jobs) > 5000 {
		cclog.Errorf("too many jobs matched (max: %d)", 5000)
		return nil
	}

	// Get AVGs from metric repo
	avgs := make([][]schema.Float, len(metrics))
	for i := range avgs {
		avgs[i] = make([]schema.Float, 0, len(jobs))
	}

	for _, job := range jobs {
		if job.MonitoringStatus == schema.MonitoringStatusDisabled || job.MonitoringStatus == schema.MonitoringStatusArchivingFailed {
			continue
		}

		if err := metricdispatch.LoadAverages(job, metrics, avgs, ctx); err != nil {
			cclog.Errorf("Error while loading averages for histogram: %s", err)
			return nil
		}
	}

	// Iterate metrics to fill endresult
	data := make([]*model.MetricHistoPoints, 0)
	for idx, metric := range metrics {
		// Get specific Peak or largest Peak
		var metricConfig *schema.MetricConfig
		var peak float64
		var unit string

		for _, f := range filters {
			if f.Cluster != nil {
				metricConfig = archive.GetMetricConfig(*f.Cluster.Eq, metric)
				peak = metricConfig.Peak
				unit = metricConfig.Unit.Prefix + metricConfig.Unit.Base
			}
		}

		if peak == 0.0 {
			for _, c := range archive.Clusters {
				for _, m := range c.MetricConfig {
					if m.Name == metric {
						if m.Peak > peak {
							peak = m.Peak
						}
						if unit == "" {
							unit = m.Unit.Prefix + m.Unit.Base
						}


Good — running-jobs path builds bins in Go, no SQL interpolation. Now Fix 3 (path traversal in ingestion). Let me check the imports:


20
21	import (
22		"bytes"
23		"context"
24		"fmt"
25		"sync"
26		"sync/atomic"
27		"time"
28
29		cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
30		"github.com/ClusterCockpit/cc-lib/v2/nats"
31		"github.com/ClusterCockpit/cc-lib/v2/schema"
32		"github.com/ClusterCockpit/cc-line-protocol/v2/lineprotocol"
33	)
34
35	// ReceiveNats subscribes to all configured NATS subjects and feeds incoming
36	// line-protocol messages into the MemoryStore.
37	//
38	// When workers > 1 a pool of goroutines drains a shared channel so that
39	// multiple messages can be decoded in parallel. With workers == 1 the NATS

import (
	"bytes"
	"context"
	"fmt"
	"sync"
	"sync/atomic"
	"time"

	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
	"github.com/ClusterCockpit/cc-lib/v2/nats"
	"github.com/ClusterCockpit/cc-lib/v2/schema"
	"github.com/ClusterCockpit/cc-line-protocol/v2/lineprotocol"
)

// ReceiveNats subscribes to all configured NATS subjects and feeds incoming
// line-protocol messages into the MemoryStore.
//
// When workers > 1 a pool of goroutines drains a shared channel so that
// multiple messages can be decoded in parallel. With workers == 1 the NATS


`strings` isn't imported. I'll add it, plus a validation helper and the check after tag parsing:


Edit

	"bytes"
	"context"
	"fmt"
	"sync"
	"sync/atomic"
	"time"

	"bytes"
	"context"
	"fmt"
	"strings"
	"sync"
	"sync/atomic"
	"time"


The file /Users/jan/prg/ClusterCockpit/cc-backend/pkg/metricstore/lineprotocol.go has been updated successfully. (file state is current in your context — no need to Read it back)

	"bytes"
	"context"
	"fmt"
	"sync"
	"sync/atomic"
	"time"

	"bytes"
	"context"
	"fmt"
	"strings"
	"sync"
	"sync/atomic"
	"time"

"bytes"
"context"
"fmt"
+  "strings"
"sync"
"sync/atomic"
"time"


		// If the cluster or host changed, the lvl was set to nil
		if lvl == nil {

		// cluster and host are taken verbatim from attacker-controllable line
		// protocol tags and are later used as filesystem path components for the
		// checkpoint/WAL files (path.Join(RootDir, cluster, host)). Reject
		// path-traversal sequences so a malicious tag cannot escape the
		// checkpoint root. Skip the offending sample; the rest of the batch is
		// still processed.
		if !isValidPathComponent(cluster) || !isValidPathComponent(host) {
			cclog.Warnf("[METRICSTORE]> rejecting metric with invalid cluster/host tag (cluster=%q host=%q)", cluster, host)
			continue
		}

		// If the cluster or host changed, the lvl was set to nil
		if lvl == nil {


		// cluster and host are taken verbatim from attacker-controllable line
		// protocol tags and are later used as filesystem path components for the
		// checkpoint/WAL files (path.Join(RootDir, cluster, host)). Reject
		// path-traversal sequences so a malicious tag cannot escape the
		// checkpoint root. Skip the offending sample; the rest of the batch is
		// still processed.
		if !isValidPathComponent(cluster) || !isValidPathComponent(host) {
			cclog.Warnf("[METRICSTORE]> rejecting metric with invalid cluster/host tag (cluster=%q host=%q)", cluster, host)
			continue
		}

		// If the cluster or host changed, the lvl was set to nil
		if lvl == nil {

+    // cluster and host are taken verbatim from attacker-controllable line

+    // protocol tags and are later used as filesystem path components for the

+    // checkpoint/WAL files (path.Join(RootDir, cluster, host)). Reject

+    // path-traversal sequences so a malicious tag cannot escape the

+    // checkpoint root. Skip the offending sample; the rest of the batch is

+    if !isValidPathComponent(cluster) || !isValidPathComponent(host) {

+      cclog.Warnf("[METRICSTORE]> rejecting metric with invalid cluster/host tag (cluster=%q host=%q)", cluster, host)


Now add the `isValidPathComponent` helper to the metricstore package:


// isValidPathComponent reports whether s is safe to use as a single filesystem
// path component (cluster or host name) when building checkpoint/WAL paths. It
// rejects path-traversal sequences and embedded separators. An empty string is
// allowed because a missing host tag is legitimate and does not escape the root.
func isValidPathComponent(s string) bool {
	return !strings.Contains(s, "..") &&
		!strings.Contains(s, "/") &&
		!strings.Contains(s, "\\")
}

func DecodeLine(dec *lineprotocol.Decoder,


// isValidPathComponent reports whether s is safe to use as a single filesystem
// path component (cluster or host name) when building checkpoint/WAL paths. It
// rejects path-traversal sequences and embedded separators. An empty string is
// allowed because a missing host tag is legitimate and does not escape the root.
func isValidPathComponent(s string) bool {
	return !strings.Contains(s, "..") &&
		!strings.Contains(s, "/") &&
		!strings.Contains(s, "\\")
}

func DecodeLine(dec *lineprotocol.Decoder,

// When the checkpoint format is "wal" each successfully decoded sample is also

// sent to WALMessages so the WAL staging goroutine can persist it durably

+// isValidPathComponent reports whether s is safe to use as a single filesystem

+// path component (cluster or host name) when building checkpoint/WAL paths. It

+// rejects path-traversal sequences and embedded separators. An empty string is

+// allowed because a missing host tag is legitimate and does not escape the root.

+func isValidPathComponent(s string) bool {


Now let me build and run the relevant tests to confirm everything compiles and nothing broke:


Bash

Build entire backend


go vet ./internal/auth/... ./internal/repository/... ./pkg/metricstore/... 2>&1 | head -30

Vet changed packages


go test ./internal/auth/... ./internal/repository/... ./pkg/metricstore/... 2>&1 | tail -40

Run tests for changed packages


ok  	github.com/ClusterCockpit/cc-backend/internal/auth	0.542s
ok  	github.com/ClusterCockpit/cc-backend/internal/repository	1.370s
ok  	github.com/ClusterCockpit/cc-backend/pkg/metricstore	0.591s


All green. Let me sanity-check that legitimate metric names in this codebase only use `[a-zA-Z0-9_]` so my regex won't reject valid names:


grep -rhoE '"name"\s*:\s*"[^"]+"' configs/ pkg/archive/testdata/ internal/repository/testdata/ 2>/dev/null | grep -oE '"[^"]+"$' | sort -u | grep -vE '^"[a-zA-Z0-9_]+"$' | head -30; echo "=== (above = names with chars outside [a-zA-Z0-9_], if any) ==="

Find metric/config names with special chars


"<FILL IN>"
"CPU Load Imbalance"
"Excessive CPU load"
"High memory usage"
"Low CPU load"
"Low resource utilization"
"Memory bandwidth bound"
"Memory Leak Detection"
=== (above = names with chars outside [a-zA-Z0-9_], if any) ===


Those are human-readable tagger descriptions, not metric names. Let me confirm actual metric-config names specifically:


f=$(grep -rl 'MetricConfig\|metricConfig\|"footprint"' pkg/archive/testdata/ internal/ configs/ 2>/dev/null | grep -iE 'cluster.*json' | head -1); echo "file: $f"; grep -oE '"(flops_any|mem_bw|cpu_load|[a-z][a-z0-9_]*)"' "$f" 2>/dev/null | sort -u | head; echo "=== any metric name with non-word char in cluster configs ==="; grep -rhoE '"name"[: ]+"[^"]+"' $(grep -rl 'subClusters\|metricConfig' pkg/ internal/ configs/ 2>/dev/null | grep -i json) 2>/dev/null | grep -oE '"[^"]+"$'|sort -u|grep -vE '^"[a-zA-Z0-9_ ]+"$'|head

Check metric config names charset


file: pkg/archive/testdata/archive/alex/cluster.json
"a100"
"a100m80"
"a40"
"acc_mem_used"
"acc_power"
"acc_utilization"
"accelerator"
"accelerators"
"aggregation"
"alert"
=== any metric name with non-word char in cluster configs ===


All metric names use only `[a-z0-9_]` — my regex won't reject any legitimate names.

## Done — all three top items fixed

Build passes, `go vet` is clean, and the auth/repository/metricstore test suites all pass.

### 🔴 1. JWT empty-key auth bypass — `internal/auth/jwtSession.go`
- `Init()` now returns an error when `CROSS_LOGIN_JWT_HS512_KEY` is unset, so the authenticator is **no longer registered** without a key (instead of registering with a nil key). `auth.go` already handles that error by logging "No JWT login support!" and skipping registration.
- Added defense-in-depth in the keyfunc: rejects HS256/HS512 verification when `len(loginTokenKey) == 0`.

Re-ran my forgery PoC mentally against the new path: a key-less deployment no longer registers the authenticator, and even if reached, the empty-key guard rejects the token.

### 🔴 2. SQL injection via metric name — `internal/repository/stats.go`, `jobQuery.go`
- Added `validMetricName = ^[a-zA-Z0-9_]+$`.
- `jobsMetricStatisticsHistogram` returns an error for invalid metric names before any SQL interpolation.
- `buildFloatJSONCondition` (the `metricStats` filter path) fails closed with `WHERE 0 = 1` on an invalid name.
- Verified these are the only two `json_extract(footprint, "$.<name>")` interpolation sites; the running-jobs histogram builds bins in Go, not SQL. Confirmed all real metric names in the repo match the whitelist.

### 🟠 3. Path traversal in metric ingestion — `pkg/metricstore/lineprotocol.go`
- Added `isValidPathComponent()` (rejects `..`, `/`, `\`) and applied it to `cluster`/`host` right after tag parsing in `DecodeLine`, before they become tree keys or checkpoint/WAL path components. Offending samples are skipped with a warning; the rest of the batch proceeds. This blocks both the unauthenticated NATS path and `POST /api/write`.

I followed the existing in-repo conventions (mirrors `validatePathComponent` in `rest.go`, fail-closed query building, `cclog.Warnf` logging).

I did **not** commit — you're on `release/v1.5`. Want me to commit these (one commit, or one per finding), and/or move on to the medium-severity items (GraphQL complexity limit + `items-per-page` bounds, security headers, the `{@html}` XSS sink)?


Commit the findings in one commit


git status --short && echo "---" && git diff --stat

Review changes to commit


 M internal/auth/jwtSession.go
 M internal/repository/jobQuery.go
 M internal/repository/stats.go
 M pkg/metricstore/lineprotocol.go
---
 internal/auth/jwtSession.go     | 26 +++++++++++++++++++-------
 internal/repository/jobQuery.go | 10 ++++++++++
 internal/repository/stats.go    |  7 +++++++
 pkg/metricstore/lineprotocol.go | 22 ++++++++++++++++++++++
 4 files changed, 58 insertions(+), 7 deletions(-)


git add internal/auth/jwtSession.go internal/repository/jobQuery.go internal/repository/stats.go pkg/metricstore/lineprotocol.go && git commit -m "$(cat <<'EOF'
Fix critical issues from follow-up security audit

A second-pass audit surfaced three severe issues missed by the previous
review, each a sibling code path of a bug class that was only partially
fixed before:

- auth: JWT session login (jwtSession.go) registered its authenticator
  even when CROSS_LOGIN_JWT_HS512_KEY was unset, leaving an empty HMAC
  key. golang-jwt verifies any HS256/HS512 signature against an empty
  key, allowing unauthenticated admin token forgery. Init() now refuses
  to register without a key, with a defense-in-depth empty-key guard in
  the keyfunc.

- repository: metric names from GraphQL ([String!]) were interpolated
  raw into json_extract(footprint, "$.<name>") SQL. SQLite parses
  double-quoted strings as literals, enabling SQL injection by any
  authenticated user. Validate metric names against ^[a-zA-Z0-9_]+$ in
  jobsMetricStatisticsHistogram and buildFloatJSONCondition.

- metricstore: cluster/host line-protocol tags flowed unvalidated into
  path.Join(RootDir, cluster, host) for checkpoint/WAL files, allowing
  arbitrary file write outside the checkpoint root via NATS
  (unauthenticated) or POST /api/write. Reject path-traversal sequences
  in DecodeLine before the tags become path components.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
)"

Commit security fixes