diff --git a/infrastructure/evault-core/src/core/http/global-rate-limiter.spec.ts b/infrastructure/evault-core/src/core/http/global-rate-limiter.spec.ts new file mode 100644 index 000000000..3ee069ba2 --- /dev/null +++ b/infrastructure/evault-core/src/core/http/global-rate-limiter.spec.ts @@ -0,0 +1,214 @@ +import { describe, expect, it } from "vitest"; +import { + type GlobalRateLimitInput, + createGlobalRateLimiter, +} from "./global-rate-limiter"; +import { isGraphQLReadOperation } from "./graphql-rate-limit-intent"; + +const authenticatedPlatform = async (token: string): Promise => + token === "valid-platform-token" ? "@vidak" : null; + +function read( + overrides: Partial = {}, +): GlobalRateLimitInput { + return { + token: "valid-platform-token", + ip: "198.51.100.10", + eName: "@viewer-a", + intent: "read", + ...overrides, + }; +} + +function write( + overrides: Partial = {}, +): GlobalRateLimitInput { + return { + token: "valid-platform-token", + ip: "198.51.100.10", + eName: "@viewer-a", + intent: "write", + ...overrides, + }; +} + +describe("global rate limiter", () => { + it("isolates authenticated read quotas by platform and valid X-ENAME while retaining an aggregate cap", async () => { + const limiter = createGlobalRateLimiter({ + authenticatePlatform: authenticatedPlatform, + readRequestsPerTenant: 2, + readRequestsPerPlatform: 4, + readRequestsPerPlatformIp: 10, + writeRequestsPerPlatform: 2, + requestsPerIp: 2, + }); + + expect((await limiter.check(read())).allowed).toBe(true); + expect( + (await limiter.check(read({ eName: " @VIEWER-A " }))).allowed, + ).toBe(true); + const exhaustedTenant = await limiter.check(read()); + expect(exhaustedTenant).toEqual({ + allowed: false, + retryAfterSeconds: 60, + }); + + expect( + (await limiter.check(read({ eName: "@viewer-b" }))).allowed, + ).toBe(true); + expect( + (await limiter.check(read({ eName: "@viewer-b" }))).allowed, + ).toBe(true); + const exhaustedPlatform = await limiter.check( + read({ eName: "@viewer-c" }), + ); + expect(exhaustedPlatform).toEqual({ + allowed: false, + retryAfterSeconds: 60, + }); + }); + + it("keeps an authenticated platform/IP cap across independent tenants", async () => { + const limiter = createGlobalRateLimiter({ + authenticatePlatform: authenticatedPlatform, + readRequestsPerTenant: 10, + readRequestsPerPlatform: 10, + readRequestsPerPlatformIp: 2, + writeRequestsPerPlatform: 10, + requestsPerIp: 10, + }); + + expect( + (await limiter.check(read({ eName: "@viewer-a" }))).allowed, + ).toBe(true); + expect( + (await limiter.check(read({ eName: "@viewer-b" }))).allowed, + ).toBe(true); + expect(await limiter.check(read({ eName: "@viewer-c" }))).toEqual({ + allowed: false, + retryAfterSeconds: 60, + }); + }); + + it("keeps writes on the original platform-wide quota regardless of X-ENAME", async () => { + const limiter = createGlobalRateLimiter({ + authenticatePlatform: authenticatedPlatform, + readRequestsPerTenant: 10, + readRequestsPerPlatform: 10, + readRequestsPerPlatformIp: 10, + writeRequestsPerPlatform: 2, + requestsPerIp: 10, + }); + + expect( + (await limiter.check(write({ eName: "@viewer-a" }))).allowed, + ).toBe(true); + expect( + (await limiter.check(write({ eName: "@viewer-b" }))).allowed, + ).toBe(true); + expect(await limiter.check(write({ eName: "@viewer-c" }))).toEqual({ + allowed: false, + retryAfterSeconds: 60, + }); + }); + + it("keeps unknown tokens and malformed X-ENAME values on strict legacy buckets", async () => { + const limiter = createGlobalRateLimiter({ + authenticatePlatform: authenticatedPlatform, + readRequestsPerTenant: 10, + readRequestsPerPlatform: 10, + readRequestsPerPlatformIp: 10, + writeRequestsPerPlatform: 2, + requestsPerIp: 2, + }); + + expect( + (await limiter.check(read({ token: "forged", eName: "@viewer-a" }))) + .allowed, + ).toBe(true); + expect( + (await limiter.check(read({ token: "forged", eName: "@viewer-b" }))) + .allowed, + ).toBe(true); + expect( + await limiter.check(read({ token: "forged", eName: "@viewer-c" })), + ).toEqual({ + allowed: false, + retryAfterSeconds: 60, + }); + + const strictTenant = createGlobalRateLimiter({ + authenticatePlatform: authenticatedPlatform, + readRequestsPerTenant: 10, + readRequestsPerPlatform: 10, + readRequestsPerPlatformIp: 10, + writeRequestsPerPlatform: 2, + requestsPerIp: 10, + }); + expect( + (await strictTenant.check(read({ eName: "not-an-ename" }))).allowed, + ).toBe(true); + expect( + (await strictTenant.check(read({ eName: "not-an-ename" }))).allowed, + ).toBe(true); + expect( + await strictTenant.check(read({ eName: "not-an-ename" })), + ).toEqual({ + allowed: false, + retryAfterSeconds: 60, + }); + }); + + it("resets a tenant bucket after its fixed window and reports Retry-After", async () => { + let now = 1_000; + const limiter = createGlobalRateLimiter({ + authenticatePlatform: authenticatedPlatform, + now: () => now, + readRequestsPerTenant: 1, + readRequestsPerPlatform: 10, + readRequestsPerPlatformIp: 10, + writeRequestsPerPlatform: 10, + requestsPerIp: 10, + }); + + expect((await limiter.check(read())).allowed).toBe(true); + expect(await limiter.check(read())).toEqual({ + allowed: false, + retryAfterSeconds: 60, + }); + now += 60_001; + expect((await limiter.check(read())).allowed).toBe(true); + }); +}); + +describe("GraphQL rate-limit intent", () => { + it("admits only an unambiguous selected query to the read path", () => { + expect( + isGraphQLReadOperation({ + query: 'query Videos { metaEnvelope(id: "x") { id } }', + }), + ).toBe(true); + expect( + isGraphQLReadOperation({ + query: "mutation Upload { createMetaEnvelope(input: {}) { errors } }", + }), + ).toBe(false); + expect( + isGraphQLReadOperation({ + query: 'query Read { metaEnvelope(id: "x") { id } } mutation Write { removeMetaEnvelope(id: "x") { errors } }', + }), + ).toBe(false); + expect( + isGraphQLReadOperation({ + operationName: "Read", + query: 'query Read { metaEnvelope(id: "x") { id } } mutation Write { removeMetaEnvelope(id: "x") { errors } }', + }), + ).toBe(true); + expect( + isGraphQLReadOperation({ + operationName: "Write", + query: 'query Read { metaEnvelope(id: "x") { id } } mutation Write { removeMetaEnvelope(id: "x") { errors } }', + }), + ).toBe(false); + }); +}); diff --git a/infrastructure/evault-core/src/core/http/global-rate-limiter.ts b/infrastructure/evault-core/src/core/http/global-rate-limiter.ts index 4d7b51f11..961c3f156 100644 --- a/infrastructure/evault-core/src/core/http/global-rate-limiter.ts +++ b/infrastructure/evault-core/src/core/http/global-rate-limiter.ts @@ -1,78 +1,373 @@ -import { decodeJwt } from "jose"; +import { createHash } from "node:crypto"; +import axios from "axios"; +import * as jose from "jose"; -const WINDOW_MS = 60_000; // 1-minute window -const MAX_REQUESTS_PER_PLATFORM = - Number(process.env.RATE_LIMIT_PER_PLATFORM) || 250; -const MAX_REQUESTS_PER_IP = Number(process.env.RATE_LIMIT_PER_IP) || 500; +const WINDOW_MS = 60_000; +const JWKS_TTL_MS = 24 * 60 * 60 * 1000; +const JWKS_FETCH_TIMEOUT_MS = 5_000; +const TOKEN_IDENTITY_CACHE_MAX_ENTRIES = 1_024; +const ENAME_PATTERN = /^@[^\s@]+$/; + +// Preserve the existing conservative defaults for writes and anonymous IPs. +const DEFAULT_WRITE_REQUESTS_PER_PLATFORM = 250; +const DEFAULT_REQUESTS_PER_IP = 500; +// Authenticated reads are isolated per eVault tenant but still bounded by +// aggregate platform and platform/IP protection. +const DEFAULT_READ_REQUESTS_PER_TENANT = 250; +const DEFAULT_READ_REQUESTS_PER_PLATFORM = 2_000; +const DEFAULT_READ_REQUESTS_PER_PLATFORM_IP = 2_000; interface RateRecord { count: number; windowStart: number; } -const platformRecords = new Map(); -const ipRecords = new Map(); +export type GlobalRateLimitIntent = "read" | "write"; + +export interface GlobalRateLimitInput { + /** Raw bearer token without the `Bearer ` prefix. */ + token: string | null; + ip: string; + /** X-ENAME; accepted only when it is a syntactically valid eName. */ + eName?: string | null; + intent: GlobalRateLimitIntent; +} + +export interface GlobalRateLimitResult { + allowed: boolean; + retryAfterSeconds: number; +} + +export type AuthenticatedPlatformResolver = ( + token: string, +) => Promise; + +export interface GlobalRateLimiterOptions { + authenticatePlatform?: AuthenticatedPlatformResolver; + now?: () => number; + writeRequestsPerPlatform?: number; + requestsPerIp?: number; + readRequestsPerTenant?: number; + readRequestsPerPlatform?: number; + readRequestsPerPlatformIp?: number; +} + +type CachedJwks = { + jwks: ReturnType; + expiresAt: number; +}; + +type CachedTokenIdentity = { + platform: string; + expiresAt: number; +}; + +const registryJwksCache = new Map(); +const pendingRegistryJwks = new Map< + string, + Promise> +>(); +// Never use raw bearer tokens as Map keys: a digest is sufficient to coalesce +// validation while keeping credentials out of process-visible data structures. +const verifiedTokenIdentities = new Map(); + +function positiveInteger(value: unknown, fallback: number): number { + const parsed = typeof value === "string" ? Number(value) : value; + return typeof parsed === "number" && + Number.isSafeInteger(parsed) && + parsed > 0 + ? parsed + : fallback; +} + +function configuredLimits() { + return { + // Existing variables retain their original write/anonymous meaning. + writeRequestsPerPlatform: positiveInteger( + process.env.RATE_LIMIT_PER_PLATFORM, + DEFAULT_WRITE_REQUESTS_PER_PLATFORM, + ), + requestsPerIp: positiveInteger( + process.env.RATE_LIMIT_PER_IP, + DEFAULT_REQUESTS_PER_IP, + ), + readRequestsPerTenant: positiveInteger( + process.env.RATE_LIMIT_READS_PER_TENANT, + DEFAULT_READ_REQUESTS_PER_TENANT, + ), + readRequestsPerPlatform: positiveInteger( + process.env.RATE_LIMIT_READS_PER_PLATFORM, + DEFAULT_READ_REQUESTS_PER_PLATFORM, + ), + readRequestsPerPlatformIp: positiveInteger( + process.env.RATE_LIMIT_READS_PER_PLATFORM_IP, + DEFAULT_READ_REQUESTS_PER_PLATFORM_IP, + ), + }; +} + +function normalizePlatform(value: unknown): string | null { + if (typeof value !== "string") return null; + const normalized = value.trim().toLowerCase(); + return normalized && + normalized.length <= 4_096 && + !normalized.includes("\u0000") + ? normalized + : null; +} + +function normalizeEName(value: unknown): string | null { + if (typeof value !== "string") return null; + const normalized = value.trim().toLowerCase(); + return normalized.length <= 4_096 && ENAME_PATTERN.test(normalized) + ? normalized + : null; +} + +function normalizeIp(value: string): string { + const normalized = value.trim().toLowerCase(); + return normalized && + normalized.length <= 4_096 && + !normalized.includes("\u0000") + ? normalized + : "unknown"; +} + +function bucketKey(...parts: string[]): string { + return parts.join("\u0000"); +} function check( - map: Map, + records: Map, key: string, limit: number, -): { allowed: boolean; retryAfterSeconds: number } { - const now = Date.now(); - let rec = map.get(key); - - if (!rec || now - rec.windowStart > WINDOW_MS) { - rec = { count: 0, windowStart: now }; - map.set(key, rec); + now: number, +): GlobalRateLimitResult { + let record = records.get(key); + if (!record || now - record.windowStart > WINDOW_MS) { + record = { count: 0, windowStart: now }; + records.set(key, record); + } + record.count += 1; + if (record.count > limit) { + return { + allowed: false, + retryAfterSeconds: Math.ceil( + (record.windowStart + WINDOW_MS - now) / 1000, + ), + }; } + return { allowed: true, retryAfterSeconds: 0 }; +} + +async function resolveRegistryJwks( + jwksUrl: string, +): Promise> { + const cached = registryJwksCache.get(jwksUrl); + if (cached && cached.expiresAt > Date.now()) return cached.jwks; + const pending = pendingRegistryJwks.get(jwksUrl); + if (pending) return pending; + const request = axios + .get(jwksUrl, { timeout: JWKS_FETCH_TIMEOUT_MS }) + .then((response) => { + const jwks = jose.createLocalJWKSet(response.data); + registryJwksCache.set(jwksUrl, { + jwks, + expiresAt: Date.now() + JWKS_TTL_MS, + }); + return jwks; + }) + .finally(() => pendingRegistryJwks.delete(jwksUrl)); + pendingRegistryJwks.set(jwksUrl, request); + return request; +} - rec.count++; +function tokenDigest(token: string): string { + return createHash("sha256").update(token).digest("base64url"); +} - if (rec.count > limit) { - const retryAfterSeconds = Math.ceil( - (rec.windowStart + WINDOW_MS - now) / 1000, - ); - return { allowed: false, retryAfterSeconds }; +function cacheVerifiedTokenIdentity( + digest: string, + platform: string, + tokenExpiresAt: number, +): void { + const now = Date.now(); + const expiresAt = Math.min(now + JWKS_TTL_MS, tokenExpiresAt); + if (expiresAt <= now) return; + for (const [key, cached] of verifiedTokenIdentities) { + if (cached.expiresAt <= now) verifiedTokenIdentities.delete(key); } - - return { allowed: true, retryAfterSeconds: 0 }; + while (verifiedTokenIdentities.size >= TOKEN_IDENTITY_CACHE_MAX_ENTRIES) { + const oldestDigest = verifiedTokenIdentities.keys().next().value; + if (!oldestDigest) break; + verifiedTokenIdentities.delete(oldestDigest); + } + verifiedTokenIdentities.set(digest, { platform, expiresAt }); } -function extractPlatform(token: string): string | null { +/** + * Uses a Registry-verified platform claim. Decoding an unverified JWT would + * let a forged `platform` claim choose an arbitrary read bucket. + */ +export async function authenticatedPlatformFromToken( + token: string, +): Promise { + const digest = tokenDigest(token); + const cached = verifiedTokenIdentities.get(digest); + if (cached && cached.expiresAt > Date.now()) return cached.platform; + if (cached) verifiedTokenIdentities.delete(digest); + + const registryUrl = + process.env.PUBLIC_REGISTRY_URL || process.env.REGISTRY_URL; + if (!registryUrl) return null; try { - const payload = decodeJwt(token); - return (payload as any).platform ?? null; + const jwks = await resolveRegistryJwks( + new URL("/.well-known/jwks.json", registryUrl).toString(), + ); + const { payload } = await jose.jwtVerify(token, jwks); + const platform = normalizePlatform(payload.platform); + // Do not cache a token without an expiry: the cache must never outlive + // the token's own authorization lifetime. + if ( + platform && + typeof payload.exp === "number" && + Number.isFinite(payload.exp) + ) { + cacheVerifiedTokenIdentity(digest, platform, payload.exp * 1_000); + } + return platform; } catch { return null; } } -export function checkGlobalRateLimit( - token: string | null, - ip: string, -): { allowed: boolean; retryAfterSeconds: number } { - if (token) { - const platform = extractPlatform(token); +/** + * Authenticated reads are bounded per `(platform, X-ENAME)` and also by + * aggregate platform and platform/IP caps. Writes deliberately keep the old + * platform + generic IP quotas and never receive an eName-derived exemption. + */ +export function createGlobalRateLimiter( + options: GlobalRateLimiterOptions = {}, +) { + const defaults = configuredLimits(); + const limits = { + writeRequestsPerPlatform: positiveInteger( + options.writeRequestsPerPlatform, + defaults.writeRequestsPerPlatform, + ), + requestsPerIp: positiveInteger( + options.requestsPerIp, + defaults.requestsPerIp, + ), + readRequestsPerTenant: positiveInteger( + options.readRequestsPerTenant, + defaults.readRequestsPerTenant, + ), + readRequestsPerPlatform: positiveInteger( + options.readRequestsPerPlatform, + defaults.readRequestsPerPlatform, + ), + readRequestsPerPlatformIp: positiveInteger( + options.readRequestsPerPlatformIp, + defaults.readRequestsPerPlatformIp, + ), + }; + const now = options.now ?? Date.now; + const authenticatePlatform = + options.authenticatePlatform ?? authenticatedPlatformFromToken; + const tenantReadRecords = new Map(); + const platformReadRecords = new Map(); + const platformIpReadRecords = new Map(); + const platformWriteRecords = new Map(); + const ipRecords = new Map(); + + const prune = (at = now()) => { + for (const records of [ + tenantReadRecords, + platformReadRecords, + platformIpReadRecords, + platformWriteRecords, + ipRecords, + ]) { + for (const [key, record] of records) { + if (at - record.windowStart > WINDOW_MS) records.delete(key); + } + } + }; + + const checkRequest = async ( + input: GlobalRateLimitInput, + ): Promise => { + const requestTime = now(); + const ip = normalizeIp(input.ip); + let platform: string | null = null; + if (input.token) { + try { + platform = await authenticatePlatform(input.token); + } catch { + platform = null; + } + } + + if (input.intent === "read" && platform) { + const tenant = normalizeEName(input.eName); + // An absent or malformed X-ENAME must remain on the strict path; + // it cannot receive the tenant-read capacity by guessing a key. + if (!tenant) { + const platformResult = check( + platformWriteRecords, + platform, + limits.writeRequestsPerPlatform, + requestTime, + ); + if (!platformResult.allowed) return platformResult; + return check(ipRecords, ip, limits.requestsPerIp, requestTime); + } + + const tenantResult = check( + tenantReadRecords, + bucketKey(platform, tenant), + limits.readRequestsPerTenant, + requestTime, + ); + if (!tenantResult.allowed) return tenantResult; + const platformResult = check( + platformReadRecords, + platform, + limits.readRequestsPerPlatform, + requestTime, + ); + if (!platformResult.allowed) return platformResult; + return check( + platformIpReadRecords, + bucketKey(platform, ip), + limits.readRequestsPerPlatformIp, + requestTime, + ); + } + if (platform) { - const result = check( - platformRecords, + const platformResult = check( + platformWriteRecords, platform, - MAX_REQUESTS_PER_PLATFORM, + limits.writeRequestsPerPlatform, + requestTime, ); - if (!result.allowed) return result; + if (!platformResult.allowed) return platformResult; } - } + return check(ipRecords, ip, limits.requestsPerIp, requestTime); + }; - return check(ipRecords, ip, MAX_REQUESTS_PER_IP); + return { check: checkRequest, prune }; } -// Periodically clean up stale entries to prevent memory growth -setInterval(() => { - const now = Date.now(); - for (const [key, rec] of platformRecords) { - if (now - rec.windowStart > WINDOW_MS) platformRecords.delete(key); - } - for (const [key, rec] of ipRecords) { - if (now - rec.windowStart > WINDOW_MS) ipRecords.delete(key); - } -}, 60_000); +const globalRateLimiter = createGlobalRateLimiter(); + +export async function checkGlobalRateLimit( + input: GlobalRateLimitInput, +): Promise { + return globalRateLimiter.check(input); +} + +const cleanupTimer = setInterval(() => globalRateLimiter.prune(), WINDOW_MS); +cleanupTimer.unref?.(); diff --git a/infrastructure/evault-core/src/core/http/graphql-rate-limit-intent.ts b/infrastructure/evault-core/src/core/http/graphql-rate-limit-intent.ts new file mode 100644 index 000000000..51d689ee3 --- /dev/null +++ b/infrastructure/evault-core/src/core/http/graphql-rate-limit-intent.ts @@ -0,0 +1,43 @@ +import { Kind, parse } from "graphql"; + +type GraphQLBody = { + query?: unknown; + operationName?: unknown; +}; + +function graphQLBody(value: unknown): GraphQLBody | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as GraphQLBody) + : null; +} + +/** + * Returns true only when the request unambiguously selects a GraphQL query. + * Mutations, subscriptions, malformed documents, and ambiguous batches must + * remain on the stricter write budget. + */ +export function isGraphQLReadOperation(body: unknown): boolean { + const input = graphQLBody(body); + if (!input || typeof input.query !== "string") return false; + try { + const document = parse(input.query, { noLocation: true }); + const operations = document.definitions.filter( + (definition) => definition.kind === Kind.OPERATION_DEFINITION, + ); + const operationName = + typeof input.operationName === "string" && + input.operationName.trim() + ? input.operationName.trim() + : undefined; + const selected = operationName + ? operations.find( + (operation) => operation.name?.value === operationName, + ) + : operations.length === 1 + ? operations[0] + : undefined; + return selected?.operation === "query"; + } catch { + return false; + } +} diff --git a/infrastructure/evault-core/src/core/http/server.files.spec.ts b/infrastructure/evault-core/src/core/http/server.files.spec.ts new file mode 100644 index 000000000..ddc9edd83 --- /dev/null +++ b/infrastructure/evault-core/src/core/http/server.files.spec.ts @@ -0,0 +1,127 @@ +import fastify, { type FastifyInstance } from "fastify"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { DbService } from "../db/db.service"; +import { FILE_SCHEMA_ID } from "../utils/w3ds-uri"; +import { registerHttpRoutes } from "./server"; + +const LEGACY_FILE_RECORD_SCHEMA_ID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; +const OWNER_ENAME = "@owner"; + +describe("GET /files/:metaEnvelopeId", () => { + let server: FastifyInstance; + let findMetaEnvelopeById: ReturnType; + + beforeEach(async () => { + findMetaEnvelopeById = vi.fn(); + server = fastify(); + await registerHttpRoutes(server, {}, undefined, { + findMetaEnvelopeById, + } as unknown as DbService); + await server.ready(); + }); + + afterEach(async () => { + await server.close(); + }); + + it("redirects a w3ds-file-v1 record to its publicUrl", async () => { + findMetaEnvelopeById.mockResolvedValue({ + ontology: FILE_SCHEMA_ID, + parsed: { publicUrl: "https://objects.example/current-video.mp4" }, + }); + + const response = await server.inject({ + method: "GET", + url: "/files/current-file", + headers: { "x-ename": OWNER_ENAME }, + }); + + expect(response.statusCode).toBe(302); + expect(response.headers.location).toBe( + "https://objects.example/current-video.mp4", + ); + }); + + it("redirects a legacy File record using its documented url and scopes the read to X-ENAME", async () => { + findMetaEnvelopeById.mockResolvedValue({ + ontology: LEGACY_FILE_RECORD_SCHEMA_ID, + parsed: { url: "https://objects.example/legacy-video.mp4" }, + }); + + const response = await server.inject({ + method: "GET", + url: "/files/legacy-file", + headers: { "x-ename": OWNER_ENAME }, + }); + + expect(response.statusCode).toBe(302); + expect(response.headers.location).toBe( + "https://objects.example/legacy-video.mp4", + ); + expect(findMetaEnvelopeById).toHaveBeenCalledWith( + "legacy-file", + OWNER_ENAME, + ); + }); + + it("prefers publicUrl when a legacy File record contains both URL fields", async () => { + findMetaEnvelopeById.mockResolvedValue({ + ontology: LEGACY_FILE_RECORD_SCHEMA_ID, + parsed: { + publicUrl: "https://objects.example/current-url.mp4", + url: "https://objects.example/legacy-url.mp4", + }, + }); + + const response = await server.inject({ + method: "GET", + url: "/files/legacy-file", + headers: { "x-ename": OWNER_ENAME }, + }); + + expect(response.statusCode).toBe(302); + expect(response.headers.location).toBe( + "https://objects.example/current-url.mp4", + ); + }); + + it("does not apply the legacy url fallback to unrelated ontologies", async () => { + findMetaEnvelopeById.mockResolvedValue({ + ontology: "some-unrelated-ontology", + parsed: { url: "https://objects.example/should-not-resolve.mp4" }, + }); + + const response = await server.inject({ + method: "GET", + url: "/files/unrelated-file", + headers: { "x-ename": OWNER_ENAME }, + }); + + expect(response.statusCode).toBe(404); + }); + + it("rejects an unsafe legacy File URL", async () => { + findMetaEnvelopeById.mockResolvedValue({ + ontology: LEGACY_FILE_RECORD_SCHEMA_ID, + parsed: { url: "javascript:alert(1)" }, + }); + + const response = await server.inject({ + method: "GET", + url: "/files/unsafe-file", + headers: { "x-ename": OWNER_ENAME }, + }); + + expect(response.statusCode).toBe(400); + }); + + it("requires X-ENAME before looking up a File record", async () => { + const response = await server.inject({ + method: "GET", + url: "/files/missing-owner", + }); + + expect(response.statusCode).toBe(400); + expect(findMetaEnvelopeById).not.toHaveBeenCalled(); + }); +}); diff --git a/infrastructure/evault-core/src/core/http/server.ts b/infrastructure/evault-core/src/core/http/server.ts index 63184faa0..0a0ca8eb8 100644 --- a/infrastructure/evault-core/src/core/http/server.ts +++ b/infrastructure/evault-core/src/core/http/server.ts @@ -13,12 +13,47 @@ import { ProtectedZoneService } from "../db/protected-zone.service"; import { connectWithRetry } from "../db/retry-neo4j"; import { validatePassphraseStrength } from "../utils/passphrase"; import { getProvisionerJwk } from "../utils/provisioner-signer"; -import { - checkRateLimit, - recordAttempt, -} from "./passphrase-rate-limiter"; -import { type TypedReply, type TypedRequest, WatcherRequest } from "./types"; import { FILE_SCHEMA_ID } from "../utils/w3ds-uri"; +import { checkRateLimit, recordAttempt } from "./passphrase-rate-limiter"; +import { type TypedReply, type TypedRequest, WatcherRequest } from "./types"; + +// Some older platform integrations recorded application-level File records +// directly and addressed them with a w3ds://file URI. Keep this compatibility +// boundary narrow: this is the only additional ontology the dereference route +// accepts, and its legacy `url` field is never used for other records. +const LEGACY_FILE_RECORD_SCHEMA_ID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + +function isDereferenceableFileOntology(ontology: unknown): boolean { + return ( + ontology === FILE_SCHEMA_ID || ontology === LEGACY_FILE_RECORD_SCHEMA_ID + ); +} + +function resolveFilePublicUrl( + ontology: unknown, + parsed: unknown, +): string | null { + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return null; + } + + const payload = parsed as Record; + if (typeof payload.publicUrl === "string") { + return payload.publicUrl; + } + + // The documented application-level File ontology predates w3ds-file-v1 + // and stores the object URL as `url`. Do not apply this fallback to any + // unrelated ontology, even if it happens to contain a `url` property. + if ( + ontology === LEGACY_FILE_RECORD_SCHEMA_ID && + typeof payload.url === "string" + ) { + return payload.url; + } + + return null; +} interface WatcherSignatureRequest { w3id: string; @@ -426,15 +461,20 @@ export async function registerHttpRoutes( eName, ); - if (!metaEnvelope || metaEnvelope.ontology !== FILE_SCHEMA_ID) { + if ( + !metaEnvelope || + !isDereferenceableFileOntology(metaEnvelope.ontology) + ) { return reply.status(404).send({ error: `No file found for w3ds://file?id=${eName}/${metaEnvelopeId}`, }); } - const publicUrl = (metaEnvelope.parsed as Record) - ?.publicUrl; - if (!publicUrl || typeof publicUrl !== "string") { + const publicUrl = resolveFilePublicUrl( + metaEnvelope.ontology, + metaEnvelope.parsed, + ); + if (!publicUrl) { return reply.status(404).send({ error: "File meta-envelope has no public URL", }); diff --git a/infrastructure/evault-core/src/index.ts b/infrastructure/evault-core/src/index.ts index 42424316c..bfef364ae 100644 --- a/infrastructure/evault-core/src/index.ts +++ b/infrastructure/evault-core/src/index.ts @@ -15,6 +15,7 @@ import { VerificationService } from "./services/VerificationService"; import { createHmacSignature } from "./utils/hmac"; import { checkGlobalRateLimit } from "./core/http/global-rate-limiter"; +import { isGraphQLReadOperation } from "./core/http/graphql-rate-limit-intent"; import fastifyCors from "@fastify/cors"; import fastify, { type FastifyInstance, @@ -85,6 +86,43 @@ let provisioningService: ProvisioningService | undefined; let awarenessOutboxDispatcher: AwarenessOutboxDispatcher | undefined; let expressServer: HttpServer | undefined; +function rawBearerToken(request: FastifyRequest): string | null { + const authHeader = request.headers.authorization; + return typeof authHeader === "string" && authHeader.startsWith("Bearer ") + ? authHeader.substring(7) + : null; +} + +function requestEName(request: FastifyRequest): string | null { + const value = request.headers["x-ename"]; + return typeof value === "string" ? value : null; +} + +function requestPath(request: FastifyRequest): string { + return (request.raw.url || request.url).split("?", 1)[0] || "/"; +} + +/** + * Only operations whose read-only nature is known after Fastify has parsed + * the body receive tenant-isolated capacity. Unknown and broad endpoints stay + * on the strict legacy budget; this prevents a caller from claiming a read + * path for a mutation or cross-tenant enumeration. + */ +function isTenantScopedReadRequest(request: FastifyRequest): boolean { + const path = requestPath(request); + if ( + (request.method === "GET" || request.method === "HEAD") && + path.startsWith("/files/") + ) { + return true; + } + return ( + request.method === "POST" && + path === "/graphql" && + isGraphQLReadOperation(request.body) + ); +} + // Initialize eVault Core const initializeEVault = async ( provisioningServiceInstance?: ProvisioningService, @@ -219,16 +257,18 @@ const initializeEVault = async ( credentials: true, }); - // Global rate limiting by platform token identity (IP fallback) - fastifyServer.addHook("onRequest", async (request, reply) => { - const authHeader = request.headers.authorization; - const token = authHeader?.startsWith("Bearer ") - ? authHeader.substring(7) - : null; - const ip = request.ip; - const { allowed, retryAfterSeconds } = checkGlobalRateLimit(token, ip); + // Rate-limit after request parsing so a GraphQL operation can be proven to + // be a query before it receives tenant-isolated read capacity. Mutations, + // unknown documents, and broad HTTP endpoints retain the old strict quota. + fastifyServer.addHook("preValidation", async (request, reply) => { + const { allowed, retryAfterSeconds } = await checkGlobalRateLimit({ + token: rawBearerToken(request), + ip: request.ip, + eName: requestEName(request), + intent: isTenantScopedReadRequest(request) ? "read" : "write", + }); if (!allowed) { - // In an async onRequest hook, Fastify only short-circuits the + // In an async Fastify hook, only returning the reply short-circuits // handler chain if you return the reply object. Without the // return, the 429 is queued but the downstream handler (GraphQL) // still runs — turning the rate limiter into a silent counter.