All files / server/services/examples http-provider.ts

79.43% Statements 85/107
54.94% Branches 50/91
93.33% Functions 14/15
88.5% Lines 77/87

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150                              7x 7x     4x   3x 1x     2x 2x     2x 2x 1x 1x   1x       1x         2x 2x 2x 2x 2x 1x 1x 1x 1x 1x 1x 1x   1x 1x 2x 2x 2x 2x   1x     1x 2x     1x 1x         2x 2x 2x     2x     2x 2x       2x 2x 2x       4x 4x 4x 4x 4x 4x 3x 3x 3x 3x   3x 3x 3x 3x 6x 6x 3x 3x       3x     3x 3x 3x 3x 3x   3x   4x         4x 4x           4x 4x 4x 4x            
import dns from "node:dns/promises";
import { isIP } from "node:net";
import { config } from "../../config";
import {
  examplesManifestSchema,
  type ExampleRecord,
  validateManifestReferences,
} from "./examples-schema";
 
type CachedRemoteSnapshot = {
  expiresAt: number;
  examples: ExampleRecord[];
};
 
export class HttpProvider {
  private cached: CachedRemoteSnapshot | null = null;
  private loading: Promise<ExampleRecord[]> | null = null;
 
  async getExamples(): Promise<{ examples: ExampleRecord[]; status: "remote" | "cache"; stale: boolean } | null> {
    if (!config.examples.source || !config.examples.ref) return null;
 
    if (this.cached && this.cached.expiresAt > Date.now()) {
      return { examples: this.cached.examples, status: "cache", stale: false };
    }
 
    this.loading ??= this.loadRemote().finally(() => {
      this.loading = null;
    });
 
    try {
      const examples = await this.loading;
      this.cached = { examples, expiresAt: Date.now() + config.examples.refreshMs };
      return { examples, status: "remote", stale: false };
    } catch {
      Iif (this.cached) {
        this.cached.expiresAt = Date.now() + config.examples.refreshMs;
        return { examples: this.cached.examples, status: "cache", stale: true };
      }
      return null;
    }
  }
 
  private async loadRemote(): Promise<ExampleRecord[]> {
    const source = validateSourceUrl(config.examples.source);
    const ref = validateRef(config.examples.ref);
    const base = new URL(`${encodeURIComponent(ref)}/`, source);
    const manifestUrl = new URL("manifest.json", base);
    const manifestText = await fetchText(manifestUrl, config.examples.maxManifestBytes);
    const parsed = examplesManifestSchema.safeParse(JSON.parse(manifestText) as unknown);
    Iif (!parsed.success) throw new Error("Invalid external examples manifest");
    const manifest = parsed.data;
    validateManifestReferences(manifest);
    Iif (manifest.ref && manifest.ref !== ref) throw new Error("External manifest ref does not match configured ref");
    const fileCount = manifest.examples.reduce((total, example) => total + example.files.length, 0);
    Iif (fileCount > config.examples.maxFiles) throw new Error("External examples exceed file count limit");
 
    const examples = await Promise.all(manifest.examples.map(async (example) => {
      const files = await Promise.all(example.files.map(async (file) => {
        const fileUrl = new URL(file.path.split("/").map(encodeURIComponent).join("/"), base);
        Iif (fileUrl.origin !== base.origin) throw new Error("Example path changed origin");
        const content = await fetchText(fileUrl, config.examples.maxFileBytes);
        return { ...file, content };
      }));
      return { ...example, files, source: "external" as const };
    }));
 
    const totalBytes = examples.reduce(
      (total, example) => total + example.files.reduce((sum, file) => sum + Buffer.byteLength(file.content, "utf8"), 0),
      0,
    );
    Iif (totalBytes > config.examples.maxTotalBytes) throw new Error("External examples exceed total size limit");
    return examples;
  }
}
 
function validateSourceUrl(value: string): URL {
  const url = new URL(value.endsWith("/") ? value : `${value}/`);
  Iif (url.username || url.password || url.search || url.hash) throw new Error("Invalid examples source URL");
  Iif (url.protocol !== "https:" && !(config.examples.allowHttp && config.nodeEnv !== "production")) {
    throw new Error("External examples source must use HTTPS");
  }
  Iif (!config.examples.allowedHosts.includes(url.hostname.toLowerCase())) {
    throw new Error("External examples source host is not allowlisted");
  }
  Iif (isIP(url.hostname) !== 0) throw new Error("IP literal examples sources are not allowed");
  return url;
}
 
function validateRef(value: string): string {
  Iif (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value)) throw new Error("Invalid external examples ref");
  Iif (config.nodeEnv === "production" && value === "main") throw new Error("Floating refs are not allowed in production");
  return value;
}
 
async function fetchText(url: URL, maxBytes: number): Promise<string> {
  await assertPublicHost(url.hostname);
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), config.examples.timeoutMs);
  try {
    const response = await fetch(url, { signal: controller.signal, redirect: "manual" });
    if (response.status >= 300 && response.status < 400) throw new Error("Redirects are not allowed for examples");
    Iif (!response.ok) throw new Error(`Examples source returned ${response.status}`);
    const contentLength = response.headers.get("content-length");
    Iif (contentLength && Number(contentLength) > maxBytes) throw new Error("Examples response exceeds size limit");
    Iif (!response.body) throw new Error("Examples response has no body");
 
    const reader = response.body.getReader();
    const chunks: Uint8Array[] = [];
    let total = 0;
    while (true) {
      const next = await reader.read();
      if (next.done) break;
      total += next.value.byteLength;
      Iif (total > maxBytes) {
        await reader.cancel();
        throw new Error("Examples response exceeds size limit");
      }
      chunks.push(next.value);
    }
 
    const bytes = new Uint8Array(total);
    let offset = 0;
    for (const chunk of chunks) {
      bytes.set(chunk, offset);
      offset += chunk.byteLength;
    }
    return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
  } finally {
    clearTimeout(timeout);
  }
}
 
async function assertPublicHost(hostname: string): Promise<void> {
  const addresses = await dns.lookup(hostname, { all: true });
  Iif (addresses.length === 0 || addresses.some(({ address }) => isPrivateAddress(address))) {
    throw new Error("Examples source resolves to a private or reserved address");
  }
}
 
function isPrivateAddress(address: string): boolean {
  Eif (isIP(address) === 4) {
    const octets = address.split(".").map(Number);
    const [a, b] = octets;
    return a === 10 || a === 127 || a === 0 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 100 && b >= 64 && b <= 127);
  }
  const normalized = address.toLowerCase();
  if (normalized.startsWith("::ffff:")) return isPrivateAddress(normalized.slice("::ffff:".length));
  return normalized === "::" || normalized === "::1" || normalized.startsWith("fc") || normalized.startsWith("fd") || normalized.startsWith("fe8") || normalized.startsWith("fe9") || normalized.startsWith("fea") || normalized.startsWith("feb");
}