akanjs 3.0.0-alpha.72 → 3.0.0-alpha.73

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.0.0-alpha.72",
3
+ "version": "3.0.0-alpha.73",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
package/server/akanApp.ts CHANGED
@@ -18,6 +18,7 @@ import { LogHub } from "./logging/logHub";
18
18
  import { LogStreamRoute } from "./logging/logStreamRoute";
19
19
  import { RotatingLogWriter } from "./logging/rotatingLogWriter";
20
20
  import { ProcessMetricsCollector } from "./processMetricsCollector";
21
+ import { resolveStaticPath } from "./staticPath";
21
22
  import { getWebConfigFromEnv } from "./types";
22
23
 
23
24
  interface ChildState {
@@ -669,7 +670,7 @@ export class AkanApp {
669
670
  if (!this.#web.ssr) return null;
670
671
  const clientPrefix = "/_akan/client/";
671
672
  if (url.pathname.startsWith(clientPrefix)) {
672
- const filePath = this.#safeResolve(
673
+ const filePath = resolveStaticPath(
673
674
  path.join(this.#artifactDir, "client"),
674
675
  url.pathname.slice(clientPrefix.length),
675
676
  );
@@ -684,7 +685,7 @@ export class AkanApp {
684
685
 
685
686
  for (const prefix of ["/_akan/styles/", "/_akan/fonts/"]) {
686
687
  if (!url.pathname.startsWith(prefix)) continue;
687
- const filePath = this.#safeResolve(this.#artifactDir, url.pathname.slice("/_akan/".length));
688
+ const filePath = resolveStaticPath(this.#artifactDir, url.pathname.slice("/_akan/".length));
688
689
  if (!filePath) return new Response("Not Found", { status: 404 });
689
690
  const file = Bun.file(filePath);
690
691
  if (!(await file.exists())) return new Response("Not Found", { status: 404 });
@@ -1018,23 +1019,6 @@ export class AkanApp {
1018
1019
  return new Response(Bun.file(filePath).stream(), { headers });
1019
1020
  }
1020
1021
 
1021
- #safeResolve(baseDir: string, urlPath: string): string | null {
1022
- let decoded: string;
1023
- try {
1024
- decoded = decodeURIComponent(urlPath);
1025
- } catch {
1026
- return null;
1027
- }
1028
- if (decoded.includes("\0")) return null;
1029
- const normalizedBase = path.resolve(baseDir);
1030
- const rel = decoded.replace(/^[/\\]+/, "");
1031
- const resolved = path.resolve(normalizedBase, rel);
1032
- if (resolved === normalizedBase) return resolved;
1033
- const baseWithSep = normalizedBase.endsWith(path.sep) ? normalizedBase : `${normalizedBase}${path.sep}`;
1034
- if (!resolved.startsWith(baseWithSep)) return null;
1035
- return resolved;
1036
- }
1037
-
1038
1022
  #makeProxyHeaders(req: Request, childIdx: number, server?: Bun.Server<GatewayWsData>) {
1039
1023
  return makeAkanChildProxyHeaders(req, childIdx, server?.requestIP(req));
1040
1024
  }
@@ -5,6 +5,7 @@ import { Logger } from "akanjs/common";
5
5
  import { isAnimatedImage } from "./animatedImage";
6
6
  import { ImageOptimizerError } from "./imageOptimizerError";
7
7
  import { Semaphore } from "./semaphore";
8
+ import { resolveStaticPath } from "./staticPath";
8
9
  import {
9
10
  type AkanImageConfig,
10
11
  type AkanImageFormat,
@@ -260,7 +261,7 @@ export class ImageOptimizer {
260
261
 
261
262
  async #readLocalImage(href: string): Promise<ImageSource> {
262
263
  const url = new URL(href, "http://local.akan");
263
- const filePath = ImageOptimizer.#safeResolve(this.#publicDir, url.pathname);
264
+ const filePath = resolveStaticPath(this.#publicDir, url.pathname);
264
265
  if (!filePath) throw new ImageOptimizerError(400, '"url" parameter is not allowed');
265
266
  const file = Bun.file(filePath);
266
267
  if (!(await file.exists())) throw new ImageOptimizerError(404, "Image not found");
@@ -455,22 +456,6 @@ export class ImageOptimizer {
455
456
  }
456
457
  }
457
458
 
458
- static #safeResolve(baseDir: string, urlPath: string): string | null {
459
- let decoded: string;
460
- try {
461
- decoded = decodeURIComponent(urlPath);
462
- } catch {
463
- return null;
464
- }
465
- if (decoded.includes("\0")) return null;
466
- const normalizedBase = path.resolve(baseDir);
467
- const rel = decoded.replace(/^[/\\]+/, "");
468
- const resolved = path.resolve(normalizedBase, rel);
469
- if (resolved === normalizedBase) return resolved;
470
- const baseWithSep = normalizedBase.endsWith(path.sep) ? normalizedBase : normalizedBase + path.sep;
471
- return resolved.startsWith(baseWithSep) ? resolved : null;
472
- }
473
-
474
459
  static async #readResponseBuffer(res: Response, maxBytes: number): Promise<Buffer> {
475
460
  const contentLength = Number.parseInt(res.headers.get("content-length") ?? "0", 10);
476
461
  if (contentLength > maxBytes) throw new ImageOptimizerError(413, "Remote image is too large");
@@ -1,4 +1,3 @@
1
- import fs from "node:fs";
2
1
  import path from "node:path";
3
2
 
4
3
  /**
@@ -9,9 +8,13 @@ import path from "node:path";
9
8
  * then a NUL check (a truncating byte in a path), then `path.resolve` to collapse the traversal, then
10
9
  * containment against the base **with a separator** — without it `/public-secrets` passes a `/public` check.
11
10
  *
12
- * `path.resolve` works on the string and does not follow links, so a symlink inside the tree can still point
13
- * out of it. Callers check existence after this, so a path that resolves to nothing is handed back and 404s
14
- * there; only a link that really leads outside is refused.
11
+ * Symlinks are followed, like nginx and `serve-static` do: `akan sync` builds `public/libs/<lib>` as a link to
12
+ * `<workspaceRoot>/libs/<lib>/public`, so a link out of the tree is how a lib ships its assets, not an attack.
13
+ * Refusing those would 404 every lib asset under `akan start` and nothing else — `akan build` copies `public/`
14
+ * with `dereference: true`, so a built app has no links left to refuse. Traversal is what the string checks
15
+ * above stop, and they run before any of this touches the filesystem.
16
+ *
17
+ * Callers check existence after this, so a path that resolves to nothing is handed back and 404s there.
15
18
  */
16
19
  export const resolveStaticPath = (baseDir: string, urlPath: string): string | null => {
17
20
  let decoded: string;
@@ -27,13 +30,5 @@ export const resolveStaticPath = (baseDir: string, urlPath: string): string | nu
27
30
  if (resolved === normalizedBase) return resolved;
28
31
  const baseWithSep = normalizedBase.endsWith(path.sep) ? normalizedBase : normalizedBase + path.sep;
29
32
  if (!resolved.startsWith(baseWithSep)) return null;
30
- try {
31
-
32
- const realBase = fs.realpathSync(normalizedBase);
33
- const realBaseWithSep = realBase.endsWith(path.sep) ? realBase : realBase + path.sep;
34
- const real = fs.realpathSync(resolved);
35
- if (real !== realBase && !real.startsWith(realBaseWithSep)) return null;
36
- } catch {
37
- }
38
33
  return resolved;
39
34
  };
@@ -6,8 +6,12 @@
6
6
  * then a NUL check (a truncating byte in a path), then `path.resolve` to collapse the traversal, then
7
7
  * containment against the base **with a separator** — without it `/public-secrets` passes a `/public` check.
8
8
  *
9
- * `path.resolve` works on the string and does not follow links, so a symlink inside the tree can still point
10
- * out of it. Callers check existence after this, so a path that resolves to nothing is handed back and 404s
11
- * there; only a link that really leads outside is refused.
9
+ * Symlinks are followed, like nginx and `serve-static` do: `akan sync` builds `public/libs/<lib>` as a link to
10
+ * `<workspaceRoot>/libs/<lib>/public`, so a link out of the tree is how a lib ships its assets, not an attack.
11
+ * Refusing those would 404 every lib asset under `akan start` and nothing else — `akan build` copies `public/`
12
+ * with `dereference: true`, so a built app has no links left to refuse. Traversal is what the string checks
13
+ * above stop, and they run before any of this touches the filesystem.
14
+ *
15
+ * Callers check existence after this, so a path that resolves to nothing is handed back and 404s there.
12
16
  */
13
17
  export declare const resolveStaticPath: (baseDir: string, urlPath: string) => string | null;