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
122 changes: 110 additions & 12 deletions src/services/versions/v1/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
gte as semverGte,
valid as semverValid
} from "semver";
import { Releases, ReleaseDetails } from "./interfaces";
import { Releases, ReleaseDetails, ResolvedReleaseDetails } from "./interfaces";
import { getReleaseR2Object } from "./r2";
import { getPlatform } from "../../../lib/middleware";
import { ICache } from "../../../lib/interfaces";
Expand Down Expand Up @@ -124,6 +124,53 @@ function buildSuccessResponse<T>(
};
}

// FOSSBilling's own Update.php sends this on every request already (src/di.php,
// added in 0.8.4), so no client change is needed to gate the R2 mirror by version.
// Anything we can't confidently read a trusted version out of - no header at all
// (<=0.8.3, which predates this header), or a version we don't recognize - falls
// through to `false`, matching the fully-backward-compatible default in
// resolveReleaseForClient() below.
const USER_AGENT_VERSION_PATTERN = /^FOSSBilling\/(.+)$/;

// First version whose Update::$allowedDownloadPrefixes trusts
// download.fossbilling.org. Older clients reject that URL outright with
// "Update canceled for security reasons" - see FOSSBilling/FOSSBilling
// incident following #4255/#2479.
const MIRROR_TRUST_MIN_VERSION = "0.8.7";

function clientTrustsMirror(userAgent: string | undefined | null): boolean {
if (!userAgent) return false;

const match = userAgent.match(USER_AGENT_VERSION_PATTERN);
if (!match) return false;

const version = match[1];
if (!semverValid(version)) return false;

return semverGte(version, MIRROR_TRUST_MIN_VERSION);
}

// Resolves a release's dual (GitHub + optional R2 mirror) download info down to
// the single URL/digest pair this specific requester should be sent, and drops
// the internal-only mirror_* fields from the public response shape.
function resolveReleaseForClient(
release: ReleaseDetails,
userAgent: string | undefined | null
): ResolvedReleaseDetails {
const {
mirror_download_url: mirrorDownloadUrl,
mirror_digest: mirrorDigest,
...resolved
} = release;

if (mirrorDownloadUrl !== null && clientTrustsMirror(userAgent)) {
resolved.download_url = mirrorDownloadUrl;
resolved.digest = mirrorDigest;
}

return resolved;
}

interface ReleaseAsset {
name: string;
browser_download_url: string;
Expand Down Expand Up @@ -152,9 +199,19 @@ registerCachedRoute("/", async (c) => {

if (hasNoReleases(releases)) {
c.header("Vary", "*");
} else {
c.header("Vary", "User-Agent");
}

return c.json(buildSuccessResponse(releases, result.source));
const userAgent = c.req.header("User-Agent");
const resolvedReleases = Object.fromEntries(
Object.entries(releases).map(([tag, release]) => [
tag,
resolveReleaseForClient(release, userAgent)
])
);

return c.json(buildSuccessResponse(resolvedReleases, result.source));
});

versionsV1.get(
Expand Down Expand Up @@ -310,17 +367,26 @@ registerCachedRoute("/:version", async (c) => {
});
}

c.header("Vary", "User-Agent");
const userAgent = c.req.header("User-Agent");

if (version === "latest") {
const sortedKeys = Object.keys(releases).sort(semverCompare);
const lastKey = sortedKeys.at(-1);
const resolved = lastKey
? resolveReleaseForClient(releases[lastKey], userAgent)
: null;

return c.json(
buildSuccessResponse(lastKey ? releases[lastKey] : null, result.source)
);
return c.json(buildSuccessResponse(resolved, result.source));
}

if (version in releases) {
return c.json(buildSuccessResponse(releases[version], result.source));
return c.json(
buildSuccessResponse(
resolveReleaseForClient(releases[version], userAgent),
result.source
)
);
}

c.status(404);
Expand Down Expand Up @@ -473,9 +539,11 @@ export async function getReleases(
}
}

