Skip to content

Commit b972e76

Browse files
fix: close SSRF bypass, make marketing surface reachable, replace fabricated AI assistant (#1016)
* fix(pulse-webhooks)!: close the SSRF bypass in the delivery guard `WebhookDelivery` kept its own `net.BlockList` of private ranges, separate from the list in `UrlValidator`. The two drifted, and the copy guarding outbound deliveries was the weaker one: it omitted `0.0.0.0/8`, the IPv6 unspecified address, CGNAT `100.64.0.0/10` and `192.0.0.0/24`. Because each of those is an IP literal, `validateResolvedHostname` returned early and never re-checked them. So `http://0.0.0.0:8080/` passed validation and was fetched - and on Linux `0.0.0.0` routes to loopback, meaning a customer-registered webhook URL could reach services bound to 127.0.0.1 on the delivery worker. Verified end to end: `validateUrl` returned null for `0.0.0.0` while correctly blocking `127.0.0.1`, and a real fetch reached a loopback-bound server. Extract the range checks into `private-ip.ts` and have both callers use it, so there is nothing left to drift. `validateUrl` also picks up the `.localhost` suffix check it was missing - RFC 6761 reserves the whole suffix, not just the bare label. `UrlValidator` is now exported. Its own docs claimed consumers wire it in front of their own fetch, but it was never re-exported from index.ts, so nobody could - the claim was false and the class was unreachable. Adds 8 regression cases against the delivery path, not just the validator. * fix(web): make the marketing surface reachable and stop overstating it A first-time visitor landing on `/` could not get anywhere. Every nav link, both hero buttons, the CTA, and all nine footer links were `href="#"` - sixteen dead links on pages whose destinations (`/docs`, `/reference`, `/demo/contracts`, `/starters`, `/changelog`) all already existed and worked. Centralise every off-page destination in `lib/links.ts` so a renamed route breaks in one file instead of rotting silently in four. Also fixed, all in the same category of claiming more than is true: - `/starters` linked three repos that do not exist - `orbital-next-starter`, `orbital-express-starter`, `orbital-anchor-starter` all 404, so every source link and all six deploy buttons pointed at nothing. Repointed at the in-repo `examples/`, with the repo path now an actual link. Vercel's clone flow takes `root-directory`, so the Next.js starter keeps a working one-click; Railway's `templateUrl` has no subdirectory equivalent, so those two link to their README rather than ship a button that deploys the repo root by mistake. - The anchor starter was described as having "a live React dashboard". It is a CLI that writes an append-only JSON Lines audit log. Description now matches the code. - The footer hardcoded "● All systems operational" next to a Status link that went nowhere. There is no uptime monitor behind it, so it read green while the demo endpoint was returning 503. Removed rather than faked. - The footer listed 2 of the published packages as unlinked plain text. Now lists the 4 that are actually on npm, linked. `anchor-sdk` and `orbital-indexer` are deliberately absent - they 404 on the registry, and linking them would recreate the exact problem this commit fixes. - The product is called Orbital, not "Orbit Stellar" - including in the `<title>`, so the browser tab was wrong too. - Twitter is dropped: no account exists, and an invented handle is worse than an absent row. `examples/next-starter` was untracked and unfinished - no page, no layout, no README, and `lib/engine.ts` imported `./config.js`, which does not resolve under `moduleResolution: "bundler"`, so it could never have built. Completed and verified with a real `next build`. * fix(web): replace the fabricated AI assistant and cap two open endpoints The docs sidebar shipped an "AI Assistant" with no model behind it. It greeted every visitor with "Ask me anything about the SDK, webhooks, or real-time events", waited a hardcoded 1200ms to look like it was thinking, then returned a canned template string with the question interpolated into it. No "coming soon" label, mounted unconditionally, so it was live on every /docs page - a developer evaluating the SDK asked a real question and got a confident non-answer. Rewritten as what it can honestly be: a docs search panel over the existing /api/docs/search endpoint, using the same debounced fetch as SearchDialog. Every line it shows is now a real section with a real link, and when nothing matches it says so instead of inventing prose. Button relabelled AI -> Search. Two unmetered amplifiers on the same surface: - /api/docs/search re-read, re-parsed and re-stripped every markdown file in the corpus on every request, uncached, with no rate limit, on a route the UI calls on a 200ms debounce. The content is build-time static, so it is now parsed once per process. Query length is capped at 128 chars. - /api/webhook-sample read an unbounded JSON body and HMAC-ed over an unbounded caller-supplied secret. Body now capped at 4KB (413 past that), secret at 256 chars, address at 56. Also: clientIp() silently collapses every caller into a single "unknown" bucket when neither x-vercel-forwarded-for nor TRUSTED_PROXY_HOPS is available. Failing closed is right, but it turns perIpStreams: 1 into a global limit - one SSE slot for the whole internet on a non-Vercel deploy. Now warns once per process naming the fix. * fix(pulse-core): stop dropping unified cursor writes, and de-flake two suites `onCursor` fired `void this.persistUnifiedCursor(...)`, discarding the promise. Nothing tracked it, so `stop()` could not wait for it and two writes were never ordered against each other. Two consequences: a shutdown could lose the newest cursor, and on a store whose writes complete out of order (Postgres, Redis, the filesystem under load) the OLDER cursor could be the one that survived. Either way the next start replays events that were already delivered - which matters for a pipeline the anchor starter sells as "audit-grade, replay-safe". Writes are now chained, and `stop()` awaits the tail. The chain is null until the first write is queued, deliberately: making `stop()` await unconditionally deferred the teardown after it by a microtask and broke three callers that read engine state immediately after stopping. That also removes the race behind EventEngine.unifiedCursorResume.test.ts, which slept 20ms after `stop()` and hoped. It failed 2 of 3 local runs; now 3 of 3. The two DeadLetterStore flakes had a different cause: unlike its sibling pulse-webhooks.test.ts, that file never mocked `dns/promises`, so every delivery attempt did a real DNS lookup of example.com - on real time, inside `vi.useFakeTimers()`, where `vi.waitFor` could not wait for it. Mocked, as the sibling already does. examples/next-starter declared a `test` script with no tests, failing `pnpm -r test`. Covered the config validation instead, which is the part worth testing. Widened `loadConfig` to take the env shape it actually reads. abi-registry's CLI-only config loader is annotated so bundlers stop trying to trace a runtime path. This does NOT clear the "whole project was traced" warning in apps/web - that needs the module moved behind its own subpath export and dropped from index.ts, which is a public-API change, so the comment says so rather than implying it is fixed. --------- Co-authored-by: Salmatcre8 <118213044+Salmatcre8@users.noreply.github.com>
1 parent ff53337 commit b972e76

35 files changed

Lines changed: 1341 additions & 382 deletions

apps/web/app/api/docs/search/route.ts

Lines changed: 64 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -44,16 +44,29 @@ function getSnippet(content: string, query: string, length = 160): string {
4444
return snippet
4545
}
4646

47-
export async function GET(request: NextRequest) {
48-
const query = request.nextUrl.searchParams.get('q')?.trim() ?? ''
47+
type IndexedDoc = {
48+
title: string
49+
href: string
50+
section: string
51+
plainContent: string
52+
lowerTitle: string
53+
lowerContent: string
54+
}
4955

50-
if (query.length < 2) {
51-
return NextResponse.json([] as SearchResult[])
52-
}
56+
/**
57+
* The docs corpus, parsed once per process.
58+
*
59+
* Every request used to `existsSync` + `readFileSync` + `gray-matter` + regex
60+
* strip every doc file, on a route with no rate limit that the search UI calls
61+
* on a 200ms debounce. The content is build-time static - it cannot change
62+
* while the server is running - so there is no reason to redo any of it.
63+
*/
64+
let corpus: IndexedDoc[] | null = null
5365

54-
const lowerQuery = query.toLowerCase()
55-
const results: (SearchResult & { score: number })[] = []
66+
function getCorpus(): IndexedDoc[] {
67+
if (corpus) return corpus
5668

69+
const docs: IndexedDoc[] = []
5770
for (const section of docSections) {
5871
for (const item of section.items) {
5972
const slug = item.href.replace('/docs/', '').split('/')
@@ -63,26 +76,59 @@ export async function GET(request: NextRequest) {
6376
const raw = fs.readFileSync(filePath, 'utf-8')
6477
const { data: fm, content } = matter(raw)
6578
const title = (fm.title as string) || item.title
66-
67-
const titleMatch = title.toLowerCase().includes(lowerQuery)
6879
const plainContent = stripMarkdown(content)
69-
const contentMatch = plainContent.toLowerCase().includes(lowerQuery)
70-
71-
if (!titleMatch && !contentMatch) continue
7280

73-
const snippet = contentMatch ? getSnippet(plainContent, query) : plainContent.slice(0, 140).trim() + '…'
74-
75-
results.push({
81+
docs.push({
7682
title,
7783
href: item.href,
7884
section: section.title,
79-
snippet,
80-
matchInTitle: titleMatch,
81-
score: titleMatch ? 10 : 1,
85+
plainContent,
86+
lowerTitle: title.toLowerCase(),
87+
lowerContent: plainContent.toLowerCase(),
8288
})
8389
}
8490
}
8591

92+
corpus = docs
93+
return corpus
94+
}
95+
96+
/**
97+
* Longest query we will scan the corpus for. Past this a query cannot match
98+
* anything meaningful, and the length is attacker-controlled.
99+
*/
100+
const MAX_QUERY_LENGTH = 128
101+
102+
export async function GET(request: NextRequest) {
103+
const query = request.nextUrl.searchParams.get('q')?.trim().slice(0, MAX_QUERY_LENGTH) ?? ''
104+
105+
if (query.length < 2) {
106+
return NextResponse.json([] as SearchResult[])
107+
}
108+
109+
const lowerQuery = query.toLowerCase()
110+
const results: (SearchResult & { score: number })[] = []
111+
112+
for (const doc of getCorpus()) {
113+
const titleMatch = doc.lowerTitle.includes(lowerQuery)
114+
const contentMatch = doc.lowerContent.includes(lowerQuery)
115+
116+
if (!titleMatch && !contentMatch) continue
117+
118+
const snippet = contentMatch
119+
? getSnippet(doc.plainContent, query)
120+
: doc.plainContent.slice(0, 140).trim() + '…'
121+
122+
results.push({
123+
title: doc.title,
124+
href: doc.href,
125+
section: doc.section,
126+
snippet,
127+
matchInTitle: titleMatch,
128+
score: titleMatch ? 10 : 1,
129+
})
130+
}
131+
86132
results.sort((a, b) => b.score - a.score)
87133

88134
return NextResponse.json(

apps/web/app/api/webhook-sample/route.ts

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,18 +37,39 @@ export async function POST(req: Request) {
3737
});
3838
}
3939

40+
// Nothing this endpoint accepts is large. Reading an unbounded body - and
41+
// then HMAC-ing over an unbounded caller-supplied secret - is free CPU for
42+
// anyone who asks, on a route that exists only to show what a payload looks
43+
// like. Both are capped well above any legitimate input.
44+
const MAX_BODY_BYTES = 4_096;
45+
const MAX_SECRET_LENGTH = 256;
46+
const MAX_ADDRESS_LENGTH = 56;
47+
4048
let body: Body = {};
4149
try {
4250
if (req.headers.get("content-type")?.includes("application/json")) {
43-
body = (await req.json()) as Body;
51+
const raw = await req.text();
52+
if (Buffer.byteLength(raw, "utf8") > MAX_BODY_BYTES) {
53+
return Response.json(
54+
{
55+
error: "payload_too_large",
56+
message: `Request body is capped at ${MAX_BODY_BYTES} bytes.`,
57+
},
58+
{ status: 413 },
59+
);
60+
}
61+
body = raw ? (JSON.parse(raw) as Body) : {};
4462
}
4563
} catch {
46-
/* allow empty body */
64+
/* allow empty or malformed body */
4765
}
4866

49-
const secret = body.secret?.trim() || `whsec_demo_${randomBytes(16).toString("hex")}`;
50-
const generatedSecret = !body.secret?.trim();
51-
const address = body.address?.trim() || "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUV";
67+
const callerSecret = body.secret?.trim().slice(0, MAX_SECRET_LENGTH);
68+
const secret = callerSecret || `whsec_demo_${randomBytes(16).toString("hex")}`;
69+
const generatedSecret = !callerSecret;
70+
const address =
71+
body.address?.trim().slice(0, MAX_ADDRESS_LENGTH) ||
72+
"GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUV";
5273

5374
const event = generateSamplePayment(address);
5475
const payload = JSON.stringify(event);

apps/web/app/layout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ const instrumentSerif = Instrument_Serif({
1313
});
1414

1515
export const metadata: Metadata = {
16-
title: "Orbit Stellar - Real-time event infrastructure for Stellar developers",
16+
title: "Orbital - Real-time event infrastructure for Stellar developers",
1717
description:
1818
"Watch any Stellar address. Register webhooks. React hooks for on-chain events. The missing event layer for Stellar developers.",
1919
};

apps/web/components/Footer.tsx

Lines changed: 52 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22

33
import Link from 'next/link'
44

5+
import {
6+
FOOTER_COMMUNITY_LINKS,
7+
FOOTER_PRODUCT_LINKS,
8+
NPM_PACKAGES,
9+
npmUrl,
10+
type NavLink,
11+
} from '@/lib/links'
12+
513
const labelStyle: React.CSSProperties = {
614
fontFamily: 'var(--font-sans)',
715
fontSize: '11px',
@@ -22,6 +30,20 @@ const linkStyle: React.CSSProperties = {
2230
transition: 'color 0.15s',
2331
}
2432

33+
function FooterLink({ label, href, external }: NavLink) {
34+
return (
35+
<Link
36+
href={href}
37+
{...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
38+
style={linkStyle}
39+
onMouseEnter={(e) => (e.currentTarget.style.color = '#fff')}
40+
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--muted2)')}
41+
>
42+
{label}
43+
</Link>
44+
)
45+
}
46+
2547
export default function Footer() {
2648
return (
2749
<footer style={{ borderTop: '1px solid var(--border)', padding: '80px 52px 0' }}>
@@ -46,7 +68,7 @@ export default function Footer() {
4668
marginBottom: '12px',
4769
}}
4870
>
49-
Orbit Stellar
71+
Orbital
5072
</p>
5173
<p
5274
style={{
@@ -69,72 +91,59 @@ export default function Footer() {
6991
>
7092
MIT License
7193
</p>
72-
<p
73-
style={{
74-
fontFamily: 'var(--font-sans)',
75-
fontSize: '12px',
76-
color: 'var(--muted2)',
77-
display: 'flex',
78-
alignItems: 'center',
79-
gap: '6px',
80-
}}
81-
>
82-
<span style={{ color: 'var(--accent)' }}></span>
83-
All systems operational
84-
</p>
94+
{/*
95+
A hardcoded "● All systems operational" badge used to sit here,
96+
next to a "Status" link that went nowhere. There is no uptime
97+
monitor behind it, so it was green even while the demo endpoint
98+
was returning 503. Removed rather than faked - put it back only
99+
alongside a real status source.
100+
*/}
85101
</div>
86102

87103
{/* Product */}
88104
<div>
89105
<span style={labelStyle}>Product</span>
90-
{['Docs', 'SDKs', 'How it works', 'Changelog', 'Status'].map((item) => (
91-
<Link
92-
key={item}
93-
href="#"
94-
style={linkStyle}
95-
onMouseEnter={(e) => (e.currentTarget.style.color = '#fff')}
96-
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--muted2)')}
97-
>
98-
{item}
99-
</Link>
106+
{FOOTER_PRODUCT_LINKS.map((link) => (
107+
<FooterLink key={link.label} {...link} />
100108
))}
101109
</div>
102110

103111
{/* Packages */}
104112
<div>
105113
<span style={labelStyle}>Packages</span>
106-
{[
107-
'npm i @orbital-stellar/pulse-webhooks',
108-
'npm i @orbital-stellar/pulse-notify',
109-
].map((cmd) => (
110-
<p
111-
key={cmd}
114+
{/*
115+
All six published packages, each linked to its npm page. This
116+
listed only two of them as unlinked plain text, which understated
117+
what is actually shipped and gave the reader nowhere to go.
118+
*/}
119+
{NPM_PACKAGES.map((pkg) => (
120+
<Link
121+
key={pkg}
122+
href={npmUrl(pkg)}
123+
target="_blank"
124+
rel="noopener noreferrer"
112125
style={{
113126
fontFamily: 'var(--font-mono)',
114127
fontSize: '13px',
115128
color: 'var(--muted2)',
129+
textDecoration: 'none',
116130
display: 'block',
117131
marginTop: '12px',
132+
transition: 'color 0.15s',
118133
}}
134+
onMouseEnter={(e) => (e.currentTarget.style.color = '#fff')}
135+
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--muted2)')}
119136
>
120-
{cmd}
121-
</p>
137+
@orbital-stellar/{pkg}
138+
</Link>
122139
))}
123140
</div>
124141

125142
{/* Community */}
126143
<div>
127144
<span style={labelStyle}>Community</span>
128-
{['GitHub', 'Twitter', 'SCF Grant', 'Open an issue'].map((item) => (
129-
<Link
130-
key={item}
131-
href="#"
132-
style={linkStyle}
133-
onMouseEnter={(e) => (e.currentTarget.style.color = '#fff')}
134-
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--muted2)')}
135-
>
136-
{item}
137-
</Link>
145+
{FOOTER_COMMUNITY_LINKS.map((link) => (
146+
<FooterLink key={link.label} {...link} />
138147
))}
139148
</div>
140149
</div>
@@ -157,7 +166,7 @@ export default function Footer() {
157166
color: 'var(--muted)',
158167
}}
159168
>
160-
© 2026 Orbit Stellar
169+
© 2026 Orbital
161170
</span>
162171
<span
163172
style={{

apps/web/components/Hero.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import Link from "next/link";
44
import { motion } from "framer-motion";
55

6+
import { GITHUB_REPO } from "@/lib/links";
7+
68
const ease = [0.22, 1, 0.36, 1] as const;
79

810
const fadeUp = (delay: number) => ({
@@ -85,7 +87,7 @@ export default function Hero() {
8587
style={{ display: "flex", gap: "12px", flexWrap: "wrap", justifyContent: "center" }}
8688
>
8789
<Link
88-
href="#"
90+
href="/docs"
8991
style={{
9092
background: "var(--accent)",
9193
color: "#000",
@@ -100,7 +102,9 @@ export default function Hero() {
100102
Read the docs
101103
</Link>
102104
<Link
103-
href="#"
105+
href={GITHUB_REPO}
106+
target="_blank"
107+
rel="noopener noreferrer"
104108
style={{
105109
background: "transparent",
106110
color: "#fff",

apps/web/components/HowItWorks.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ const STEPS = [
1111
},
1212
{
1313
num: "03",
14-
title: "Orbit Stellar",
14+
title: "Orbital",
1515
description: "Filters by address, normalizes the payload, and routes to subscribers.",
1616
},
1717
{
@@ -23,7 +23,7 @@ const STEPS = [
2323

2424
export default function HowItWorks() {
2525
return (
26-
<section style={{ padding: "120px 32px" }}>
26+
<section id="how-it-works" style={{ padding: "120px 32px" }}>
2727
<div style={{ maxWidth: "var(--max-width)", margin: "0 auto" }}>
2828
<h2
2929
style={{

0 commit comments

Comments
 (0)