Dev#1
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
| export const refreshRepositoryStars = action({ | ||
| args: { | ||
| owner: v.string(), | ||
| repo: v.string(), | ||
| }, | ||
| handler: async (ctx, args) => { | ||
| const now = Date.now(); | ||
| const current = await ctx.runQuery(api.github.getRepositoryStars, args); |
There was a problem hiding this comment.
🔴 Public action leaks server GITHUB_TOKEN to query arbitrary repositories
The refreshRepositoryStars action is a public Convex action (not internalAction) that accepts arbitrary owner and repo strings from any unauthenticated client. The server then uses its GITHUB_TOKEN to make authenticated requests to any GitHub repository the caller specifies.
Root Cause and Impact
At packages/backend/convex/github.ts:153-159, the server constructs a GitHub API URL from user-supplied args.owner and args.repo and attaches the server's GITHUB_TOKEN as a Bearer token:
const endpoint = `https://api.github.com/repos/${encodeURIComponent(args.owner)}/${encodeURIComponent(args.repo)}`;
const githubToken = process.env.GITHUB_TOKEN;
// ...
headers.Authorization = `Bearer ${githubToken.trim()}`;The star count result is then stored in the database via setRepositoryStars and is readable by any client through the public getRepositoryStars query (packages/backend/convex/github.ts:35-56).
Impact:
- Information disclosure: An attacker can retrieve star counts of private repositories by calling
refreshRepositoryStars({owner: "target-org", repo: "private-repo"})then reading the result viagetRepositoryStars. The star count of a private repo is not public information. - Rate limit exhaustion: The cooldown is per-slug, so an attacker can use many different owner/repo combinations to bypass it entirely and exhaust the server's GitHub API rate limit.
- Database pollution: Arbitrary entries are created in
githubRepositoryStatsfor any owner/repo combination.
The action should validate that the requested repository matches the expected RELEASE_REPOSITORY constant or a server-side whitelist.
Prompt for agents
In packages/backend/convex/github.ts, the refreshRepositoryStars action should validate that the requested owner/repo matches an expected allowlist (e.g. the RELEASE_REPOSITORY). Add a server-side constant or environment variable for the allowed repository slug, and reject requests that don't match. For example, add a check at the top of the handler like: const allowedSlug = process.env.ALLOWED_REPOSITORY_SLUG || 'Noisemaker111/opencode-mono'; const requestedSlug = `${args.owner}/${args.repo}`; if (requestedSlug !== allowedSlug) { return { refreshed: false, source: 'not-allowed' }; }. The same validation should be applied to getRepositoryStars if private repo star counts should not be queryable.
Was this helpful? React with 👍 or 👎 to provide feedback.
| if (stars >= 1_000) { | ||
| const thousands = stars / 1_000; | ||
| const value = thousands >= 10 ? Math.round(thousands).toString() : thousands.toFixed(1); | ||
| return `${value}k`; |
There was a problem hiding this comment.
🟡 formatStarCount produces "1000k" instead of "1M" due to rounding overflow
When stars is between 999,500 and 999,999, formatStarCount returns "1000k" instead of the expected "1M".
Detailed Explanation
At apps/web/src/routes/index.tsx:755-758, the code divides by 1000 and rounds:
if (stars >= 1_000) {
const thousands = stars / 1_000;
const value = thousands >= 10 ? Math.round(thousands).toString() : thousands.toFixed(1);
return `${value}k`;
}For stars = 999_500: thousands = 999.5, thousands >= 10 is true, Math.round(999.5) = 1000, so the function returns "1000k". The same class of issue exists at the millions boundary (e.g., stars = 9_999_500 would produce "10.0M" via toFixed(1) instead of "10M").
Expected: Values that round up past a boundary should be formatted with the next unit (e.g. "1M" not "1000k").
Impact: Cosmetic — the star badge in the menu bar would display an awkward label like "1000k Stars" for repos with ~999.5k stars.
| if (stars >= 1_000) { | |
| const thousands = stars / 1_000; | |
| const value = thousands >= 10 ? Math.round(thousands).toString() : thousands.toFixed(1); | |
| return `${value}k`; | |
| if (stars >= 1_000) { | |
| const thousands = stars / 1_000; | |
| const value = thousands >= 10 ? Math.round(thousands).toString() : thousands.toFixed(1); | |
| if (value === "1000") { | |
| return "1M"; | |
| } | |
| return `${value}k`; | |
| } |
Was this helpful? React with 👍 or 👎 to provide feedback.
| const windowsUrl = extractUpdaterUrl( | ||
| platformEntries, | ||
| (key, url) => | ||
| key.includes("windows") || url.toLowerCase().includes(".msi") || url.toLowerCase().includes(".exe") || url.toLowerCase().includes("windows"), | ||
| ); | ||
| const linuxUrl = extractUpdaterUrl( | ||
| platformEntries, | ||
| (key, url) => | ||
| key.includes("linux") || | ||
| url.toLowerCase().includes(".appimage") || | ||
| url.toLowerCase().includes(".deb") || | ||
| url.toLowerCase().includes(".rpm") || | ||
| url.toLowerCase().includes("linux"), |
There was a problem hiding this comment.
🟡 windowsUrl and linuxUrl updater fallback matchers lack platform-key scoping, unlike macOS matchers
The extractUpdaterFallbackUrls function has an inconsistency in how it matches platform entries. The macOS matchers (lines 262-272) correctly use && to require both a platform key match AND an architecture/URL match. However, the windowsUrl matcher (lines 274-278) and the linuxUrl matcher (lines 279-286) use only top-level ||, meaning any entry from any platform could match if the URL happens to contain .msi, .exe, windows, .appimage, .deb, .rpm, or linux.
Root Cause
macOS matchers correctly scope:
(key.includes("darwin") || key.includes("mac")) &&
(key.includes("x86_64") || ...)
But Windows uses:
key.includes("windows") || url.toLowerCase().includes(".msi") || url.toLowerCase().includes(".exe") || url.toLowerCase().includes("windows")
Because the || is at the top level, the platform key requirement is optional. If the RELEASE_REPOSITORY name contains windows or linux, the resulting download URLs in the Tauri updater manifest would ALL contain that substring in their path, causing a darwin-aarch64 entry to be incorrectly selected as windowsUrl or linuxUrl. For example, a repo like user/my-windows-tool would make url.toLowerCase().includes("windows") match the macOS entry first, serving a macOS .app.tar.gz as the Windows download link.
Impact: Users on certain repo configurations could be offered wrong-platform download links when the updater manifest fallback is triggered.
| const windowsUrl = extractUpdaterUrl( | |
| platformEntries, | |
| (key, url) => | |
| key.includes("windows") || url.toLowerCase().includes(".msi") || url.toLowerCase().includes(".exe") || url.toLowerCase().includes("windows"), | |
| ); | |
| const linuxUrl = extractUpdaterUrl( | |
| platformEntries, | |
| (key, url) => | |
| key.includes("linux") || | |
| url.toLowerCase().includes(".appimage") || | |
| url.toLowerCase().includes(".deb") || | |
| url.toLowerCase().includes(".rpm") || | |
| url.toLowerCase().includes("linux"), | |
| const windowsUrl = extractUpdaterUrl( | |
| platformEntries, | |
| (key, url) => | |
| key.includes("windows") && | |
| (key.includes("x86_64") || key.includes("x64") || url.toLowerCase().includes(".msi") || url.toLowerCase().includes(".exe")), | |
| ); | |
| const linuxUrl = extractUpdaterUrl( | |
| platformEntries, | |
| (key, url) => | |
| key.includes("linux") && | |
| (url.toLowerCase().includes(".appimage") || | |
| url.toLowerCase().includes(".deb") || | |
| url.toLowerCase().includes(".rpm") || | |
| url.toLowerCase().includes("linux")), | |
| ); |
Was this helpful? React with 👍 or 👎 to provide feedback.
| const RELEASES_API_BASE_URL = `https://api.github.com/repos/${RELEASE_REPOSITORY}`; | ||
| const RELEASES_API_URL = `${RELEASES_API_BASE_URL}/releases/latest`; | ||
| const RELEASES_LIST_API_URL = `${RELEASES_API_BASE_URL}/releases?per_page=20`; | ||
| const UPDATER_MANIFEST_URL = `https://github.com/${RELEASE_REPOSITORY}/releases/latest/download/latest.json`; |
There was a problem hiding this comment.
🟡 Updater manifest fallback URL always resolves to the latest stable release, giving wrong download links on the dev channel
The UPDATER_MANIFEST_URL at apps/web/src/routes/index.tsx:34 is constructed as https://github.com/${RELEASE_REPOSITORY}/releases/latest/download/latest.json. GitHub's /releases/latest endpoint always redirects to the latest non-prerelease release.
Root Cause and Impact
When RELEASE_CHANNEL === "dev" and the primary GitHub Releases API call fails (network error, rate limit, etc.), the code falls back to applyUpdaterManifestFallback() at lines 1001, 1016, and 1022. This function fetches UPDATER_MANIFEST_URL, which resolves to the latest stable release's latest.json, not the latest dev/prerelease release.
As a result, dev-channel users who hit the fallback path see download links pointing to stable release assets (e.g., stable .dmg and .exe URLs) instead of the expected prerelease/dev assets. The primary path correctly uses RELEASES_LIST_API_URL with pickReleaseForChannel to select a prerelease, but the fallback path has no equivalent channel awareness.
Impact: Dev-channel visitors may be directed to download stable binaries instead of dev binaries when the GitHub API is unavailable.
Prompt for agents
In apps/web/src/routes/index.tsx, the UPDATER_MANIFEST_URL on line 34 always uses /releases/latest/ which resolves to the latest non-prerelease release. For the dev channel, this gives the wrong assets. Consider either: (1) skipping the updater manifest fallback entirely when RELEASE_CHANNEL === 'dev' (since there is no reliable static URL for the latest prerelease's latest.json), or (2) constructing the updater manifest URL dynamically based on the dev release tag if available. The simplest fix is to guard the applyUpdaterManifestFallback calls (around lines 1001, 1016, 1022) so they only execute when RELEASE_CHANNEL === 'stable'.
Was this helpful? React with 👍 or 👎 to provide feedback.
| return false; | ||
| } | ||
|
|
||
| if (!isActive) { | ||
| return false; | ||
| } | ||
|
|
||
| applyDownloadUi(buildDownloadOptions([], fallbackUrls), platform, architecture, null); |
There was a problem hiding this comment.
🔴 Updater manifest URLs served as user-facing download links point to wrong file format
When the GitHub Releases API is unavailable, applyUpdaterManifestFallback fetches the Tauri updater manifest (latest.json) and uses its platform URLs as fallback download links. However, Tauri updater manifest URLs point to updater bundles (.app.tar.gz for macOS, .nsis.zip for Windows), not the user-installable artifacts (.dmg, .exe/.msi).
Root Cause and Impact
At apps/web/src/routes/index.tsx:1097, the fallback path calls:
applyDownloadUi(buildDownloadOptions([], fallbackUrls), platform, architecture, null);
with an empty assets array and fallbackUrls extracted from the updater manifest. Since primary assets is empty, buildDownloadOptions falls through to the fallback URLs at lines 377-405.
These fallback URLs come from extractUpdaterFallbackUrls (line 273-333) which reads platforms.*.url from the Tauri updater JSON. With "createUpdaterArtifacts": true in tauri.conf.json:63, these URLs point to compressed updater bundles:
- macOS:
OpenUsage.app.tar.gz(not.dmg) - Windows:
OpenUsage_x64-setup.nsis.zip(not.exeor.msi)
Meanwhile, the download cards still display subtitles like "arm64 dmg", "x64 dmg", "x64 installer" — which are hardcoded in buildDownloadOptions at lines 413, 423, 432. Users would download a .tar.gz or .nsis.zip expecting a .dmg or .exe installer. On Windows especially, .nsis.zip cannot be installed directly without manual extraction.
Impact: When the GitHub releases API is rate-limited or unavailable, users are presented with misleading download links that deliver updater bundles instead of installer files, with incorrect file format labels.
Prompt for agents
The applyUpdaterManifestFallback function at apps/web/src/routes/index.tsx:1075-1101 uses Tauri updater manifest URLs as user-facing download links. These URLs point to updater bundles (.app.tar.gz, .nsis.zip) rather than installer files (.dmg, .exe). Two approaches to fix:
1. (Preferred) Transform updater manifest URLs to their corresponding installer asset URLs. Since GitHub release assets follow predictable naming, derive the .dmg/.exe URLs from the updater bundle URLs by stripping the updater suffixes (e.g., replace .app.tar.gz with .dmg, replace _x64-setup.nsis.zip with _x64-setup.exe).
2. (Alternative) If serving updater bundles is acceptable as a last resort, update the subtitle text in buildDownloadOptions to reflect the actual file format when fallback URLs are used (e.g., change subtitle from "arm64 dmg" to "arm64 tar.gz" when the URL ends in .tar.gz).
Was this helpful? React with 👍 or 👎 to provide feedback.
Uh oh!
There was an error while loading. Please reload this page.