// Prefer the R2 mirror over the GitHub asset - github.com has no AAAA
// record, so IPv6-only hosts can't reach it. Fall back to GitHub for
// releases that predate mirroring or if the R2 lookup fails.
// Record both the GitHub asset and the R2 mirror (if this release has
// one) rather than resolving to a single download_url here - which of
// the two a given client should actually be sent depends on whether
// *that client's own version* trusts download.fossbilling.org, and is
// decided per-request in resolveReleaseForClient().
const releaseEntries: [string, ReleaseDetails][] = await Promise.all(
releasesToProcess.map(
async ({ tag, release, zipAsset, cachedPhpVersion }) => {
Expand All @@ -499,13 +567,14 @@ export async function getReleases(
version: release.name || tag,
released_on: release.published_at ?? "",
minimum_php_version: phpVersion,
download_url:
r2Object?.downloadUrl ?? zipAsset.browser_download_url,
download_url: zipAsset.browser_download_url,
mirror_download_url: r2Object?.downloadUrl ?? null,
size_bytes: zipAsset.size,
is_prerelease: Boolean(release.prerelease),
github_release_id: release.id ?? 0,
changelog: release.body || "",
digest: r2Object?.digest ?? zipAsset.digest ?? null
digest: zipAsset.digest ?? null,
mirror_digest: r2Object?.digest ?? null
};
return [tag, releaseDetails];
}
Expand Down Expand Up @@ -612,6 +681,35 @@ function parseCachedReleases(
if (release.digest === undefined) {
release.digest = null;
}

// A release missing *either* mirror field predates them (both are
// always written together - see getReleases() below - so a partial
// pair means this entry is malformed or from before they existed),
// which includes the window when download_url itself was written
// as the (unconditionally preferred) R2 mirror URL, with no
// separate field preserving the GitHub URL every older client
// trusts. There's no `null` to backfill that repairs it: the
// GitHub URL isn't recoverable from this cache entry at all, and
// serving download_url as-is risks handing every client - not
// just old ones - the untrusted mirror URL. A one-sided pair is
// just as unsafe: resolveReleaseForClient() would pair a real
// mirror_download_url with an undefined mirror_digest (or vice
// versa) for a trusting client, and JSON drops that undefined key
// from the response entirely - incomplete update metadata. Invalidate
// the whole cache so getReleases() falls through to a fresh fetch,
// which rebuilds every entry with its GitHub and mirror URLs kept
// separate again.
if (
release.mirror_download_url === undefined ||
release.mirror_digest === undefined
) {
logWarn(
"versions",
"Cache entry predates mirror fields; invalidating cache to rebuild with separated URLs",
{ cacheKey: RELEASE_CACHE_KEY, version: release.version }
);
return null;
}
}
return parsedCache as Releases;
}
Expand Down
18 changes: 18 additions & 0 deletions src/services/versions/v1/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,34 @@ export type ReleaseDetails = {
version: string;
released_on: string;
minimum_php_version: string;
// Always the GitHub asset URL - every FOSSBilling version ever released
// trusts this, so it's the safe default for a client we can't identify.
download_url: string;
// The R2 mirror URL, if this release has one. Only versions >=
// MIRROR_TRUST_MIN_VERSION have download.fossbilling.org in their signed
// download-URL allowlist (Update::$allowedDownloadPrefixes) and will
// accept it - see resolveReleaseForClient().
mirror_download_url: string | null;
size_bytes: number;
is_prerelease: boolean;
github_release_id: number;
changelog: string;
// SHA-256 digest of the release zip (`sha256:<hex>`), as computed by
// GitHub; null for older assets predating GitHub's digest support.
digest: string | null;
// Digest for mirror_download_url. Set alongside it from the R2 object's
// own custom metadata - see getReleaseR2Object().
mirror_digest: string | null;
};

// The public response shape returned to FOSSBilling clients - download_url/digest
// already resolved to whichever source (GitHub or the R2 mirror) that specific
// client trusts. See resolveReleaseForClient().
export type ResolvedReleaseDetails = Omit<
ReleaseDetails,
"mirror_download_url" | "mirror_digest"
>;

export type Releases = {
[version: string]: ReleaseDetails;
};
19 changes: 14 additions & 5 deletions test/services/stats/v1/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,10 +262,16 @@ describe("Stats API v1", () => {
describe("Shared release cache", () => {
// getReleases writes gh-fossbilling-releases - the same cache key the
// versions service reads - so a stats-triggered fresh fetch must still
// resolve R2 download_url/digest. Otherwise stats would overwrite that
// cache with GitHub-only URLs for up to 24h, silently undoing the R2
// preference for IPv6-only hosts. See FOSSBilling/FOSSBilling#2479.
it("resolves R2 download_url/digest when it triggers the shared release fetch", async () => {
// resolve the R2 mirror. Otherwise stats would overwrite that cache with
// GitHub-only entries for up to 24h, silently undoing the R2 mirror for
// clients that trust it. See FOSSBilling/FOSSBilling#2479.
//
// The cache stores both download_url (GitHub - always trusted) and
// mirror_download_url (R2, when mirrored); which one a given FOSSBilling
// install is actually sent is resolved per-request in the versions
// service based on that client's own reported version, not baked into
// this shared cache - see resolveReleaseForClient() there.
it("resolves both the GitHub and R2 mirror URLs when it triggers the shared release fetch", async () => {
await env.DOWNLOAD_BUCKET.put(
"releases/0.6.0/FOSSBilling-0.6.0.zip",
"mirrored archive contents",
Expand All @@ -292,9 +298,12 @@ describe("Stats API v1", () => {
const releases = JSON.parse(cached!);

expect(releases["0.6.0"].download_url).toBe(
"https://github.com/FOSSBilling/FOSSBilling/releases/download/0.6.0/FOSSBilling.zip"
);
expect(releases["0.6.0"].mirror_download_url).toBe(
"https://download.fossbilling.org/releases/0.6.0/FOSSBilling-0.6.0.zip"
);
expect(releases["0.6.0"].digest).toBe(
expect(releases["0.6.0"].mirror_digest).toBe(
"sha256:deadbeefcafe0000000000000000000000000000000000000000000000000000"
);
});
Expand Down
7 changes: 6 additions & 1 deletion test/services/versions/v1/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,12 @@ describe("Versions API v1 - Error Handling", () => {
size_bytes: 1000,
is_prerelease: false,
github_release_id: 1,
changelog: "Release notes"
changelog: "Release notes",
// Present (as null) so this entry isn't treated as predating the
// mirror fields entirely - see the "R2 release mirror" describe
// block in index.test.ts for that invalidation behavior.
mirror_download_url: null,
mirror_digest: null
}
})
);
Expand Down
Loading
Loading