Networking Integration Guide
What is networking, as a working model?
Almost all of it is someone else's infrastructure, so the real skill is proving which layer is lying.
The DNS rule that resolves most tickets: dig @<authoritative-ns> gives truth, dig @1.1.1.1 gives reach — right at the NS and wrong at 1.1.1.1 means a TTL is still counting down, so wait, because lowering a TTL is not retroactive and re-editing resets nothing. The TLS rule: the most common production bug is a server that serves the leaf but omits the intermediate, which browsers rescue via AIA fetching and curl and mobile SDKs do not, so 'works in Chrome, fails in the app' is a chain problem — and NODE_TLS_REJECT_UNAUTHORIZED=0 turns it into a permanent MITM hole. For codeAmani the two live edges are the deploy path (Porkbun registrar → apex A record because CNAME is illegal at the apex → CAA record or issuance fails → Cloudflare Full (strict) so the origin hop is verified too) and the callback endpoints: M-Pesa and Stripe are inbound signature-verification problems, but any admin field that accepts a callback URL is an outbound SSRF sink where 169.254.169.254 is one fetch away. On Kenya-targeted builds, latency is the budget — HTTP/3 over QUIC removes TCP head-of-line blocking on lossy radio and survives a tower change, and every extra third-party origin costs a fresh DNS+TCP+TLS round trip, roughly a second at 300ms RTT.
Six things you reach for first
Not a protocol tour — the handful of concepts and commands that actually localise a fault.
███╗ ██╗███████╗████████╗██╗ ██╗ ██████╗ ██████╗ ██╗ ██╗██╗███╗ ██╗ ██████╗
████╗ ██║██╔════╝╚══██╔══╝██║ ██║██╔═══██╗██╔══██╗██║ ██╔╝██║████╗ ██║██╔════╝
██╔██╗ ██║█████╗ ██║ ██║ █╗ ██║██║ ██║██████╔╝█████╔╝ ██║██╔██╗ ██║██║ ███╗
██║╚██╗██║██╔══╝ ██║ ██║███╗██║██║ ██║██╔══██╗██╔═██╗ ██║██║╚██╗██║██║ ██║
██║ ╚████║███████╗ ██║ ╚███╔███╔╝╚██████╔╝██║ ██║██║ ██╗██║██║ ╚████║╚██████╔╝
╚═╝ ╚═══╝╚══════╝ ╚═╝ ╚══╝╚══╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝ ╚═════╝Networking Integration Guide
Focus: The working model of TCP/IP, addressing, DNS, TLS and HTTP/2–3 that an application developer actually needs — plus the diagnostic commands that prove which layer is lying, and the defensive posture for network-layer risk (open ports, weak TLS, DNS hijacking, SSRF, MITM).
Overview
This is a topic guide, not an SDK. There is nothing to npm install; the deliverable is a mental model plus a toolbox. Reach for it when a deploy "works locally", when a domain resolves for you but not for your users, when a certificate is valid in the browser but rejected by curl, or when a webhook receiver needs to make an outbound call to a URL it did not choose.
Two models describe the same stack. The OSI seven-layer model is the vocabulary (people say "layer 7" and "layer 4"); the TCP/IP four-layer model is what actually ships. Use OSI to talk, TCP/IP to debug.
| TCP/IP layer | OSI layers | What lives here | What breaks |
|---|---|---|---|
| Link | 1–2 Physical, Data Link | Ethernet, Wi-Fi, MAC addresses, ARP | Cable/radio, MTU, local LAN only |
| Internet | 3 Network | IPv4/IPv6, ICMP, routing, NAT | Wrong route, firewall drop, no IPv6 |
| Transport | 4 Transport | TCP (ordered, reliable), UDP (datagram — carries QUIC) | Port closed, RST, SYN timeout |
| Application | 5–7 Session, Presentation, Application | DNS, TLS, HTTP/1.1, HTTP/2, HTTP/3 | Bad record, expired cert, 4xx/5xx |
Every arrow in that diagram is a place a request can die, and each one has a distinct symptom. The rest of this guide walks them in order.
Official Documentation
Setup — the diagnostics toolbox
Install the tools first; every section below assumes them.
# Debian / Ubuntu / WSL
sudo apt update && sudo apt install -y dnsutils curl iproute2 net-tools traceroute nmap openssl
# macOS (Homebrew) — dig/host/nslookup come from the bind formula
brew install bind curl nmap openssl@3 mtr# Windows — nslookup, tracert and netstat ship with the OS.
# The PowerShell equivalents are richer and script better:
Resolve-DnsName tech-stack.codeamanilabs.org -Type A
Test-NetConnection tech-stack.codeamanilabs.org -Port 443
Get-NetTCPConnection -State Listen | Sort-Object LocalPortOptional environment variables the examples use:
# .env.local — used by the SSRF-guard example below. Server-side only.
OUTBOUND_URL_ALLOWLIST=api.stripe.com,sandbox.safaricom.co.ke,api.safaricom.co.ke
apt install nmapis fine; running nmap is not automatically fine. See Scanning, authorized only.
IP addressing, CIDR, subnets, NAT
An IPv4 address is 32 bits (203.0.113.10); IPv6 is 128 bits (2001:db8::1). CIDR notation (RFC 4632) appends a prefix length: the first N bits are the network, the rest identify hosts inside it.
| CIDR | Netmask | Usable host addresses (IPv4) | Typical use |
|---|---|---|---|
/32 | 255.255.255.255 | 1 | A single host — firewall rules, allowlists |
/24 | 255.255.255.0 | 254 | One small subnet / VPC subnet |
/16 | 255.255.0.0 | 65,534 | A VPC |
/8 | 255.0.0.0 | 16,777,214 | 10.0.0.0/8 private space |
/0 | 0.0.0.0 | everything | Default route; "any source" in a security group |
Two addresses in every IPv4 subnet are not usable hosts — the all-zeros network address and the all-ones broadcast address — which is why a /24 gives 254, not 256.
Ranges that must never be reachable from user input
These are the ranges an SSRF guard denies. Memorise them; they show up again in the security section.
| Range | RFC | Meaning |
|---|---|---|
10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 | RFC 1918 | Private IPv4 |
127.0.0.0/8, ::1/128 | — | Loopback |
169.254.0.0/16, fe80::/10 | RFC 3927 | Link-local — includes 169.254.169.254, the cloud metadata endpoint |
100.64.0.0/10 | RFC 6598 | Carrier-grade NAT shared space |
fc00::/7 | RFC 4193 | IPv6 unique local addresses |
0.0.0.0/8 | — | "This network"; 0.0.0.0 often resolves to localhost |
NAT
Network Address Translation lets many private addresses share one public address by rewriting source IP + port and keeping a translation table. Consequences that matter to application code:
- Inbound connections do not work by default. A device behind NAT (a rider's phone on a Kenyan mobile network, almost always behind CGNAT) cannot be dialled; it must initiate. This is why webhooks push to your public HTTPS endpoint and why local webhook testing needs a tunnel (
ngrok http 3000). - Client IP is not the client. Behind NAT and behind a CDN,
req.socket.remoteAddressis the edge. Read the forwarded header your platform sets and trust it only because the platform sets it — on Vercel that isx-forwarded-for/x-real-ip, and only the value your own proxy appended is trustworthy. - Rate limiting by IP punishes shared exits. A whole ISP or office can share one public IP. Prefer a per-user or per-API-key key over a raw IP key wherever you have an identity.
Ports and sockets
A socket is the five-tuple {protocol, source IP, source port, destination IP, destination port}. A server binds and listens on a well-known port; a client connects from an ephemeral port. IANA divides the space into System (0–1023), User/Registered (1024–49151) and Dynamic/ephemeral (49152–65535, never assigned).
| Port | Service | Note |
|---|---|---|
| 22 | SSH | Never expose with password auth; keys only |
| 25 / 465 / 587 | SMTP / SMTPS / submission | 587 is the modern submission port; many hosts block 25 outbound |
| 53 | DNS | UDP first, TCP fallback for large responses (RFC 7766) |
| 80 | HTTP | Keep it open only to 301 to HTTPS and to serve ACME HTTP-01 |
| 443 | HTTPS | TCP for HTTP/1.1 + HTTP/2, UDP for HTTP/3/QUIC |
| 853 | DNS-over-TLS | RFC 7858 |
| 3000 / 3001 | Next.js dev | Local only |
| 5432 | Postgres | Supabase / Neon — never open to 0.0.0.0/0 |
| 6379 | Redis | Historically unauthenticated by default; treat as internal-only |
Two rules that prevent most self-inflicted outages:
- Bind to
127.0.0.1for anything that does not need to be public. A service bound to0.0.0.0on a cloud VM is on the internet the moment the security group allows it. - Open ports are inventory. You cannot defend a listener you did not know existed — see the
ssrecipe below.
DNS
DNS turns a name into an address (RFC 1035; terminology in RFC 8499). Four roles are involved, and confusing them is the root of most "it works for me" reports.
The authoritative nameserver is the only place a record actually changes. Everything between it and the user is a cache. "DNS propagation" is not a push — it is the world's recursive resolvers expiring their cached copy after the record's TTL elapses. That is the whole mechanism, and it dictates the operational rule below.
Record types
| Type | Points at | Notes |
|---|---|---|
A | IPv4 address | Apex domains on Vercel use an A record |
AAAA | IPv6 address | Add it — a growing share of mobile networks are IPv6-only with NAT64 |
CNAME | Another name | Cannot coexist with other records on the same name, and cannot legally sit at the zone apex. Providers work around this with CNAME flattening / ALIAS / ANAME |
MX | Mail exchanger + priority | Lower priority number wins (RFC 5321) |
TXT | Arbitrary text | Domain-ownership proofs, SPF (RFC 7208), DKIM (RFC 6376), DMARC (RFC 7489) |
CAA | Which CAs may issue for this name | RFC 8659 — issue / issuewild / iodef |
NS | Delegation to authoritative servers | Changing these at the registrar moves the whole zone |
SOA | Zone metadata | Serial, refresh, and the negative-cache TTL |
SRV | Service host and port | Used by protocols that need port discovery |
HTTPS / SVCB | Connection hints before the first request | RFC 9460 — lets a client learn ALPN (h3), IP hints and port up front |
PTR | Reverse: address → name | Lives in the provider's zone, not yours |
Two TXT records everyone gets wrong: SPF must be one TXT record per domain (multiple v=spf1 records is a permanent error), and DMARC lives at _dmarc.example.com, not the apex.
The TTL rule
Lower the TTL before you change anything.
# 1. Days before a cutover: drop TTL to 300s on the records you will change,
# then wait for the OLD TTL to fully elapse.
# 2. Make the change.
# 3. Verify from multiple resolvers.
# 4. Days later, once stable, raise TTL back to 3600+.You cannot shorten a TTL retroactively: a resolver that cached the old record at 86400s will hold it for up to a day no matter what you do afterwards. This is why a rushed DNS cutover is a multi-hour outage and a planned one is invisible.
Diagnosing DNS
# What does the authoritative server say? (bypasses every cache — the ground truth)
dig +short NS codeamanilabs.org
dig @ns1.porkbun.com tech-stack.codeamanilabs.org A
# What does the world see? Query specific public resolvers.
dig @1.1.1.1 tech-stack.codeamanilabs.org A +short
dig @8.8.8.8 tech-stack.codeamanilabs.org A +short
# Full delegation path, root → TLD → authoritative
dig +trace tech-stack.codeamanilabs.org
# Remaining TTL on the cached answer (run twice — it counts down)
dig tech-stack.codeamanilabs.org A | grep -A1 "ANSWER SECTION"
# Other record types
dig codeamanilabs.org MX +short
dig codeamanilabs.org CAA +short
dig _dmarc.codeamanilabs.org TXT +short
# nslookup — everywhere, including bare Windows
nslookup -type=A tech-stack.codeamanilabs.org 1.1.1.1# Windows PowerShell equivalents
Resolve-DnsName tech-stack.codeamanilabs.org -Type A -Server 1.1.1.1
Resolve-DnsName codeamanilabs.org -Type MX
Clear-DnsClientCache # flushes the local stub cache only, not the recursive resolverIf
dig @<authoritative-ns>is right butdig @1.1.1.1is wrong, you are waiting on TTL — do nothing. If the authoritative answer itself is wrong, fix the record. That single test separates "be patient" from "act", and it is the most useful thing in this guide.
TLS and HTTPS
TLS 1.3 (RFC 8446) authenticates the server, negotiates keys, and encrypts everything after. The handshake is one round trip before application data in the full case; a resumed session can send 0-RTT early data (with replay caveats — never put a non-idempotent request in 0-RTT).
Three fields in ClientHello do a lot of work:
- SNI (RFC 6066) carries the hostname in cleartext so one IP can serve many certificates. It is why a CDN can host thousands of sites on one address — and why the hostname you request is visible to the network even under TLS.
- ALPN (RFC 7301) negotiates the application protocol in the handshake itself:
h2,http/1.1, orh3. No extra round trip to discover HTTP/2. - key_share is sent optimistically in the first flight — the reason TLS 1.3 is 1-RTT instead of TLS 1.2's 2-RTT.
Certificates: what validation actually checks
A certificate is trusted only if all of these hold. Any one failing is a hard error, and the error message rarely says which.
- Chain of trust — leaf → intermediate(s) → a root in the client's trust store. The single most common production TLS bug is a server that serves the leaf but omits the intermediate: browsers often paper over it with AIA fetching,
curland mobile SDKs do not. Symptom: "works in Chrome, fails in the app". - Hostname match — the requested name must appear in the certificate's Subject Alternative Name list (RFC 9525). The legacy Common Name field is no longer used for matching.
- Validity window —
notBefore≤ now ≤notAfter. Expiry is an outage, so renewal must be automated. - Revocation / CT — modern clients also expect Certificate Transparency signatures.
# Full handshake detail: chain, SANs, protocol, cipher
openssl s_client -connect tech-stack.codeamanilabs.org:443 \
-servername tech-stack.codeamanilabs.org -showcerts </dev/null
# Just the dates and names
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
| openssl x509 -noout -subject -issuer -dates -ext subjectAltName
# Does the chain validate standalone? (no browser AIA rescue)
curl -vI https://example.com 2>&1 | grep -Ei "SSL|subject|issuer|ALPN|HTTP/"
# Prove a specific TLS version is or is not accepted
curl --tlsv1.3 --tls-max 1.3 -sI https://example.com -o /dev/null -w "%{http_version} %{ssl_verify_result}\n"Hardening checklist
- TLS 1.2 minimum, 1.3 preferred. SSLv3, TLS 1.0 and TLS 1.1 are deprecated — disable them. Generate config from https://ssl-config.mozilla.org/ rather than hand-writing cipher strings.
- HSTS — send
Strict-Transport-Security: max-age=63072000; includeSubDomainsso the browser refuses plaintext on its own. Addpreloadonly when you are certain every subdomain is HTTPS; it is hard to undo. - CAA records — publish which CAs may issue for your domain. It is one DNS record and it closes off mis-issuance by any other CA.
- Automate renewal. Vercel, Netlify, Cloudflare and Render all issue and renew automatically; if you ever terminate TLS yourself, ACME + a monitor on
notAfteris not optional. - Never disable verification to "fix" a TLS error.
NODE_TLS_REJECT_UNAUTHORIZED=0,curl -k, andrejectUnauthorized: falseconvert a certificate bug into a permanent MITM vulnerability. Fix the chain instead.
HTTP/1.1, HTTP/2, HTTP/3
| HTTP/1.1 | HTTP/2 (RFC 9113) | HTTP/3 (RFC 9114) | |
|---|---|---|---|
| Transport | TCP | TCP | QUIC over UDP (RFC 9000) |
| Framing | Text | Binary frames | Binary frames on QUIC streams |
| Concurrency | One request in flight per connection; browsers open ~6 | Multiplexed streams on one connection | Multiplexed streams, independent |
| Head-of-line blocking | At the request level | Removed at HTTP level, remains at TCP level — one lost segment stalls every stream | Removed: a lost packet stalls only its own stream |
| Header compression | None | HPACK | QPACK |
| Handshake | TCP + TLS separately | TCP + TLS separately | TLS 1.3 folded into the QUIC handshake |
| Connection migration | No | No | Yes — connection ID survives an IP change |
| Discovery | default | ALPN h2 | Alt-Svc: h3=":443" header, or a DNS HTTPS record |
HTTP/3 cannot be negotiated in-band on the first connection the way h2 can, because it is a different transport. A client learns about it from an Alt-Svc response header (RFC 7838) or, increasingly, from an HTTPS/SVCB DNS record (RFC 9460) that advertises alpn=h3 before the first packet — saving the initial TCP round trip entirely.
# Which protocol did you actually get?
curl -sI --http2 https://example.com -o /dev/null -w "%{http_version}\n"
curl -sI --http3 https://example.com -o /dev/null -w "%{http_version}\n" # needs an HTTP/3-capable curl
# Is HTTP/3 advertised?
curl -sI https://example.com | grep -i alt-svc
dig example.com HTTPS +short # SVCB/HTTPS record, if published
# Timing breakdown — where the milliseconds go
curl -s -o /dev/null -w "dns=%{time_namelookup} connect=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}\n" https://example.comThat last command is the highest-value one-liner in this document: it splits a "slow site" complaint into a DNS problem, a TCP-RTT problem, a TLS problem, or an origin problem, in one request.
CDNs and the edge
A CDN terminates TLS at a point of presence close to the user, serves cacheable responses from there, and reuses long-lived warm connections back to origin. The win is not only bytes — it is round trips. On a 3G link with a 200–400 ms RTT, moving the TLS handshake from a US origin to a nearby PoP saves more wall-clock time than any payload optimisation you can make.
# Which PoP answered, and did it cache?
curl -sI https://tech-stack.codeamanilabs.org | grep -Ei "cf-ray|cf-cache-status|x-vercel-cache|age|cache-control"| Header | Meaning |
|---|---|
x-vercel-cache: HIT / MISS / STALE | Vercel edge cache outcome |
cf-cache-status: HIT / MISS / DYNAMIC / BYPASS | Cloudflare cache outcome |
cf-ray: …-NBO | Cloudflare ray id; the suffix is the PoP IATA code (NBO = Nairobi) |
age | Seconds the response has sat in cache |
When a name is proxied through Cloudflare (orange cloud), dig returns Cloudflare's anycast address, not your origin — expected, not a misconfiguration. See cloudflare for proxy status and the origin SSL modes; use Full (strict) so the Cloudflare↔origin hop is verified too, otherwise the padlock the user sees covers only half the path.
Diagnostics: which layer is lying?
Work bottom-up. Each command clears one layer, so the first failure localises the fault.
# L3 — is the host routable at all?
ping -c 4 1.1.1.1 # raw IP: no DNS involved
traceroute -n example.com # hop-by-hop path; * * * means filtered ICMP, not always a fault
mtr -rw example.com # traceroute + loss stats over time (best for flaky links)
# L4 — is the port open?
nc -vz example.com 443
# PowerShell: Test-NetConnection example.com -Port 443
# What is listening locally, and which process owns it?
ss -tlnp # TCP, listening, numeric, process (needs sudo for other users' procs)
ss -tunap | grep :5432 # every socket touching Postgres
netstat -tulpn # older systems; ss is the modern replacement
# L7 — the full story of one request
curl -v https://example.com
curl -sSL -o /dev/null -w "%{http_code} %{num_redirects} %{redirect_url}\n" http://example.com # follow the redirect chain| Symptom | Most likely layer | Next command |
|---|---|---|
Could not resolve host | DNS | dig @1.1.1.1 <name> then dig @<authoritative-ns> <name> |
Connection refused | Transport — nothing listening | ss -tlnp on the host |
Connection timed out | Firewall / security group silently dropping | traceroute, then check the security group |
certificate verify failed | TLS chain or hostname | openssl s_client -showcerts |
Works in browser, fails in curl/app | Missing intermediate certificate | Serve the full chain |
Right in dig @ns, wrong for users | DNS TTL still counting down | Wait; do not re-edit the record |
| Fast locally, slow in production | RTT / cache miss | curl -w timing breakdown |
Scanning, authorized only
nmap is a legitimate inventory tool for systems you own or have explicit written permission to test. Unauthorized port scanning is unlawful in many jurisdictions and violates most providers' terms of service — read https://nmap.org/book/legal-issues.html before pointing it anywhere.
# Legitimate use: audit YOUR OWN host's exposed surface from outside it.
nmap -Pn -p 1-1024 <your-own-host> # what does the internet see?
nmap --script ssl-enum-ciphers -p 443 <your-own-host> # TLS versions/ciphers your server offersPrefer the alternatives when they exist: ss -tlnp on the box itself is faster, more accurate and needs no permission conversation; your cloud provider's security-group listing is the authoritative answer to "what is exposed".
Network-layer risks and their mitigations
Defensive framing only — each row is a risk you close on your own infrastructure.
| Risk | Why it happens | Mitigation |
|---|---|---|
| Open ports / exposed services | Service bound to 0.0.0.0; security group left at 0.0.0.0/0 from a debugging session | Bind internal services to 127.0.0.1; default-deny inbound; audit with ss -tlnp and the provider's security-group list; put admin surfaces behind a VPN or identity proxy, never behind "an unguessable port" |
| Weak / misconfigured TLS | Legacy protocol versions left enabled; missing intermediate; expired cert | TLS 1.2 minimum; config from Mozilla's generator; serve the full chain; automate renewal and alert on notAfter; enable HSTS |
| DNS hijacking (registrar/zone takeover) | Registrar account compromise, or a stale CNAME to a deprovisioned host that an attacker re-claims | 2FA + registrar lock on the registrar account (porkbun); publish CAA; delete DNS records when you tear down the resource they point at — dangling CNAMEs are subdomain takeover |
| DNS spoofing / cache poisoning | Plaintext UDP:53 answers are forgeable on a hostile network | DNSSEC (RFC 4033) on your zone; DNS-over-HTTPS (RFC 8484) or DNS-over-TLS (RFC 7858) on clients you control; never make a security decision from an unauthenticated DNS answer |
| SSRF — server fetches an attacker-chosen URL | Any endpoint that takes a URL: webhook-target config, image import, link unfurling, "test my callback" buttons | Allowlist destination hosts; resolve DNS yourself and reject private/link-local IPs before connecting; refuse redirects or re-validate every hop; block non-http(s) schemes; enforce egress rules at the network. Detail below |
| MITM on hostile networks | Public Wi-Fi, transparent proxies, captive portals | HTTPS everywhere + HSTS; never disable certificate verification; treat any request arriving over plain HTTP as untrusted |
| Credentials on the wire | Postgres/Redis exposed publicly; API keys in query strings (they land in every access log) | TLS on database connections (sslmode=require or stricter); keys in Authorization headers, never in URLs; rotate on exposure |
| Amplification / volumetric abuse of your endpoints | Any unauthenticated endpoint that does real work | Rate limit at the edge; keep expensive routes behind auth; let the CDN absorb L3/L4 volume |
SSRF: the pattern that matters for webhook and callback endpoints
The dangerous shape is outbound traffic to a URL a user supplied. From inside a cloud network, http://169.254.169.254/ and http://10.x.x.x/ are reachable and often unauthenticated, so a naïve fetch(userUrl) hands an attacker your internal network.
// lib/safe-fetch.ts — server-side only.
import { lookup } from "node:dns/promises";
import { isIP } from "node:net";
const ALLOWED_HOSTS = new Set(
(process.env.OUTBOUND_URL_ALLOWLIST ?? "").split(",").map((h) => h.trim()).filter(Boolean),
);
/** RFC 1918 / 3927 / 6598 / 4193 + loopback. Reject, do not "sanitize". */
function isPrivateAddress(ip: string): boolean {
if (isIP(ip) === 6) {
const v6 = ip.toLowerCase();
return v6 === "::1" || v6.startsWith("fe80:") || v6.startsWith("fc") || v6.startsWith("fd");
}
const [a, b] = ip.split(".").map(Number);
if (a === 10 || a === 127 || a === 0) return true;
if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 192 && b === 168) return true;
if (a === 169 && b === 254) return true; // cloud metadata lives here
if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
return false;
}
export async function safeFetch(rawUrl: string, init?: RequestInit): Promise<Response> {
const url = new URL(rawUrl);
// 1. Scheme allowlist — blocks file:, gopher:, ftp:, data:
if (url.protocol !== "https:") throw new Error("only https is allowed");
// 2. Host allowlist — the strongest control. Prefer it whenever the set is known.
if (ALLOWED_HOSTS.size > 0 && !ALLOWED_HOSTS.has(url.hostname)) {
throw new Error(`host not allowed: ${url.hostname}`);
}
// 3. Resolve and reject internal addresses (all records, not just the first).
const resolved = await lookup(url.hostname, { all: true });
if (resolved.some((r) => isPrivateAddress(r.address))) {
throw new Error("resolved to a private address");
}
// 4. Never follow redirects blindly — a 302 to 169.254.169.254 defeats steps 1-3.
return fetch(url, { ...init, redirect: "error", signal: AbortSignal.timeout(5_000) });
}Two honest caveats. First, a DNS rebinding attacker can return a public IP to your lookup() and a private one to the connection that follows; closing that gap requires pinning the validated IP into the connection itself (an undici Agent with a custom connect.lookup). Second, application-level checks are defence in depth — the durable control is network egress policy, so the internal address is unreachable from the request-handling process no matter what the code does. OWASP's cheat sheet is the reference: https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html
Inbound webhooks are the mirror-image problem — signature verification, not URL validation. That belongs to webhooks; the broader application-security posture is in security.
codeAmani notes
The deploy path: Porkbun → Cloudflare → Vercel
This is the sequence that actually wires a codeAmani property, and the order matters.
- Registrar — the name lives at Porkbun; the
porkbun-dnsskill edits records programmatically. Registrar lock + 2FA on that account is the root of trust for the entire domain: whoever controls it controls your DNS, and therefore your certificates. - DNS — either Porkbun's nameservers or Cloudflare's. Apex →
Arecord (Vercel shows the value in the project's Domains tab; historically76.76.21.21); subdomain →CNAMEto the per-project Vercel target.CNAMEcannot sit at the apex, which is exactly why the apex gets anA. - TLS — issued and renewed automatically by the platform. Publish a
CAArecord for whichever CA the platform uses, or issuance fails; verify withdig <domain> CAA +short. - Verify before declaring done —
dig @<authoritative-ns>for truth,dig @1.1.1.1for reach,curl -vIfor the chain. A green dashboard and a wrongCAArecord look identical until the first renewal.
If a name is proxied through Cloudflare in front of Vercel, set SSL mode to Full (strict) — anything less leaves the Cloudflare↔origin hop unverified.
Security
- Secrets stay server-side. Nothing in this guide changes that: the SSRF allowlist, resolver config and any API tokens live in
.env.local/ Vercel env vars / Hazina, never inNEXT_PUBLIC_*. - The M-Pesa and Stripe endpoints are the two shapes at once. Inbound, they are unauthenticated public HTTPS endpoints that must verify a signature before doing work (Stripe signing secret; Daraja callback validation) and must be idempotent on retry. Outbound, any admin screen that lets someone type a callback or webhook URL is an SSRF sink — run it through
safeFetchabove. Daraja additionally requires an HTTPS callback, which is why local testing goes throughngrok http 3000rather than a raw port. - Never widen the database to debug. Supabase and Neon connections are TLS-enforced and IP-scoped for a reason; a temporary
0.0.0.0/0rule outlives the debugging session that created it. - Delete DNS records when you delete the thing they point to. A
CNAMEleft pointing at a torn-down preview host is a subdomain takeover waiting for someone to claim the name.
Kenya-targeted projects: the bandwidth angle
On a 2G/3G link, latency and packet loss — not throughput — decide whether an app feels usable. Round trips are the budget.
- HTTP/3 earns its keep here. QUIC removes TCP-level head-of-line blocking, so one lost packet stalls a single stream instead of every response on the connection — a large win on lossy mobile radio. Connection migration also means a rider moving between cell towers or from Wi-Fi to data keeps the same connection instead of re-handshaking. Vercel and Cloudflare serve HTTP/3 by default; confirm with
curl -sI … | grep -i alt-svc. - Connection reuse is the cheapest optimisation available. Every new origin is a fresh DNS lookup + TCP + TLS handshake — three round trips before a byte of content. At 300 ms RTT that is ~1 s per extra domain. Serve fonts, images and scripts from your own origin rather than a third-party CDN; use
preconnectfor the ones you genuinely cannot move. - Mobile clients are behind CGNAT. They cannot receive inbound connections, and many share one public IP — so poll or use webhooks pushed to your server, and never rate-limit East African mobile traffic by raw IP.
- IPv6 matters more here than in the US. Several African mobile networks are IPv6-only with NAT64; publish
AAAArecords (Vercel and Cloudflare do this for you) rather than assuming IPv4 reachability. - Cache aggressively at the edge. Cloudflare R2's zero egress fee plus a Nairobi PoP (
cf-raysuffixNBO) means static assets never cross an ocean twice.
Troubleshooting
| Issue | Fix |
|---|---|
| Record changed but users still see the old value | TTL has not expired. dig @<authoritative-ns> to confirm the record is correct, then wait — re-editing resets nothing |
CNAME rejected at the apex | Not legal in DNS. Use an A record, or the provider's ALIAS/ANAME/CNAME-flattening feature |
| Certificate issuance fails on a new domain | Check dig <domain> CAA +short — a CAA record that omits your platform's CA blocks issuance |
curl says certificate verify failed, browser is fine | Server omits the intermediate; the browser fetched it via AIA and curl did not. Serve the full chain |
ERR_SSL_PROTOCOL_ERROR on a proxied domain | Cloudflare SSL mode vs origin mismatch — Flexible in front of an HTTPS origin loops; use Full (strict) |
| Webhook works in production, never fires locally | Your dev box is behind NAT and unroutable. Tunnel it: ngrok http 3000, and register the HTTPS URL |
Port shows open with nc but the app 502s | Transport is fine, application is not — move up a layer: curl -v and the origin's logs |
traceroute shows * * * for the last hops | ICMP filtered — normal for cloud hosts, not evidence of a fault. Test the port with nc -vz instead |
Two v=spf1 TXT records on one domain | Permanent SPF error. Merge into a single record |
ss shows nothing but the service "is running" | It bound to 127.0.0.1 (or inside a container's namespace) — check the bind address and, for Docker, the port publish flags |