← back to Directory Core

test/compliance-ssrf.test.ts

257 lines

// test/compliance-ssrf.test.ts
// Tests for the SSRF guard in src/compliance.ts (assertPublicHost, accessed
// indirectly via fetchCompliant and getRobotsFor).
//
// Strategy:
//   - Mock dns.resolve4 / dns.resolve6 via mock.method from node:test.
//   - Mock undici fetch via MockAgent so no real HTTP leaves the process.
//   - Use unique hostnames per describe block so the module-level robotsCache
//     never causes cross-test contamination.
//
// DATABASE_URL must be set before db.ts (transitively imported) loads.

process.env['DATABASE_URL'] = process.env['DATABASE_URL'] || 'postgresql://fake:fake@localhost:5432/fake';

import { describe, it, before, after, mock } from 'node:test';
import assert from 'node:assert/strict';
import dns from 'node:dns/promises';
import { MockAgent, setGlobalDispatcher, getGlobalDispatcher } from 'undici';

import { fetchCompliant } from '../src/compliance.js';

// ── helpers ───────────────────────────────────────────────────────────────────

type RealDispatcher = ReturnType<typeof getGlobalDispatcher>;

function makeMockAgent(): MockAgent {
  const agent = new MockAgent();
  agent.disableNetConnect();
  setGlobalDispatcher(agent);
  return agent;
}

/** Wire up a MockAgent that replies 200 to every path on the given host scheme+host. */
function allow200(agent: MockAgent, schemeHost: string): void {
  // robots.txt
  agent.get(schemeHost)
    .intercept({ path: '/robots.txt', method: 'GET' })
    .reply(200, 'User-agent: *\nAllow: /', { headers: { 'content-type': 'text/plain' } });
  // actual page
  agent.get(schemeHost)
    .intercept({ path: '/x', method: 'GET' })
    .reply(200, 'OK', { headers: { 'content-type': 'text/plain' } });
}

// ── public host succeeds ──────────────────────────────────────────────────────

describe('fetchCompliant — SSRF: public IP passes through', () => {
  const HOST = 'http://ssrf-public-unique.example';
  let agent: MockAgent;
  let realDispatcher: RealDispatcher;
  let r4: ReturnType<typeof mock.method>;
  let r6: ReturnType<typeof mock.method>;

  before(() => {
    realDispatcher = getGlobalDispatcher();
    agent = makeMockAgent();
    allow200(agent, HOST);
    // Resolve to a real public address
    r4 = mock.method(dns, 'resolve4', async () => ['1.2.3.4']);
    r6 = mock.method(dns, 'resolve6', async () => { throw Object.assign(new Error('ENODATA'), { code: 'ENODATA' }); });
  });

  after(async () => {
    r4.mock.restore();
    r6.mock.restore();
    setGlobalDispatcher(realDispatcher);
    await agent.close();
  });

  it('resolves to 1.2.3.4 and fetches successfully', async () => {
    const res = await fetchCompliant(`${HOST}/x`, { respectRobots: false });
    assert.equal(res.status, 200);
  });
});

// ── private IPv4 ranges are blocked ──────────────────────────────────────────

const privateIPv4Cases: Array<{ addr: string; label: string }> = [
  { addr: '10.0.0.5',       label: '10.x RFC1918' },
  { addr: '192.168.1.1',    label: '192.168.x RFC1918' },
  { addr: '172.16.0.1',     label: '172.16.x RFC1918' },
  { addr: '127.0.0.1',      label: '127.x loopback' },
  { addr: '169.254.169.254', label: '169.254.169.254 cloud metadata' },
  { addr: '100.64.0.1',     label: '100.64.x CGNAT' },
];

for (const { addr, label } of privateIPv4Cases) {
  describe(`fetchCompliant — SSRF blocked: ${label} (${addr})`, () => {
    // Use a unique hostname per case to avoid robotsCache hits from prior cases
    const HOST = `http://ssrf-priv-${addr.replace(/\./g, '-')}.example`;
    let agent: MockAgent;
    let realDispatcher: RealDispatcher;
    let r4: ReturnType<typeof mock.method>;
    let r6: ReturnType<typeof mock.method>;

    before(() => {
      realDispatcher = getGlobalDispatcher();
      agent = makeMockAgent();
      r4 = mock.method(dns, 'resolve4', async () => [addr]);
      r6 = mock.method(dns, 'resolve6', async () => { throw Object.assign(new Error('ENODATA'), { code: 'ENODATA' }); });
    });

    after(async () => {
      r4.mock.restore();
      r6.mock.restore();
      setGlobalDispatcher(realDispatcher);
      await agent.close();
    });

    it(`throws SSRF_BLOCKED when DNS returns ${addr}`, async () => {
      await assert.rejects(
        () => fetchCompliant(`${HOST}/x`, { respectRobots: false }),
        (err: unknown) => {
          assert.ok(err instanceof Error);
          assert.equal((err as Error & { code?: string }).code, 'SSRF_BLOCKED',
            `Expected code=SSRF_BLOCKED, got: ${(err as Error & { code?: string }).code}`);
          return true;
        },
      );
    });
  });
}

// ── private IPv6 ranges are blocked ──────────────────────────────────────────

const privateIPv6Cases: Array<{ addr: string; label: string }> = [
  { addr: '::1',    label: '::1 loopback' },
  { addr: 'fe80::1', label: 'fe80:: link-local' },
];

