@obsunified/collector 2.4.3 → 2.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,7 +7,10 @@ import {
7
7
  type ExportTraceServiceRequest,
8
8
  ExportTraceServiceRequestSchema,
9
9
  } from "../gen/opentelemetry/proto/collector/trace/v1/trace_service_pb.js";
10
- import type { ResourceSpans } from "../gen/opentelemetry/proto/trace/v1/trace_pb.js";
10
+ import type {
11
+ ResourceSpans,
12
+ Span,
13
+ } from "../gen/opentelemetry/proto/trace/v1/trace_pb.js";
11
14
  import { decodeJsonBody, OtlpDecodeError, type ReadBodyResult } from "./body";
12
15
  import { adaptKeyValue, bigintToString, bytesToHex } from "./values";
13
16
 
@@ -17,7 +20,57 @@ import { adaptKeyValue, bigintToString, bytesToHex } from "./values";
17
20
  */
18
21
  export const decodeTraceRequest = (
19
22
  body: ReadBodyResult,
20
- ): OtlpTraceExportRequest => {
23
+ ): OtlpTraceExportRequest =>
24
+ decodeTraceRequestCapped(body, Number.POSITIVE_INFINITY).request;
25
+
26
+ export interface CappedTraceRequest {
27
+ /** Adapted request holding at most `maxSpans` spans. */
28
+ request: OtlpTraceExportRequest;
29
+ /** Spans kept (≤ maxSpans). */
30
+ accepted: number;
31
+ /** Well-formed spans dropped because the cap was reached. */
32
+ rejected: number;
33
+ }
34
+
35
+ /**
36
+ * Like {@link decodeTraceRequest}, but applies the per-request span cap to
37
+ * the raw decoded message *before* adapting, so an oversized batch never
38
+ * gets a second, adapted in-memory copy. Spans with malformed trace/span
39
+ * ID lengths are skipped without counting against the cap — exactly as
40
+ * the adapter drops them — so the kept set is identical to adapting
41
+ * everything and keeping the first `maxSpans`.
42
+ */
43
+ export const decodeTraceRequestCapped = (
44
+ body: ReadBodyResult,
45
+ maxSpans: number,
46
+ ): CappedTraceRequest => {
47
+ const msg = parseTraceMessage(body);
48
+ let accepted = 0;
49
+ let rejected = 0;
50
+ const resourceSpans: OtlpResourceSpans[] = [];
51
+ for (const rs of msg.resourceSpans) {
52
+ for (const ss of rs.scopeSpans) {
53
+ const kept: Span[] = [];
54
+ for (const s of ss.spans) {
55
+ if (!hasValidIds(s)) continue;
56
+ if (accepted >= maxSpans) {
57
+ rejected++;
58
+ } else {
59
+ kept.push(s);
60
+ accepted++;
61
+ }
62
+ }
63
+ ss.spans = kept;
64
+ }
65
+ resourceSpans.push(adaptResourceSpans(rs));
66
+ }
67
+ return { request: { resourceSpans }, accepted, rejected };
68
+ };
69
+
70
+ const hasValidIds = (s: Span): boolean =>
71
+ s.traceId.length === 16 && s.spanId.length === 8;
72
+
73
+ const parseTraceMessage = (body: ReadBodyResult): ExportTraceServiceRequest => {
21
74
  let msg: ExportTraceServiceRequest;
22
75
  try {
23
76
  msg =
@@ -30,7 +83,7 @@ export const decodeTraceRequest = (
30
83
  400,
31
84
  );
32
85
  }
33
- return { resourceSpans: msg.resourceSpans.map(adaptResourceSpans) };
86
+ return msg;
34
87
  };
35
88
 
36
89
  // ── proto-native → legacy shape adapters ─────────────────────────────
