fix: preserve charset detection and block CGNAT range in parse-url SSRF fix

Follow-up to the multi-reviewer review of the SSRF fix:

- Restore charset handling lost when switching from extract() to
  response.text(): non-UTF-8 pages (Shift_JIS/GBK/EUC/Big5, common on CJK
  sites this project targets) decoded as mojibake. Now read the body as
  bytes, detect charset from Content-Type / <meta charset>, and decode
  with TextDecoder before extractFromHtml.
- Wrap extractFromHtml in try/catch: it throws (not returns null) on
  empty/non-HTML bodies, which previously surfaced as a 500 instead of the
  intended 400.
- Add 100.64.0.0/10 (RFC 6598 CGNAT) to isPrivateIp; it is routable inside
  some cloud internal networks and was a residual SSRF target.
- Add tests for CGNAT, its boundaries, 0.0.0.0, and DNS-resolved IPv6.
This commit is contained in:
dayuan.jiang
2026-06-28 00:32:24 +09:00
parent 73862f6108
commit 886e748aa7
3 changed files with 54 additions and 2 deletions

View File

@@ -31,9 +31,26 @@ describe("isPrivateUrl", () => {
expect(await isPrivateUrl("http://10.0.0.5/")).toBe(true)
expect(await isPrivateUrl("http://192.168.1.1/")).toBe(true)
expect(await isPrivateUrl("http://169.254.169.254/")).toBe(true)
expect(await isPrivateUrl("http://0.0.0.0/")).toBe(true)
// 100.64.0.0/10 CGNAT (RFC 6598), routable in some cloud internal nets
expect(await isPrivateUrl("http://100.64.0.1/")).toBe(true)
expect(await isPrivateUrl("http://100.127.255.255/")).toBe(true)
expect(lookupMock).not.toHaveBeenCalled()
})
it("treats CGNAT boundaries correctly", async () => {
// 100.63.x and 100.128.x are outside 100.64.0.0/10 → public
lookupMock.mockResolvedValue([{ address: "100.63.255.255", family: 4 }])
expect(await isPrivateUrl("http://just-below.example/")).toBe(false)
lookupMock.mockResolvedValue([{ address: "100.128.0.1", family: 4 }])
expect(await isPrivateUrl("http://just-above.example/")).toBe(false)
})
it("blocks a hostname that resolves to a private IPv6 address", async () => {
lookupMock.mockResolvedValue([{ address: "fd00::1", family: 6 }])
expect(await isPrivateUrl("http://v6.example.com/")).toBe(true)
})
it("allows public URLs that resolve to public IPs", async () => {
lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }])
expect(await isPrivateUrl("https://example.com/article")).toBe(false)