openvibers/openvibe.network Commits

Keep Link installer current during GitHub API rate limits (#826)

OP OpenVibers committed 2 days ago · 528a1d0326 · 5 files, +97 −9

* Keep Link installer current when GitHub release API is limited

* Document Link release fallback in SSRF review

Changes

ddiff --git a/server/link/release.js b/server/link/release.js
iindex c04e6fdd..a84bc409 100644
---- a/server/link/release.js
++++ b/server/link/release.js
@@@ -149,6 +149,34 @@ async function newestTag({ fetchImpl = globalThis.fetch } = {}) {
return tag;
}
++/** The newest *published* release, through GitHub's public redirect when its REST API is rate limited. */
++async function publishedTag({ fetchImpl = globalThis.fetch } = {}) {
++ const signal = typeof AbortSignal.timeout === 'function' ? AbortSignal.timeout(TIMEOUT_MS) : undefined;
++ const res = await fetchImpl(LATEST, {
++ method: 'HEAD', redirect: 'manual', headers: { 'User-Agent': 'openvibe-network' }, signal,
++ });
++ if (!res || res.status !== 302 || !res.headers || typeof res.headers.get !== 'function') {
++ throw new Error('GitHub latest release did not redirect');
++ }
++ const target = new URL(res.headers.get('location'));
++ const prefix = `/${REPO}/releases/tag/`;
++ const tag = target.pathname.startsWith(prefix) ? target.pathname.slice(prefix.length) : '';
++ if (target.origin !== 'https://github.com' || !TAG_REF_RE.test(tag)) {
++ throw new Error('GitHub latest release did not name a version');
++ }
++ return tag;
++}
++
++/** Release links that can be formed without the rate-limited asset metadata. */
++function partialRelease(tag) {
++ const base = `https://github.com/${REPO}/releases`;
++ return {
++ version: tag, assets: [], publishedAt: '', url: `${base}/tag/${tag}`,
++ installUrl: `${base}/download/${tag}/install.sh`,
++ checksumsUrl: `${base}/download/${tag}/SHA256SUMS`, partial: true,
++ };
++}
++
/** Ask GitHub once and keep the answer. Resolves to the shaped release; rejects when GitHub could not be read. */
async function fetchLatest({ fetchImpl = globalThis.fetch, now = Date.now } = {}) {
const controller = typeof AbortController === 'function' ? new AbortController() : null;
@@@ -186,7 +214,7 @@ function refresh({ fetchImpl = globalThis.fetch, now = Date.now } = {}) {
if (cached) {
try {
const tag = await newestTag({ fetchImpl });
-- if (tag === cached.data.version) {
++ if (tag === cached.data.version && !cached.data.partial) {
cached = { at: now(), data: cached.data };
failedAt = 0;
saveDisk(cached);
@@@ -196,7 +224,25 @@ function refresh({ fetchImpl = globalThis.fetch, now = Date.now } = {}) {
// ls-refs did not answer: ask the REST API as before.
}
}
-- return fetchLatest({ fetchImpl, now });
++ try {
++ return await fetchLatest({ fetchImpl, now });
++ } catch {
++ // The host may exhaust GitHub's unauthenticated REST limit. Its public latest-release redirect
++ // still identifies a published release, so the page and installer can advance without guessing
++ // from a tag that might not have any release assets yet.
++ try {
++ const tag = await publishedTag({ fetchImpl });
++ if (tag !== cached.data.version || cached.data.partial) {
++ cached = { at: now(), data: partialRelease(tag) };
++ failedAt = 0;
++ saveDisk(cached);
++ return cached.data;
++ }
++ } catch {
++ // Keep the last good answer when GitHub's public redirect is unreachable too.
++ }
++ return null;
++ }
})()
.catch(() => null)
.finally(() => { inflight = null; });
ddiff --git a/server/link/routes.js b/server/link/routes.js
iindex dc2ab34b..e9d0ab29 100644
---- a/server/link/routes.js
++++ b/server/link/routes.js
@@@ -167,12 +167,12 @@ function createLinkRoutes({ releaseId, releaseApi = release, db = null, config =
// www is not a second name for Link: every path lands on the apex, and nginx does the same before us.
if (host === WWW_HOST) return res.redirect(301, `${SITE}${path}${url.search}`);
-- // The installer: the latest release's own asset, so `curl -fsSL https://openvibe.link/install | sh`
-- // installs that release. The same cached answer the page shows.
++ // The installer: a stale cached release must never pin an older version while GitHub's metadata
++ // API is rate limited. GitHub's latest/download redirect resolves to the newest published asset.
if (path === '/install') {
let rel;
try { rel = await releaseApi.latest(); } catch { rel = release.FALLBACK; }
-- const target = rel && rel.installUrl ? rel.installUrl : release.FALLBACK.installUrl;
++ const target = rel && !rel.stale && rel.installUrl ? rel.installUrl : release.FALLBACK.installUrl;
return res.set('Cache-Control', 'public, max-age=600').redirect(302, target);
}
ddiff --git a/test/link-home.test.js b/test/link-home.test.js
iindex bbb7a186..e1d80102 100644
---- a/test/link-home.test.js
++++ b/test/link-home.test.js
@@@ -217,7 +217,12 @@ const SHAPED = {
const linkInstall = await get('/install', 'openvibe.link');
assert.strictEqual(linkInstall.status, 302);
-- assert.ok(String(linkInstall.headers.location || '').endsWith('/install.sh'));
++ assert.strictEqual(linkInstall.headers.location, SHAPED.installUrl);
++ releaseApi.latest = async () => ({ ...SHAPED, stale: true });
++ const staleInstall = await get('/install', 'openvibe.link');
++ assert.strictEqual(staleInstall.headers.location, release.FALLBACK.installUrl,
++ 'a stale cache sends the installer to GitHub\'s latest published asset');
++ releaseApi.latest = async () => SHAPED;
const machines = await get('/machines', 'openvibe.link');
assert.strictEqual(machines.status, 302, 'a signed-out arrival starts the silent sign-in, not the hub');
ddiff --git a/test/node-release-cache.test.js b/test/node-release-cache.test.js
iindex 0fd656f6..c8a0ccac 100644
---- a/test/node-release-cache.test.js
++++ b/test/node-release-cache.test.js
@@@ -80,7 +80,7 @@ const fresh = () => {
assert.strictEqual(stale.stale, true, 'and marked stale');
assert.ok(stale.ageMs >= 15 * 60e3, 'its age comes with it');
await restarted.whenIdle();
-- assert.strictEqual(tries, 2, 'the refresh ran in the background: ls-refs, then the REST fallback');
++ assert.strictEqual(tries, 3, 'the refresh tried ls-refs, REST, then the public release redirect');
const kept = await restarted.latest({
fetchImpl: async () => { tries += 1; throw new Error('still down'); },
@@@ -88,7 +88,7 @@ const fresh = () => {
dataDir: dir,
});
assert.strictEqual(kept.version, 'v1.2.3', 'the failure did not lose the answer');
-- assert.strictEqual(tries, 2, 'and the cooldown kept the next visitor off GitHub');
++ assert.strictEqual(tries, 3, 'and the cooldown kept the next visitor off GitHub');
// ── Refresh: once GitHub is back, the next request gets the new release from the cache ──
now += 16 * 60e3;
@@@ -164,6 +164,43 @@ const fresh = () => {
fs.rmSync(tagDir, { recursive: true, force: true });
}
++ // A host can exhaust the REST rate limit while GitHub's public release redirect still works.
++ // The published release advances the page; a later REST success fills in its asset metadata.
++ const limitedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-node-limited-'));
++ try {
++ const limited = fresh();
++ await limited.latest({ fetchImpl: ok('v0.54.0'), now: () => now, dataDir: limitedDir });
++ now += 16 * 60e3;
++ let restCalls = 0;
++ const limitedFetch = async (url) => {
++ if (isGit(url)) return { ok: true, status: 200, text: async () => LS(['v0.56.0']) };
++ if (url === limited.LATEST) return {
++ status: 302,
++ headers: { get: () => 'https://github.com/OpenVibers/OpenVibe.Link/releases/tag/v0.56.0' },
++ };
++ restCalls += 1;
++ return { ok: false, status: 403 };
++ };
++ const expired = await limited.latest({ fetchImpl: limitedFetch, now: () => now, dataDir: limitedDir });
++ assert.strictEqual(expired.version, 'v0.54.0', 'the first expired request is answered immediately');
++ await limited.whenIdle();
++ const advanced = await limited.latest({ fetchImpl: limitedFetch, now: () => now + 1000, dataDir: limitedDir });
++ assert.strictEqual(restCalls, 1, 'the rate-limited API was tried once');
++ assert.strictEqual(advanced.version, 'v0.56.0', 'the public redirect names the published release');
++ assert.strictEqual(advanced.partial, true, 'asset metadata is marked incomplete');
++ assert.deepStrictEqual(advanced.assets, []);
++ assert.strictEqual(advanced.installUrl, 'https://github.com/OpenVibers/OpenVibe.Link/releases/download/v0.56.0/install.sh');
++
++ now += 16 * 60e3;
++ await limited.latest({ fetchImpl: ok('v0.56.0'), now: () => now, dataDir: limitedDir });
++ await limited.whenIdle();
++ const complete = await limited.latest({ fetchImpl: ok('v0.56.0'), now: () => now + 1000, dataDir: limitedDir });
++ assert.ok(!complete.partial, 'a REST success replaces the incomplete answer');
++ assert.ok(complete.assets.includes('install.sh'), 'and restores the asset list');
++ } finally {
++ fs.rmSync(limitedDir, { recursive: true, force: true });
++ }
++
// ── The token: GITHUB_TOKEN is sent when set, from the environment only ──
const sent = [];
const tokenDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-node-token-'));
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 4f22ea07..9b0bebf4 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -148,7 +148,7 @@ const REVIEWED = {
'server/registry/deploy-drift.js fetch': [2, 'fixed hosts: api.github.com (compare) and github.com (git ls-refs); the repository matches REPO_RE (OpenVibers/ only)'],
'server/registry/ecosystem.js fetch': [2, 'service URLs from openvibe-contracts manifests and operator env overrides'],
'server/registry/library-tags.js fetch': [1, 'fixed host api.github.com'],
-- 'server/link/release.js fetch': [2, 'the Node\'s own release page: fixed hosts only — https://github.com/OpenVibers/OpenVibe.Link.git/git-upload-pack (ls-refs of refs/tags/v, no REST call) and the fixed GitHub API path (https://api.github.com/repos/OpenVibers/OpenVibe.Link/releases/latest), each with an 8 s timeout; the REST answer is held 15 minutes in memory and on disk and read only when a new tag exists; no request value ever names a host and it only ever reads the public release list'],
++ 'server/link/release.js fetch': [3, 'Link\'s own release page: fixed hosts only — GitHub git-upload-pack for release tags, GitHub releases/latest API for asset metadata, and a HEAD to the fixed github.com/OpenVibers/OpenVibe.Link/releases/latest page when REST is rate limited; the HEAD does not follow the redirect and its destination must be the expected GitHub release tag path; all requests time out after 8 s, no visitor value names a host, and the answer is cached for 15 minutes'],
'server/registry/machines.js fetch': [1, 'the machines hub calls Bot: the internal URL from operator config (OV_BOT_INTERNAL_URL, loopback by default), the paths are fixed, the machine id is a checked dev_ id (percent-encoded) and the person is named in a header, never in the URL'],
'server/presence/index.js fetch': [2, "Live and Chat internal URLs from operator config (loopback defaults): Live's public /api/streams (fixed path, 2 s timeout, held 30 s) and Chat's POST /internal/chat/dm/peers (fixed path, 1.5 s, the subject list in the body); never a request value names a host"],
'server/updates/routes.js fetch': [1, 'OV_BLOG_INTERNAL_URL (operator config)'],