Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion crates/graphicle/tests/test_python_sdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
21 changes: 18 additions & 3 deletions sdk/python/graphicle_sdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"""
from __future__ import annotations

import ipaddress
import json
import math
import os
Expand All @@ -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
Expand Down Expand Up @@ -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")
Expand All @@ -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)

Expand Down
17 changes: 16 additions & 1 deletion sdk/typescript/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,29 @@ 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}) {
if (typeof url !== "string" || /[\u0000-\u0020\u007f]/.test(url)) throw new TypeError("url must be an HTTP(S) origin");
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");
Expand Down
7 changes: 7 additions & 0 deletions sdk/typescript/test/client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading