wiki-plugin-hitchhiker 0.3.0 → 0.3.1

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/server/server.js +38 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-plugin-hitchhiker",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "House UX + default-pages plugin for the hitchhiker farms: ships default pages (Tools, Changes to this Site) and hosts the wiki-ux.js client override (friendlier factory chooser + page-flag tool page), loaded globally by the wiki-security-hitchhiker bootstrap.",
5
5
  "type": "module",
6
6
  "files": [
package/server/server.js CHANGED
@@ -17,8 +17,9 @@
17
17
  // the client: on any failure it returns a minimal card so a drop always works.
18
18
  //
19
19
  // v1 scope: private farm (owner-only). Before public use, harden SSRF against
20
- // redirect-following (the extractor libs do their own fetch and may follow
21
- // redirects to internal hosts) and tighten image caps.
20
+ // redirect-following: since 0.3.1 the OpenGraph and image fetches walk redirects
21
+ // by hand and re-check every hop (safeFetch); the oEmbed endpoint hop remains
22
+ // the library's own — see fromOembed.
22
23
 
23
24
  import dns from 'node:dns/promises'
24
25
  import net from 'node:net'
@@ -116,12 +117,34 @@ const assertPublicUrl = async raw => {
116
117
  return u
117
118
  }
118
119
 
120
+ // ── a fetch that re-checks every hop ─────────────────────────────────────────
121
+ // redirect:'follow' would let a public host 302 us onto 127.0.0.1 after the
122
+ // guard has already passed the starting URL. So follow by hand: manual
123
+ // redirects, assertPublicUrl on each Location, and a small hop cap.
124
+ const MAX_HOPS = 4
125
+ const safeFetch = async (raw, init = {}) => {
126
+ let url = raw
127
+ for (let hop = 0; hop <= MAX_HOPS; hop++) {
128
+ await assertPublicUrl(url)
129
+ const r = await fetch(url, { ...init, redirect: 'manual' })
130
+ if (r.status >= 300 && r.status < 400) {
131
+ const loc = r.headers.get('location')
132
+ if (!loc) return r
133
+ url = new URL(loc, url).href
134
+ continue
135
+ }
136
+ return r
137
+ }
138
+ throw new Error('too many redirects')
139
+ }
140
+
141
+ export const __safeFetchForTest = (...a) => safeFetch(...a)
142
+
119
143
  // ── thumbnail → data URI (no client→provider request when the card renders) ───
120
144
  const inlineImage = async imgUrl => {
121
145
  if (!imgUrl) return ''
122
146
  try {
123
- await assertPublicUrl(imgUrl)
124
- const r = await fetch(imgUrl, { signal: AbortSignal.timeout(FETCH_MS), redirect: 'follow' })
147
+ const r = await safeFetch(imgUrl, { signal: AbortSignal.timeout(FETCH_MS) })
125
148
  if (!r.ok) return ''
126
149
  const type = r.headers.get('content-type') || ''
127
150
  if (!/^image\//i.test(type)) return ''
@@ -138,6 +161,12 @@ const inlineImage = async imgUrl => {
138
161
  // ── extractors ────────────────────────────────────────────────────────────────
139
162
  const fromOembed = async url => {
140
163
  try {
164
+ // Residual: the extractor discovers an oEmbed endpoint from the page and
165
+ // fetches it with its own client. The starting URL is checked by the route,
166
+ // the thumbnail it returns is re-checked by inlineImage, but the endpoint
167
+ // hop itself is the library's. Acceptable on a host that only unfurls
168
+ // public URLs; not acceptable as the only guard. Hence the redirect-checked
169
+ // path for OpenGraph and the image, which are the two fetches we control.
141
170
  const d = await oembedExtract(url, {}, { signal: AbortSignal.timeout(FETCH_MS) })
142
171
  if (!d) return null
143
172
  return {
@@ -156,7 +185,11 @@ const fromOembed = async url => {
156
185
 
157
186
  const fromOpenGraph = async url => {
158
187
  try {
159
- const r = await ogs({ url, fetchOptions: { signal: AbortSignal.timeout(FETCH_MS) } })
188
+ // Walk redirects ourselves so every hop is checked, then hand the library
189
+ // the final URL with redirects off. It cannot be led anywhere we did not look.
190
+ const probe = await safeFetch(url, { method: 'GET', signal: AbortSignal.timeout(FETCH_MS) })
191
+ const finalUrl = probe.url || url
192
+ const r = await ogs({ url: finalUrl, fetchOptions: { signal: AbortSignal.timeout(FETCH_MS), redirect: 'manual' } })
160
193
  if (!r || r.error || !r.result) return null
161
194
  const x = r.result
162
195
  const img =