diff --git a/crates/graphicle/tests/test_python_sdk.py b/crates/graphicle/tests/test_python_sdk.py index 6054bfa..41c0f75 100644 --- a/crates/graphicle/tests/test_python_sdk.py +++ b/crates/graphicle/tests/test_python_sdk.py @@ -348,7 +348,14 @@ def test_bad_capability_raises_permission_error(local): def test_the_capability_is_never_sent_over_plain_http_to_another_host(): with pytest.raises(ValueError, match="loopback"): Client("http://memory.example.com:8854", "w", "secret") - for url in ("https://memory.example.com", "http://127.0.0.1:1", "http://localhost:1", "http://[::1]:1"): + for url in ( + "https://memory.example.com", + "http://127.0.0.1:1", + "http://127.0.0.2:1", + "http://localhost:1", + "http://[::1]:1", + "http://[::ffff:127.0.0.1]:1", + ): Client(url, "w", "secret") Client("http://10.0.0.5:8854", "w", "secret", insecure_http=True) diff --git a/sdk/python/graphicle_sdk/client.py b/sdk/python/graphicle_sdk/client.py index a45a007..0e6cc1d 100644 --- a/sdk/python/graphicle_sdk/client.py +++ b/sdk/python/graphicle_sdk/client.py @@ -60,6 +60,7 @@ """ from __future__ import annotations +import ipaddress import json import math import os @@ -84,7 +85,21 @@ #: Job states that are still in progress. PENDING_STATES = frozenset({"queued", "retry", "running"}) -_LOOPBACK = frozenset({"127.0.0.1", "localhost", "::1"}) +def _is_loopback(hostname: str) -> bool: + """True for localhost and every loopback address, not only three spellings. + + 127.0.0.2 and IPv4-mapped ::ffff:127.0.0.1 are loopback. Sending the workspace + capability to them in cleartext is the same as sending it to 127.0.0.1, and + refusing them makes a local service on any other 127/8 address unusable. + """ + if hostname.lower() == "localhost": + return True + try: + return ipaddress.ip_address(hostname).is_loopback + except ValueError: + return False + + # Service limits: connector.import takes at most 5,000 documents per call and a request body is # capped at 24 MiB; source.forget takes at most 1,000 document ids. _IMPORT_DOCUMENTS = 5000 @@ -179,7 +194,7 @@ def __init__(self, url: str, workspace: str, capability: str, timeout: float = 6 raise ValueError("url must be an http(s) origin, for example http://127.0.0.1:8854") # Accessing port validates non-numeric and out-of-range port strings. _ = parts.port - if parts.scheme == "http" and parts.hostname not in _LOOPBACK and not insecure_http: + if parts.scheme == "http" and not _is_loopback(parts.hostname) and not insecure_http: # The capability is a bearer secret; plain HTTP would expose it on the network. raise ValueError("Plain http is only for loopback addresses; use https, or pass " "insecure_http=True for a network you trust") @@ -191,7 +206,7 @@ def __init__(self, url: str, workspace: str, capability: str, timeout: float = 6 self._capability = capability self._base = f"/api/workspaces/{urllib.parse.quote(workspace, safe='')}" handlers: List[urllib.request.BaseHandler] = [_NoRedirect()] - if parts.hostname in _LOOPBACK: + if _is_loopback(parts.hostname): handlers.append(urllib.request.ProxyHandler({})) self._opener = urllib.request.build_opener(*handlers) diff --git a/sdk/typescript/index.js b/sdk/typescript/index.js index 7f99b77..29b73e5 100644 --- a/sdk/typescript/index.js +++ b/sdk/typescript/index.js @@ -19,6 +19,21 @@ function plainObject(value) { return value; } +function isLoopbackHost(hostname) { + const host = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname; + if (host.toLowerCase() === "localhost" || host === "::1") return true; + const octets = host.split("."); + if (octets.length === 4 && octets.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)) { + return Number(octets[0]) === 127; + } + // The URL parser rewrites [::ffff:127.0.0.1] to [::ffff:7f00:1]. + const mapped = /^::ffff:(?:(\d+\.\d+\.\d+\.\d+)|([0-9a-f]{1,4}):([0-9a-f]{1,4}))$/i.exec(host); + if (!mapped) return false; + if (mapped[1]) return isLoopbackHost(mapped[1]); + const hi = Number.parseInt(mapped[2], 16); + return hi >= 0x7f00 && hi <= 0x7fff; +} + export class Graphicle { #origin; #capability; #fetch; #timeout; constructor({url, workspace, capability, timeoutMs = 60_000, fetch: fetcher = globalThis.fetch, insecureHttp = false}) { @@ -26,7 +41,7 @@ export class Graphicle { const parsed = new URL(url); if (!["http:", "https:"].includes(parsed.protocol) || parsed.username || parsed.password || parsed.pathname !== "/" || url.includes("?") || url.includes("#")) throw new TypeError("url must be an origin without credentials, path, query or fragment"); - if (parsed.protocol === "http:" && !["localhost", "127.0.0.1", "[::1]"].includes(parsed.hostname) && !insecureHttp) { + if (parsed.protocol === "http:" && !isLoopbackHost(parsed.hostname) && !insecureHttp) { throw new TypeError("Use HTTPS for remote services; insecureHttp is only for an explicitly trusted network"); } if (typeof capability !== "string" || !capability || capability.length > 512 || /[\r\n]/.test(capability)) throw new TypeError("Supply a workspace capability"); diff --git a/sdk/typescript/test/client.test.js b/sdk/typescript/test/client.test.js index f71669f..b2d3643 100644 --- a/sdk/typescript/test/client.test.js +++ b/sdk/typescript/test/client.test.js @@ -64,6 +64,13 @@ test("caller cancellation is preserved", async t => { await assert.rejects(client(url).health({signal: abort.signal}), error => error === reason); }); +test("plain http is limited to loopback, including the rest of 127/8 and IPv4-mapped loopback", () => { + for (const url of ["http://127.0.0.1:8854", "http://127.0.0.2:8854", "http://[::1]:8854", "http://[::ffff:127.0.0.1]:8854", "http://localhost:8854"]) { + assert.equal(client(url).url, new URL(url).origin); + } + assert.throws(() => client("http://192.0.2.1:8854"), /HTTPS|loopback|trusted/i); +}); + test("credentials and project authority cannot be redirected by arguments", async () => { for (const url of ["https://user:pass@example.com", "https://example.com/path", "https://example.com?x=1", "http://example.com", "https://example.com/#x"]) assert.throws(() => client(url)); const c = client("https://example.com");