@khanhcan148/mk 0.1.14 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,15 +17,22 @@ const TARBALL_URL = `${GITHUB_API}/repos/${KIT_REPO}/tarball/${KIT_BRANCH}`;
17
17
  * from crafted tarballs with large size fields. 50 MB is well above any kit file. */
18
18
  const MAX_ENTRY_SIZE = 50 * 1024 * 1024; // 52428800 bytes
19
19
 
20
+ /** Hostnames allowed for kit downloads. Hoisted to module scope to avoid rebuilding on each call. */
21
+ const ALLOWED_HOSTS = new Set(['github.com', 'api.github.com', 'codeload.github.com']);
22
+
23
+ /** HTTP status codes that indicate a redirect. Hoisted to module scope alongside ALLOWED_HOSTS. */
24
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
25
+
20
26
  /**
21
27
  * Validate that the download URL's hostname is a GitHub domain.
22
28
  * Prevents SSRF: caller-supplied URLs (e.g. tarballUrl from GitHub API JSON) could be
23
29
  * redirected to an attacker-controlled host, exfiltrating the Bearer token.
24
30
  * Allowed: api.github.com, *.github.com (e.g. codeload.github.com)
31
+ * Exported so that releases.js can validate tarball_url at the source.
25
32
  * @param {string} url
26
33
  * @throws {Error} if hostname is not a GitHub domain
27
34
  */
28
- function assertGitHubHostname(url) {
35
+ export function assertGitHubHostname(url) {
29
36
  let parsed;
30
37
  try {
31
38
  parsed = new URL(url);
@@ -33,7 +40,6 @@ function assertGitHubHostname(url) {
33
40
  throw new Error(`SSRF guard: invalid URL "${url}"`);
34
41
  }
35
42
  const { hostname } = parsed;
36
- const ALLOWED_HOSTS = new Set(['github.com', 'api.github.com', 'codeload.github.com']);
37
43
  if (!ALLOWED_HOSTS.has(hostname)) {
38
44
  throw new Error(
39
45
  `SSRF guard: URL hostname "${hostname}" is not allowed. ` +
@@ -289,12 +295,35 @@ export async function downloadAndExtractKit(token, opts = {}) {
289
295
  Authorization: `Bearer ${token}`,
290
296
  Accept: 'application/vnd.github.v3+json'
291
297
  },
292
- redirect: 'follow'
298
+ // S1: Use redirect: 'manual' to prevent the Authorization header from being forwarded
299
+ // to the redirect target (e.g. codeload.github.com). We handle the redirect manually:
300
+ // validate the Location hostname, then re-fetch without the auth header.
301
+ redirect: 'manual'
293
302
  });
294
303
  } catch (err) {
295
304
  throw new Error(`Network connection failed: ${err.message}`);
296
305
  }
297
306
 
307
+ // Handle 3xx redirects: validate Location hostname, then re-fetch without auth header.
308
+ // This prevents the Bearer token from leaking to third-party servers via a compromised redirect.
309
+ if (REDIRECT_STATUSES.has(res.status)) {
310
+ const location = res.headers.get('location');
311
+ if (!location) {
312
+ throw new Error(`GitHub API redirect (${res.status}) had no Location header`);
313
+ }
314
+ // Validate the redirect target is a GitHub hostname before following
315
+ assertGitHubHostname(location);
316
+ try {
317
+ res = await fetch(location, {
318
+ // No Authorization header on the redirect target — the token is only for api.github.com
319
+ headers: { Accept: 'application/vnd.github.v3+json' },
320
+ redirect: 'follow'
321
+ });
322
+ } catch (err) {
323
+ throw new Error(`Network connection failed on redirect: ${err.message}`);
324
+ }
325
+ }
326
+
298
327
  if (!res.ok) {
299
328
  throw new Error(`GitHub API error: ${res.status} ${res.statusText}`);
300
329
  }
@@ -1,5 +1,6 @@
1
1
  import semver from 'semver';
2
2
  import { GITHUB_API, KIT_REPO } from './constants.js';
3
+ import { assertGitHubHostname } from './download.js';
3
4
 
4
5
  // ---------------------------------------------------------------------------
5
6
  // GitHub Releases API helpers
@@ -53,6 +54,14 @@ export async function fetchLatestRelease(token) {
53
54
 
54
55
  const { tag_name: tag, body = null, tarball_url: tarballUrl } = data;
55
56
 
57
+ // S3: Validate tarball_url hostname before returning it to callers.
58
+ // A compromised GitHub API response could supply an attacker-controlled URL for SSRF.
59
+ try {
60
+ assertGitHubHostname(tarballUrl);
61
+ } catch {
62
+ return { available: false, reason: 'Tarball URL failed hostname validation' };
63
+ }
64
+
56
65
  // Parse version: try clean() first (handles v-prefix), then coerce() as fallback.
57
66
  // coerce('release-0.2.0') => '0.2.0', coerce('totally-not-semver') => null
58
67
  const version = semver.clean(tag) ?? (semver.coerce(tag) ? semver.coerce(tag).version : null);