for (const { addr, label } of privateIPv6Cases) {
  describe(`fetchCompliant — SSRF blocked: ${label}`, () => {
    const safeLabel = addr.replace(/:/g, '_').replace(/%/g, '_');
    const HOST = `http://ssrf-ipv6-${safeLabel}.example`;
    let agent: MockAgent;
    let realDispatcher: RealDispatcher;
    let r4: ReturnType<typeof mock.method>;
    let r6: ReturnType<typeof mock.method>;

    before(() => {
      realDispatcher = getGlobalDispatcher();
      agent = makeMockAgent();
      // resolve4 returns nothing; resolve6 returns the private address
      r4 = mock.method(dns, 'resolve4', async () => { throw Object.assign(new Error('ENODATA'), { code: 'ENODATA' }); });
      r6 = mock.method(dns, 'resolve6', async () => [addr]);
    });

    after(async () => {
      r4.mock.restore();
      r6.mock.restore();
      setGlobalDispatcher(realDispatcher);
      await agent.close();
    });

    it(`throws SSRF_BLOCKED for ${addr}`, async () => {
      await assert.rejects(
        () => fetchCompliant(`${HOST}/x`, { respectRobots: false }),
        (err: unknown) => {
          assert.ok(err instanceof Error);
          assert.equal((err as Error & { code?: string }).code, 'SSRF_BLOCKED');
          return true;
        },
      );
    });
  });
}

// ── SSRF check runs BEFORE robots.txt fetch ───────────────────────────────────

describe('fetchCompliant — SSRF check fires before robots.txt', () => {
  // If SSRF check is first, dns.resolve4 is called and throws before any HTTP
  // (including robots.txt) leaves the process. We verify that robots.txt is
  // never fetched for an SSRF-blocked host.
  const HOST = 'http://ssrf-order-check.example';
  let agent: MockAgent;
  let realDispatcher: RealDispatcher;
  let r4: ReturnType<typeof mock.method>;
  let r6: ReturnType<typeof mock.method>;
  let robotsIntercepted = false;

  before(() => {
    realDispatcher = getGlobalDispatcher();
    agent = new MockAgent();
    // Do NOT disable net-connect globally — instead intercept robots.txt and
    // mark a flag if it's ever called.
    agent.get(HOST)
      .intercept({ path: '/robots.txt', method: 'GET' })
      .reply(() => { robotsIntercepted = true; return { statusCode: 200, data: '' }; });
    setGlobalDispatcher(agent);

    r4 = mock.method(dns, 'resolve4', async () => ['10.1.2.3']);
    r6 = mock.method(dns, 'resolve6', async () => { throw Object.assign(new Error('ENODATA'), { code: 'ENODATA' }); });
  });

  after(async () => {
    r4.mock.restore();
    r6.mock.restore();
    setGlobalDispatcher(realDispatcher);
    await agent.close();
  });

  it('throws SSRF_BLOCKED and never fetches robots.txt', async () => {
    await assert.rejects(
      () => fetchCompliant(`${HOST}/x`),
      (err: unknown) => {
        assert.equal((err as Error & { code?: string }).code, 'SSRF_BLOCKED');
        return true;
      },
    );
    assert.equal(robotsIntercepted, false, 'robots.txt should not have been fetched');
  });
});

// ── getRobotsFor internal SSRF check ─────────────────────────────────────────

describe('isAllowed — getRobotsFor SSRF guard prevents robots.txt fetch', () => {
  // isAllowed calls getRobotsFor which calls assertPublicHost internally.
  // If the host resolves to a private IP, getRobotsFor swallows the error
  // (permissive on transport error) and allows the URL (empty robots = allow).
  // What we verify: the SSRF throw inside getRobotsFor means robots.txt is
  // never fetched over the wire.
  const HOST = 'http://ssrf-robots-guard.example';
  let agent: MockAgent;
  let realDispatcher: RealDispatcher;
  let r4: ReturnType<typeof mock.method>;
  let r6: ReturnType<typeof mock.method>;
  let robotsFetched = false;

  before(() => {
    realDispatcher = getGlobalDispatcher();
    agent = new MockAgent();
    agent.get(HOST)
      .intercept({ path: '/robots.txt', method: 'GET' })
      .reply(() => { robotsFetched = true; return { statusCode: 200, data: '' }; });
    setGlobalDispatcher(agent);

    r4 = mock.method(dns, 'resolve4', async () => ['192.168.0.1']);
    r6 = mock.method(dns, 'resolve6', async () => { throw Object.assign(new Error('ENODATA'), { code: 'ENODATA' }); });
  });

  after(async () => {
    r4.mock.restore();
    r6.mock.restore();
    setGlobalDispatcher(realDispatcher);
    await agent.close();
  });

  it('does not fetch robots.txt when host resolves to private IP', async () => {
    // getRobotsFor swallows SSRF errors — isAllowed returns true (permissive).
    // The key assertion is that no HTTP actually went out.
    const { isAllowed } = await import('../src/compliance.js');
    const result = await isAllowed(`${HOST}/page`);
    assert.equal(result, true, 'permissive on unresolvable robots (SSRF swallowed)');
    assert.equal(robotsFetched, false, 'robots.txt must not have been fetched over wire');
  });
});