File size: 4,800 Bytes
eeb9404
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
/**
 * Outbound fetch proxy. The browser-side agent cannot fetch cross-origin, so
 * external curl routes through here. Server-side fetch with SSRF protection,
 * size/timeout caps, same-origin validation, a kill-switch, and rate limiting.
 * Nothing is persisted.
 */
import { NextRequest, NextResponse } from 'next/server';
import { assertPublicUrl } from '@/lib/web/ssrf-guard';
import { webRequestPreflight } from '@/lib/web/request-guards';

export const runtime = 'nodejs';

const MAX_BYTES = 5 * 1024 * 1024;
const TIMEOUT_MS = 20_000;
const MAX_REDIRECTS = 5;

const TEXT_TYPES = [
  'text/', 'application/json', 'application/xml', '+xml',
  'application/javascript', 'application/x-javascript', 'image/svg+xml',
];
function isTextType(ct: string): boolean {
  const c = ct.toLowerCase();
  return TEXT_TYPES.some(t => c.includes(t));
}

async function readCapped(resp: Response): Promise<{ bytes: Uint8Array; truncated: boolean }> {
  const reader = resp.body?.getReader();
  if (!reader) return { bytes: new Uint8Array(0), truncated: false };
  const chunks: Uint8Array[] = [];
  let total = 0;
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    if (value) {
      total += value.length;
      if (total > MAX_BYTES) { reader.cancel(); return { bytes: new Uint8Array(0), truncated: true }; }
      chunks.push(value);
    }
  }
  const out = new Uint8Array(total);
  let off = 0;
  for (const c of chunks) { out.set(c, off); off += c.length; }
  return { bytes: out, truncated: false };
}

export async function POST(request: NextRequest) {
  const pre = webRequestPreflight(request);
  if (!pre.ok) return pre.response;

  let payload: { url?: string; method?: string; headers?: string[]; body?: string };
  try { payload = await request.json(); }
  catch { return NextResponse.json({ error: 'invalid request body' }, { status: 400 }); }

  const { url, method = 'GET', headers = [], body } = payload;
  if (!url || typeof url !== 'string') {
    return NextResponse.json({ error: 'missing url' }, { status: 400 });
  }

  // Parse the curl-style header list ("Name: value") into a Headers object.
  const outHeaders = new Headers();
  for (const h of headers) {
    const idx = h.indexOf(':');
    if (idx > 0) outHeaders.set(h.slice(0, idx).trim(), h.slice(idx + 1).trim());
  }

  try {
    let current = url;
    let response: Response | null = null;
    for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
      await assertPublicUrl(current); // throws on private/blocked/invalid
      // Note: a small residual TOCTOU window remains between the DNS resolve in
      // assertPublicUrl and the actual connect below. Full IP-pinning is out of
      // scope for this threat model since the response only enters the model
      // context, not secrets.
      response = await fetch(current, {
        method,
        headers: outHeaders,
        body: method === 'GET' || method === 'HEAD' ? undefined : body,
        redirect: 'manual',
        signal: AbortSignal.timeout(TIMEOUT_MS),
      });
      // Manual redirect handling: re-validate each Location before following.
      if (response.status >= 300 && response.status < 400 && response.headers.get('location')) {
        const loc = response.headers.get('location')!;
        current = new URL(loc, current).toString();
        if (hop === MAX_REDIRECTS) {
          return NextResponse.json({ error: 'too many redirects' }, { status: 200 });
        }
        continue;
      }
      break;
    }
    if (!response) return NextResponse.json({ error: 'no response' }, { status: 200 });

    const contentType = response.headers.get('content-type') || 'application/octet-stream';

    // HEAD: return status + headers only
    if (method === 'HEAD') {
      return NextResponse.json({ status: response.status, contentType, encoding: 'utf8', body: '' });
    }

    const { bytes, truncated } = await readCapped(response);
    if (truncated) return NextResponse.json({ error: 'response too large (>5MB)' }, { status: 200 });

    if (isTextType(contentType)) {
      const text = new TextDecoder().decode(bytes);
      return NextResponse.json({ status: response.status, contentType, encoding: 'utf8', body: text });
    }
    const base64 = Buffer.from(bytes).toString('base64');
    return NextResponse.json({ status: response.status, contentType, encoding: 'base64', body: base64 });
  } catch (e: unknown) {
    const msg = e instanceof Error ? e.message : 'request failed';
    // assertPublicUrl throws 'blocked: ...'; timeouts throw a DOMException named TimeoutError.
    if (e instanceof Error && e.name === 'TimeoutError') {
      return NextResponse.json({ error: 'timeout after 20s' }, { status: 200 });
    }
    return NextResponse.json({ error: msg }, { status: 200 });
  }
}