@@ -44,7 +97,7 @@ const adaptResourceSpans = (rs: ResourceSpans): OtlpResourceSpans => ({
44
97
  ? { name: ss.scope.name, version: ss.scope.version }
45
98
  : undefined,
46
99
  spans: ss.spans.flatMap((s) => {
47
- if (s.traceId.length !== 16 || s.spanId.length !== 8) return [];
100
+ if (!hasValidIds(s)) return [];
48
101
  return [
49
102
  {
50
103
  traceId: bytesToHex(s.traceId),
@@ -55,23 +55,63 @@ export const anyValueToString = (v: AnyValue | undefined): string => {
55
55
  return bytesToBase64(v.value.value);
56
56
  case "arrayValue":
57
57
  case "kvlistValue":
58
- return JSON.stringify(adaptAnyValue(v));
58
+ return JSON.stringify(adaptAnyValueAt(v, 1));
59
59
  default:
60
60
  return "";
61
61
  }
62
62
  };
63
63
 
64
+ /**
65
+ * Max nesting depth for `arrayValue` / `kvlistValue` AnyValues. Real OTel
66
+ * attributes are a handful of levels deep at most; the cap keeps recursion
67
+ * bounded (no stack overflow on hostile payloads) and keeps stored JSON far
68
+ * below SQLite's 1000-level JSON depth limit. Containers nested deeper than
69
+ * this are replaced by {@link TRUNCATED_DEPTH_MARKER}.
70
+ */
71
+ export const MAX_ANY_VALUE_DEPTH = 64;
72
+ export const TRUNCATED_DEPTH_MARKER = `[truncated: nested deeper than ${MAX_ANY_VALUE_DEPTH} levels]`;
73
+
74
+ /**
75
+ * Assign as an own data property. Plain `out[key] = v` would treat a
76
+ * `__proto__` attribute key as a prototype setter and silently drop it;
77
+ * defineProperty stores it like any other key (and JSON.stringify emits it).
78
+ */
79
+ const setOwn = (
80
+ out: Record<string, ObsJsonValue>,
81
+ key: string,
82
+ value: ObsJsonValue,
83
+ ): void => {
84
+ if (key === "__proto__") {
85
+ Object.defineProperty(out, key, {
86
+ value,
87
+ enumerable: true,
88
+ writable: true,
89
+ configurable: true,
90
+ });
91
+ } else {
92
+ out[key] = value;
93
+ }
94
+ };
95
+
64
96
  export const keyValuesToRecord = (
65
97
  kvs: KeyValue[],
98
+ ): Record<string, ObsJsonValue> => keyValuesToRecordAt(kvs, 0);
99
+
100
+ const keyValuesToRecordAt = (
101
+ kvs: KeyValue[],
102
+ depth: number,
66
103
  ): Record<string, ObsJsonValue> => {
67
104
  const out: Record<string, ObsJsonValue> = {};
68
105
  for (const kv of kvs) {
69
- out[kv.key] = anyValueToJson(kv.value);
106
+ setOwn(out, kv.key, anyValueToJson(kv.value, depth + 1));
70
107
  }
71
108
  return out;
72
109
  };
73
110
 
74
- const anyValueToJson = (v: AnyValue | undefined): ObsJsonValue => {
111
+ const anyValueToJson = (
112
+ v: AnyValue | undefined,
113
+ depth: number,
114
+ ): ObsJsonValue => {
75
115
  if (!v) return null;
76
116
  switch (v.value.case) {
77
117
  case "stringValue":
@@ -86,20 +126,28 @@ const anyValueToJson = (v: AnyValue | undefined): ObsJsonValue => {
86
126
  case "bytesValue":
87
127
  return bytesToBase64(v.value.value);
88
128
  case "arrayValue":
89
- return v.value.value.values.map((x) => anyValueToJson(x));
129
+ if (depth > MAX_ANY_VALUE_DEPTH) return TRUNCATED_DEPTH_MARKER;
130
+ return v.value.value.values.map((x) => anyValueToJson(x, depth + 1));
90
131
  case "kvlistValue":
91
- return keyValuesToRecord(v.value.value.values);
132
+ if (depth > MAX_ANY_VALUE_DEPTH) return TRUNCATED_DEPTH_MARKER;
133
+ return keyValuesToRecordAt(v.value.value.values, depth);
92
134
  default:
93
135
  return null;
94
136
  }
95
137
  };
96
138
 
97
- export const adaptKeyValue = (kv: KeyValue): OtlpKeyValue => ({
139
+ export const adaptKeyValue = (kv: KeyValue): OtlpKeyValue =>
140
+ adaptKeyValueAt(kv, 0);
141
+
142
+ const adaptKeyValueAt = (kv: KeyValue, depth: number): OtlpKeyValue => ({
98
143
  key: kv.key,
99
- value: kv.value ? adaptAnyValue(kv.value) : undefined,
144
+ value: kv.value ? adaptAnyValueAt(kv.value, depth + 1) : undefined,
100
145
  });
101
146
 
102
- export const adaptAnyValue = (v: AnyValue): OtlpAnyValue => {
147
+ export const adaptAnyValue = (v: AnyValue): OtlpAnyValue =>
148
+ adaptAnyValueAt(v, 1);
149
+
150
+ const adaptAnyValueAt = (v: AnyValue, depth: number): OtlpAnyValue => {
103
151
  switch (v.value.case) {
104
152
  case "stringValue":
105
153
  return { stringValue: v.value.value };
@@ -110,15 +158,21 @@ export const adaptAnyValue = (v: AnyValue): OtlpAnyValue => {
110
158
  case "doubleValue":
111
159
  return { doubleValue: v.value.value };
112
160
  case "arrayValue":
161
+ if (depth > MAX_ANY_VALUE_DEPTH)
162
+ return { stringValue: TRUNCATED_DEPTH_MARKER };
113
163
  return {
114
164
  arrayValue: {
115
- values: v.value.value.values.map((x) => adaptAnyValue(x)),
165
+ values: v.value.value.values.map((x) =>
166
+ adaptAnyValueAt(x, depth + 1),
167
+ ),
116
168
  },
117
169
  };
118
170
  case "kvlistValue":
171
+ if (depth > MAX_ANY_VALUE_DEPTH)
172
+ return { stringValue: TRUNCATED_DEPTH_MARKER };
119
173
  return {
120
174
  kvlistValue: {
121
- values: v.value.value.values.map(adaptKeyValue),
175
+ values: v.value.value.values.map((kv) => adaptKeyValueAt(kv, depth)),
122
176
  },
123
177
  };
124
178
  case "bytesValue":
@@ -1,12 +1,17 @@
1
1
  import { create, toBinary, toJson } from "@bufbuild/protobuf";
2
2
  import { describe, expect, it } from "vitest";
3
+ import { toStoredSpans } from "../lib/otlp";
3
4
  import {
4
5
  decodeLogsRequest,
6
+ decodeLogsRequestCapped,
5
7
  decodeMetricsRequest,
8
+ decodeMetricsRequestCapped,
6
9
  decodeTraceRequest,
10
+ decodeTraceRequestCapped,
7
11
  OtlpDecodeError,
8
12
  readOtlpBody,
9
13
  } from "./decode";
14
+ import { MAX_ANY_VALUE_DEPTH, TRUNCATED_DEPTH_MARKER } from "./decode/values";
10
15
  import { ExportLogsServiceRequestSchema } from "./gen/opentelemetry/proto/collector/logs/v1/logs_service_pb.js";
11
16
  import { ExportMetricsServiceRequestSchema } from "./gen/opentelemetry/proto/collector/metrics/v1/metrics_service_pb.js";
12
17
  import { ExportTraceServiceRequestSchema } from "./gen/opentelemetry/proto/collector/trace/v1/trace_service_pb.js";
@@ -798,3 +803,303 @@ describe("decodeMetricsRequest", () => {
798
803
  ]);
799
804
  });
800
805
  });
806
+
807
+ // ── Security hardening: pre-adaptation caps, depth cap, __proto__ keys ──
808
+
809
+ const strAttr = (key: string, value: string) =>
810
+ create(KeyValueSchema, {
811
+ key,
812
+ value: create(AnyValueSchema, { value: { case: "stringValue", value } }),
813
+ });
814
+
815
+ /** AnyValue nested `levels` kvlists deep, innermost holding a string. */
816
+ const nestedKvlist = (levels: number) => {
817
+ let v = create(AnyValueSchema, {
818
+ value: { case: "stringValue", value: "leaf" },
819
+ });
820
+ for (let i = 0; i < levels; i++) {
821
+ v = create(AnyValueSchema, {
822
+ value: {
823
+ case: "kvlistValue",
824
+ value: { values: [create(KeyValueSchema, { key: "k", value: v })] },
825
+ },
826
+ });
827
+ }
828
+ return v;
829
+ };
830
+
831
+ const jsonDepth = (v: unknown): number => {
832
+ if (Array.isArray(v)) return 1 + Math.max(0, ...v.map(jsonDepth));
833
+ if (v && typeof v === "object")
834
+ return 1 + Math.max(0, ...Object.values(v).map(jsonDepth));
835
+ return 0;
836
+ };
837
+
838
+ const traceBatch = (spanCounts: number[], invalidEvery = 0) =>
839
+ create(ExportTraceServiceRequestSchema, {
840
+ resourceSpans: spanCounts.map((count, r) =>
841
+ create(ResourceSpansSchema, {
842
+ resource: create(ResourceSchema, {
843
+ attributes: [strAttr("service.name", `svc-${r}`)],
844
+ }),
845
+ scopeSpans: [
846
+ create(ScopeSpansSchema, {
847
+ spans: Array.from({ length: count }, (_, i) =>
848
+ create(SpanSchema, {
849
+ traceId:
850
+ invalidEvery && i % invalidEvery === 0
851
+ ? new Uint8Array(3)
852
+ : new Uint8Array(16).fill((i % 250) + 1),
853
+ spanId: new Uint8Array(8).fill((r % 250) + 1),
854
+ name: `op-${r}-${i}`,
855
+ }),
856
+ ),
857
+ }),
858
+ ],
859
+ }),
860
+ ),
861
+ });
862
+
863
+ describe("decodeTraceRequestCapped", () => {
864
+ it("keeps exactly the spans the post-adapt cap kept, and counts the rest", () => {
865
+ const bytes = toBinary(
866
+ ExportTraceServiceRequestSchema,
867
+ traceBatch([300, 400], 7),
868
+ );
869
+ const full = decodeTraceRequest({ bytes, wireFormat: "protobuf" });
870
+ const fullSpans = (full.resourceSpans ?? []).flatMap((rs) =>
871
+ (rs.scopeSpans ?? []).flatMap((ss) => ss.spans ?? []),
872
+ );
873
+ const capped = decodeTraceRequestCapped(
874
+ { bytes, wireFormat: "protobuf" },
875
+ 500,
876
+ );
877
+ const cappedSpans = capped.request.resourceSpans?.flatMap((rs) =>
878
+ (rs.scopeSpans ?? []).flatMap((ss) => ss.spans ?? []),
879
+ );
880
+ expect(capped.accepted).toBe(500);
881
+ expect(capped.rejected).toBe(fullSpans.length - 500);
882
+ expect(cappedSpans).toEqual(fullSpans.slice(0, 500));
883
+ // Resource/scope structure is preserved even when all its spans drop.
884
+ expect(capped.request.resourceSpans).toHaveLength(2);
885
+ });
886
+
887
+ it("does not reject anything under the cap (JSON)", () => {
888
+ const json = toJson(ExportTraceServiceRequestSchema, traceBatch([10, 5]));
889
+ const bytes = new TextEncoder().encode(JSON.stringify(json));
890
+ const capped = decodeTraceRequestCapped({ bytes, wireFormat: "json" }, 500);
891
+ expect(capped).toMatchObject({ accepted: 15, rejected: 0 });
892
+ expect(capped.request).toEqual(
893
+ decodeTraceRequest({ bytes, wireFormat: "json" }),
894
+ );
895
+ });
896
+
897
+ it("still surfaces malformed bodies as OtlpDecodeError 400", () => {
898
+ expect(() =>
899
+ decodeTraceRequestCapped(
900
+ { bytes: new Uint8Array([0xff, 0xff]), wireFormat: "protobuf" },
901
+ 500,
902
+ ),
903
+ ).toThrow(OtlpDecodeError);
904
+ });
905
+ });
906
+
907
+ describe("decodeLogsRequestCapped", () => {
908
+ const logsBatch = (counts: number[]) =>
909
+ create(ExportLogsServiceRequestSchema, {
910
+ resourceLogs: counts.map((count, r) =>
911
+ create(ResourceLogsSchema, {
912
+ resource: create(ResourceSchema, {
913
+ attributes: [strAttr("service.name", `svc-${r}`)],
914
+ }),
915
+ scopeLogs: [
916
+ create(ScopeLogsSchema, {
917
+ logRecords: Array.from({ length: count }, (_, i) =>
918
+ create(LogRecordSchema, {
919
+ timeUnixNano: 1_700_000_000_000_000_000n,
920
+ body: create(AnyValueSchema, {
921
+ value: { case: "stringValue", value: `m-${r}-${i}` },
922
+ }),
923
+ }),
924
+ ),
925
+ }),
926
+ ],
927
+ }),
928
+ ),
929
+ });
930
+
931
+ it("equals decode-then-slice and reports the same rejected count", () => {
932
+ const bytes = toBinary(
933
+ ExportLogsServiceRequestSchema,
934
+ logsBatch([600, 300, 400]),
935
+ );
936
+ const full = decodeLogsRequest({ bytes, wireFormat: "protobuf" });
937
+ const capped = decodeLogsRequestCapped(
938
+ { bytes, wireFormat: "protobuf" },
939
+ 1000,
940
+ );
941
+ expect(capped.rejected).toBe(full.length - 1000);
942
+ expect(capped.records.map((r) => r.message)).toEqual(
943
+ full.slice(0, 1000).map((r) => r.message),
944
+ );
945
+ });
946
+ });
947
+
948
+ describe("decodeMetricsRequestCapped", () => {
949
+ const gauge = (name: string, n: number) =>
950
+ create(MetricSchema, {
951
+ name,
952
+ data: {
953
+ case: "gauge",
954
+ value: create(GaugeSchema, {
955
+ dataPoints: Array.from({ length: n }, (_, i) =>
956
+ create(NumberDataPointSchema, {
957
+ timeUnixNano: 1_700_000_000_000_000_000n + BigInt(i),
958
+ value: { case: "asDouble", value: i },
959
+ }),
960
+ ),
961
+ }),
962
+ },
963
+ });
964
+ const metricsBatch = () =>
965
+ create(ExportMetricsServiceRequestSchema, {
966
+ resourceMetrics: [
967
+ create(ResourceMetricsSchema, {
968
+ scopeMetrics: [
969
+ create(ScopeMetricsSchema, {
970
+ metrics: [gauge("a", 1500), gauge("b", 800)],
971
+ }),
972
+ ],
973
+ }),
974
+ create(ResourceMetricsSchema, {
975
+ scopeMetrics: [
976
+ create(ScopeMetricsSchema, { metrics: [gauge("c", 50)] }),
977
+ ],
978
+ }),
979
+ ],
980
+ });
981
+
982
+ it("equals decode-then-slice and reports the same rejected count", () => {
983
+ const bytes = toBinary(ExportMetricsServiceRequestSchema, metricsBatch());
984
+ const full = decodeMetricsRequest({ bytes, wireFormat: "protobuf" });
985
+ const capped = decodeMetricsRequestCapped(
986
+ { bytes, wireFormat: "protobuf" },
987
+ 2000,
988
+ );
989
+ expect(full).toHaveLength(2350);
990
+ expect(capped.rejected).toBe(350);
991
+ expect(capped.points).toEqual(full.slice(0, 2000));
992
+ });
993
+ });
994
+
995
+ describe("AnyValue nesting depth cap", () => {
996
+ const logWithAttr = (value: ReturnType<typeof nestedKvlist>) =>
997
+ toBinary(
998
+ ExportLogsServiceRequestSchema,
999
+ create(ExportLogsServiceRequestSchema, {
1000
+ resourceLogs: [
1001
+ create(ResourceLogsSchema, {
1002
+ scopeLogs: [
1003
+ create(ScopeLogsSchema, {
1004
+ logRecords: [
1005
+ create(LogRecordSchema, {
1006
+ attributes: [
1007
+ create(KeyValueSchema, { key: "deep", value }),
1008
+ ],
1009
+ }),
1010
+ ],
1011
+ }),
1012
+ ],
1013
+ }),
1014
+ ],
1015
+ }),
1016
+ );
1017
+
1018
+ it("leaves realistic nesting untouched", () => {
1019
+ const [log] = decodeLogsRequest({
1020
+ bytes: logWithAttr(nestedKvlist(MAX_ANY_VALUE_DEPTH)),
1021
+ wireFormat: "protobuf",
1022
+ });
1023
+ expect(JSON.stringify(log?.attributes)).not.toContain("truncated");
1024
+ expect(jsonDepth(log?.attributes)).toBe(MAX_ANY_VALUE_DEPTH + 1);
1025
+ });
1026
+
1027
+ it("truncates attributes nested past the cap (logs/metrics path)", () => {
1028
+ const [log] = decodeLogsRequest({
1029
+ bytes: logWithAttr(nestedKvlist(400)),
1030
+ wireFormat: "protobuf",
1031
+ });
1032
+ const serialized = JSON.stringify(log?.attributes);
1033
+ expect(serialized).toContain(TRUNCATED_DEPTH_MARKER);
1034
+ expect(jsonDepth(log?.attributes)).toBeLessThanOrEqual(
1035
+ MAX_ANY_VALUE_DEPTH + 1,
1036
+ );
1037
+ });
1038
+
1039
+ it("truncates span attributes nested past the cap (traces path)", () => {
1040
+ const msg = traceBatch([1]);
1041
+ const span = msg.resourceSpans[0]?.scopeSpans[0]?.spans[0];
1042
+ if (!span) throw new Error("fixture");
1043
+ span.attributes = [
1044
+ create(KeyValueSchema, { key: "deep", value: nestedKvlist(400) }),
1045
+ ];
1046
+ const bytes = toBinary(ExportTraceServiceRequestSchema, msg);
1047
+ const stored = toStoredSpans(
1048
+ decodeTraceRequest({ bytes, wireFormat: "protobuf" }),
1049
+ "p",
1050
+ new Date(),
1051
+ 72,
1052
+ );
1053
+ const attrs = JSON.parse(stored[0]?.attributesJson ?? "{}");
1054
+ expect(stored[0]?.attributesJson).toContain(TRUNCATED_DEPTH_MARKER);
1055
+ expect(jsonDepth(attrs)).toBeLessThanOrEqual(MAX_ANY_VALUE_DEPTH + 1);
1056
+ });
1057
+ });
1058
+
1059
+ describe("__proto__ attribute keys", () => {
1060
+ it("are stored as normal own keys, not dropped or used as a prototype", () => {
1061
+ const bytes = toBinary(
1062
+ ExportLogsServiceRequestSchema,
1063
+ create(ExportLogsServiceRequestSchema, {
1064
+ resourceLogs: [
1065
+ create(ResourceLogsSchema, {
1066
+ scopeLogs: [
1067
+ create(ScopeLogsSchema, {
1068
+ logRecords: [
1069
+ create(LogRecordSchema, {
1070
+ attributes: [
1071
+ strAttr("__proto__", "x"),
1072
+ create(KeyValueSchema, {
1073
+ key: "nested",
1074
+ value: create(AnyValueSchema, {
1075
+ value: {
1076
+ case: "kvlistValue",
1077
+ value: {
1078
+ values: [
1079
+ create(KeyValueSchema, {
1080
+ key: "__proto__",
1081
+ value: nestedKvlist(1),
1082
+ }),
1083
+ ],
1084
+ },
1085
+ },
1086
+ }),
1087
+ }),
1088
+ ],
1089
+ }),
1090
+ ],
1091
+ }),
1092
+ ],
1093
+ }),
1094
+ ],
1095
+ }),
1096
+ );
1097
+ const [log] = decodeLogsRequest({ bytes, wireFormat: "protobuf" });
1098
+ const attrs = log?.attributes ?? {};
1099
+ expect(Object.keys(attrs)).toEqual(["__proto__", "nested"]);
1100
+ expect(Object.getPrototypeOf(attrs)).toBe(Object.prototype);
1101
+ expect(JSON.stringify(attrs)).toBe(
1102
+ '{"__proto__":"x","nested":{"__proto__":{"k":"leaf"}}}',
1103
+ );
1104
+ });
1105
+ });
@@ -1,11 +1,17 @@
1
1
  export type { OtlpWireFormat, ReadBodyResult } from "./decode/body";
2
2
  export { OtlpDecodeError, readOtlpBody } from "./decode/body";
3
3
  export type { DecodedLogRecord } from "./decode/logs";
4
- export { decodeLogsRequest } from "./decode/logs";
4
+ export { decodeLogsRequest, decodeLogsRequestCapped } from "./decode/logs";
5
5
  export type {
6
6
  DecodedExemplar,
7
7
  DecodedMetricPoint,
8
8
  MetricType,
9
9
  } from "./decode/metrics";
10
- export { decodeMetricsRequest } from "./decode/metrics";
11
- export { decodeTraceRequest } from "./decode/traces";
10
+ export {
11
+ decodeMetricsRequest,
12
+ decodeMetricsRequestCapped,
13
+ } from "./decode/metrics";
14
+ export {
15
+ decodeTraceRequest,
16
+ decodeTraceRequestCapped,
17
+ } from "./decode/traces";
@@ -104,6 +104,15 @@ const isAgentAutonomyLevel = (value: unknown): value is AgentAutonomyLevel =>
104
104
  * Enforces Review Item #4: POST /internal/remediations/:id/approve and /reject
105
105
  * require an authenticated human dashboard session (`obs_session` cookie) and
106
106
  * reject programmatic agent/MCP bearer tokens (`403 Forbidden`).
107
+ *
108
+ * Trust model: this only checks that an `obs_session` cookie is *present*.
109
+ * With the built-in `createDashboardAuth()` the cookie's HMAC signature and
110
+ * expiry are verified by the `/internal/*` middleware before this runs, so
111
+ * a forged cookie never gets here. Hosts that supply their own
112
+ * `dashboardAuth` middleware own that verification: the route cannot tell
113
+ * which auth is mounted, and re-verifying against DASHBOARD_PASSWORD here
114
+ * would break custom hosts whose sessions are signed differently. See the
115
+ * "Who counts as a human approver" note in apps/docs autosre.mdx.
107
116
  */
108
117
  async function requireHumanDashboardSession(c: {
109
118
  req: { header: (name: string) => string | undefined };
@@ -6,7 +6,7 @@ import { sqlDbFor } from "../lib/sql-db";
6
6
  import { logToTailEvent, publishTail } from "../lib/tail-publisher";
7
7
  import {
8
8
  type DecodedLogRecord,
9
- decodeLogsRequest,
9
+ decodeLogsRequestCapped,
10
10
  OtlpDecodeError,
11
11
  type ReadBodyResult,
12
12
  readOtlpBody,
@@ -32,9 +32,15 @@ export const logsReceiverPlugin: CollectorPlugin = {
32
32
  throw err;
33
33
  }
34
34
 
35
+ // The record cap is applied during decode, before adaptation, so
36
+ // an oversized batch never gets a second full in-memory copy.
35
37
  let decoded: DecodedLogRecord[];
38
+ let rejected = 0;
36
39
  try {
37
- decoded = decodeLogsRequest(body);
40
+ ({ records: decoded, rejected } = decodeLogsRequestCapped(
41
+ body,
42
+ MAX_LOGS_PER_REQUEST,
43
+ ));
38
44
  } catch (err) {
39
45
  if (err instanceof OtlpDecodeError) {
40
46
  return c.json({ error: err.message }, err.status);
@@ -42,12 +48,6 @@ export const logsReceiverPlugin: CollectorPlugin = {
42
48
  throw err;
43
49
  }
44
50
 
45
- let rejected = 0;
46
- if (decoded.length > MAX_LOGS_PER_REQUEST) {
47
- rejected = decoded.length - MAX_LOGS_PER_REQUEST;
48
- decoded = decoded.slice(0, MAX_LOGS_PER_REQUEST);
49
- }
50
-
51
51
  const store = new LogsStore(sqlDbFor(c.env));
52
52
  const retentionHours = getConfiguredRetentionHours(c.env.RETENTION_HOURS);
53
53
  const now = new Date();
@@ -5,7 +5,8 @@ import type { CollectorPlugin } from "../framework/collector";
5
5
  import { MetricsStore } from "../lib/metrics-store";
6
6
  import { sqlDbFor } from "../lib/sql-db";
7
7
  import {
8
- decodeMetricsRequest,
8
+ type DecodedMetricPoint,
9
+ decodeMetricsRequestCapped,
9
10
  OtlpDecodeError,
10
11
  type ReadBodyResult,
11
12
  readOtlpBody,
@@ -31,9 +32,15 @@ export const metricsReceiverPlugin: CollectorPlugin = {
31
32
  throw err;
32
33
  }
33
34
 
34
- let points: ReturnType<typeof decodeMetricsRequest>;
35
+ // The point cap is applied during decode, before adaptation, so
36
+ // an oversized batch never gets a second full in-memory copy.
37
+ let points: DecodedMetricPoint[];
38
+ let rejected = 0;
35
39
  try {
36
- points = decodeMetricsRequest(body);
40
+ ({ points, rejected } = decodeMetricsRequestCapped(
41
+ body,
42
+ MAX_POINTS_PER_REQUEST,
43
+ ));
37
44
  } catch (err) {
38
45
  if (err instanceof OtlpDecodeError) {
39
46
  return c.json({ error: err.message }, err.status);
@@ -41,12 +48,6 @@ export const metricsReceiverPlugin: CollectorPlugin = {
41
48
  throw err;
42
49
  }
43
50
 
44
- let rejected = 0;
45
- if (points.length > MAX_POINTS_PER_REQUEST) {
46
- rejected = points.length - MAX_POINTS_PER_REQUEST;
47
- points = points.slice(0, MAX_POINTS_PER_REQUEST);
48
- }
49
-
50
51
  const retentionHours = getConfiguredRetentionHours(c.env.RETENTION_HOURS);
51
52
  const now = new Date();
52
53
  const receivedAt = now.toISOString();