@goodandready/dsh-agent-orchestrator 0.1.6

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.
@@ -0,0 +1,166 @@
1
+ /**
2
+ * HTTP guard utilities for protecting write endpoints (POST/PUT/DELETE)
3
+ * against drive-by CSRF, unauthorized cross-network access, and cross-origin tampering.
4
+ *
5
+ * Implements Issue #113:
6
+ * 1. Loopback / Origin / Host validation
7
+ * 2. Sec-Fetch-Site and Referer verification
8
+ * 3. Body size limiter (default 1MB) with fail-closed rejection
9
+ */
10
+
11
+ export const DEFAULT_MAX_BODY_BYTES = 1024 * 1024 // 1MB
12
+
13
+ export function isLoopbackAddress(ip) {
14
+ if (!ip || typeof ip !== 'string') return false
15
+ return (
16
+ ip === '127.0.0.1' ||
17
+ ip === '::1' ||
18
+ ip === '::ffff:127.0.0.1' ||
19
+ ip.startsWith('127.')
20
+ )
21
+ }
22
+
23
+ export function getClientIp(req) {
24
+ return (
25
+ req.socket?.remoteAddress ||
26
+ req.connection?.remoteAddress ||
27
+ req.info?.remoteAddress ||
28
+ ''
29
+ )
30
+ }
31
+
32
+ /**
33
+ * Checks whether an incoming HTTP request is safe for mutation or sensitive dispatch.
34
+ *
35
+ * Requirements:
36
+ * 1. If Origin header is present:
37
+ * - must not be "null" or empty
38
+ * - parsed Origin host must match Host header
39
+ * 2. If Referer header is present:
40
+ * - parsed Referer host must match Host header
41
+ * 3. If Sec-Fetch-Site header is present:
42
+ * - must be "same-origin" or "none" (cross-site and same-site are rejected)
43
+ * 4. If neither Origin nor Sec-Fetch-Site is present:
44
+ * - only local loopback clients (127.0.0.1, ::1) are permitted.
45
+ */
46
+ export function isTrustedWriteRequest(req) {
47
+ const headers = req.headers || {}
48
+ const host = headers.host || ''
49
+
50
+ const origin = headers.origin
51
+ if (origin !== undefined) {
52
+ if (origin === 'null' || !origin) return false
53
+ try {
54
+ const parsed = new URL(origin)
55
+ if (parsed.host && host && parsed.host !== host) return false
56
+ } catch {
57
+ return false
58
+ }
59
+ }
60
+
61
+ const referer = headers.referer
62
+ if (referer !== undefined && referer) {
63
+ try {
64
+ const parsed = new URL(referer)
65
+ if (parsed.host && host && parsed.host !== host) return false
66
+ } catch {
67
+ return false
68
+ }
69
+ }
70
+
71
+ const site = headers['sec-fetch-site']
72
+ if (site !== undefined && site) {
73
+ if (site !== 'same-origin' && site !== 'none') return false
74
+ }
75
+
76
+ // If both origin and sec-fetch-site are absent, permit only loopback callers
77
+ if (origin === undefined && site === undefined) {
78
+ const ip = getClientIp(req)
79
+ if (!isLoopbackAddress(ip)) {
80
+ return false
81
+ }
82
+ }
83
+
84
+ return true
85
+ }
86
+
87
+ export function rejectUntrustedRequest(req, res) {
88
+ if (!isTrustedWriteRequest(req)) {
89
+ try {
90
+ res.statusCode = 403
91
+ res.setHeader('Content-Type', 'application/json; charset=utf-8')
92
+ res.setHeader('Cache-Control', 'no-store')
93
+ res.end(
94
+ JSON.stringify({
95
+ ok: false,
96
+ error: {
97
+ code: 'forbidden',
98
+ message: 'Forbidden: same-origin or local loopback only',
99
+ },
100
+ })
101
+ )
102
+ } catch {
103
+ /* socket closed */
104
+ }
105
+ return true
106
+ }
107
+ return false
108
+ }
109
+
110
+ /**
111
+ * Parses JSON request body with explicit payload size limit (Issue #113).
112
+ * Throws an error if body exceeds maxBytes or is invalid JSON.
113
+ *
114
+ * @param {import('http').IncomingMessage} req
115
+ * @param {number} [maxBytes=DEFAULT_MAX_BODY_BYTES]
116
+ * @returns {Promise<object>}
117
+ */
118
+ export function parseBoundedJsonBody(req, maxBytes = DEFAULT_MAX_BODY_BYTES) {
119
+ return new Promise((resolve, reject) => {
120
+ let body = ''
121
+ let receivedBytes = 0
122
+
123
+ const onData = (chunk) => {
124
+ receivedBytes += chunk.length
125
+ if (receivedBytes > maxBytes) {
126
+ req.pause()
127
+ cleanup()
128
+ const err = new Error(`Payload Too Large: body exceeds limit of ${maxBytes} bytes`)
129
+ err.statusCode = 413
130
+ reject(err)
131
+ return
132
+ }
133
+ body += chunk
134
+ }
135
+
136
+ const onEnd = () => {
137
+ cleanup()
138
+ if (!body || body.trim() === '') {
139
+ return resolve({})
140
+ }
141
+ try {
142
+ const parsed = JSON.parse(body)
143
+ resolve(parsed)
144
+ } catch (e) {
145
+ const err = new Error(`Invalid JSON body: ${e.message}`)
146
+ err.statusCode = 400
147
+ reject(err)
148
+ }
149
+ }
150
+
151
+ const onError = (err) => {
152
+ cleanup()
153
+ reject(err)
154
+ }
155
+
156
+ function cleanup() {
157
+ req.removeListener('data', onData)
158
+ req.removeListener('end', onEnd)
159
+ req.removeListener('error', onError)
160
+ }
161
+
162
+ req.on('data', onData)
163
+ req.on('end', onEnd)
164
+ req.on('error', onError)
165
+ })
166
+ }