Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
lint:
name: Lint & Format
runs-on: ubuntu-latest
timeout-minutes: 10
timeout-minutes: 15
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
Expand All @@ -46,6 +46,12 @@ jobs:
- name: Build workspace packages
run: pnpm --filter @buildinternet/uploads run build

# tsc --noEmit across every workspace. Type-aware oxlint below is not a
# substitute — it doesn't run the compiler. CONTRIBUTING.md documents
# this as part of the CI gate.
- name: Typecheck
run: pnpm typecheck

- name: Oxlint
run: pnpm lint

Expand Down Expand Up @@ -78,7 +84,7 @@ jobs:
- name: Generate Wrangler types
run: pnpm types

# Single-process Vitest across every workspace project (see vitest.config.ts).
# Single-process Vitest across every workspace project (see vitest.projects.ts).
# The root `pretest` builds @buildinternet/uploads first — apps/mcp imports
# it from dist. Replaces the former serial `pnpm --filter … test` chain and
# adds apps/web, packages/email, and packages/errors to CI coverage.
Expand Down
53 changes: 52 additions & 1 deletion apps/api/src/budget.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { describe, expect, it } from "vitest";
import { checkPutBudget, resolveBudgetLimits, storageBudgetApplies } from "./budget";
import {
checkPutBudget,
enforcedMaxStorageBytes,
resolveBudgetLimits,
storageBudgetApplies,
usageWithLimits,
} from "./budget";
import type { WorkspaceUsage } from "./usage";

describe("resolveBudgetLimits — plan-aware resolution", () => {
Expand Down Expand Up @@ -124,3 +130,48 @@ describe("checkPutBudget — storage cap skipped for BYO, upload cap unaffected"
expect(denial?.code).toBe("upload_budget_exceeded");
});
});

describe("enforcedMaxStorageBytes (#365 BYO storage-budget fix)", () => {
it("returns undefined for a BYO record (HTTP credentials, no binding)", () => {
expect(
enforcedMaxStorageBytes({
maxStorageBytes: 1_000,
accountId: "a".repeat(32),
accessKeyId: "key",
secretAccessKey: "secret",
}),
).toBeUndefined();
});

it("returns the plan cap for a plain shared-bucket record", () => {
expect(enforcedMaxStorageBytes({ plan: "free" })).toBe(250_000_000);
});
});

describe("usageWithLimits — storage fields gated by storage ownership", () => {
const usage: WorkspaceUsage = {
workspace: "acme",
bytes: 900,
objects: 1,
uploadsInPeriod: 2,
periodStart: "2026-07",
updatedAt: "2026-07-31T00:00:00.000Z",
};

it("omits maxStorageBytes/storageRemainingBytes for a BYO record", () => {
const out = usageWithLimits(usage, {
maxStorageBytes: 1_000,
accountId: "a".repeat(32),
accessKeyId: "key",
secretAccessKey: "secret",
});
expect(out.maxStorageBytes).toBeUndefined();
expect(out.storageRemainingBytes).toBeUndefined();
});

it("includes maxStorageBytes/storageRemainingBytes for a shared-bucket record", () => {
const out = usageWithLimits(usage, { maxStorageBytes: 1_000 });
expect(out.maxStorageBytes).toBe(1_000);
expect(out.storageRemainingBytes).toBe(100);
});
});
17 changes: 12 additions & 5 deletions apps/api/src/budget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ export function storageBudgetApplies(record: WorkspaceBudgetLimits): boolean {
return !isCustomerCredentialStorage;
}

/** Storage cap to *enforce*: undefined when the workspace owns its storage. */
export function enforcedMaxStorageBytes(record: WorkspaceBudgetLimits): number | undefined {
if (!storageBudgetApplies(record)) return undefined;
return resolveBudgetLimits(record).maxStorageBytes;
}

export type BudgetDenialCode = "storage_quota_exceeded" | "upload_budget_exceeded";

export interface BudgetDenial {
Expand Down Expand Up @@ -174,7 +180,8 @@ export function checkPutBudget(
limits: WorkspaceBudgetLimits,
delta: { bytes: number; uploads: number },
): BudgetDenial | null {
const { maxStorageBytes, maxUploadsPerPeriod } = resolveBudgetLimits(limits);
const { maxUploadsPerPeriod } = resolveBudgetLimits(limits);
const maxStorageBytes = enforcedMaxStorageBytes(limits);

if (maxUploadsPerPeriod !== undefined && delta.uploads > 0) {
if (usage.uploadsInPeriod + delta.uploads > maxUploadsPerPeriod) {
Expand All @@ -185,7 +192,6 @@ export function checkPutBudget(
if (
maxStorageBytes !== undefined &&
delta.bytes > 0 &&
storageBudgetApplies(limits) &&
usage.bytes + delta.bytes > maxStorageBytes
) {
return storageBudgetDenial(usage, maxStorageBytes, delta.bytes);
Expand All @@ -197,6 +203,7 @@ export function checkPutBudget(
/** Fields for GET /usage — limits + remaining when capped. */
export function usageWithLimits(usage: WorkspaceUsage, limits: WorkspaceBudgetLimits) {
const resolved = resolveBudgetLimits(limits);
const maxStorageBytes = enforcedMaxStorageBytes(limits);
const out: Record<string, unknown> = {
workspace: usage.workspace,
bytes: usage.bytes,
Expand All @@ -206,9 +213,9 @@ export function usageWithLimits(usage: WorkspaceUsage, limits: WorkspaceBudgetLi
updatedAt: usage.updatedAt,
};

if (resolved.maxStorageBytes !== undefined) {
out.maxStorageBytes = resolved.maxStorageBytes;
out.storageRemainingBytes = Math.max(0, resolved.maxStorageBytes - usage.bytes);
if (maxStorageBytes !== undefined) {
out.maxStorageBytes = maxStorageBytes;
out.storageRemainingBytes = Math.max(0, maxStorageBytes - usage.bytes);
}
if (resolved.maxUploadsPerPeriod !== undefined) {
out.maxUploadsPerPeriod = resolved.maxUploadsPerPeriod;
Expand Down
76 changes: 76 additions & 0 deletions apps/api/src/cors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,79 @@ describe("CORS preflights from the web origin", () => {
expect(res.headers.get("Access-Control-Allow-Methods")).toContain("PATCH");
});
});

// Loopback origins (http://localhost[:port], http://127.0.0.1[:port]) are
// reflected on credentialed CORS for local dev convenience, but that
// reflection must not survive in production. A local page
// loading a victim's uploads.sh session cookie could otherwise make
// credentialed cross-origin reads against /admin-ui, /me, and the
// session-cookie-authenticated /v1/workspaces surface.
describe("loopback origin reflection is gated by ENVIRONMENT", () => {
function loopbackPreflight(path: string, env: Record<string, unknown>) {
return app.request(
`https://api.uploads.sh${path}`,
{
method: "OPTIONS",
headers: {
Origin: "http://localhost:5173",
"Access-Control-Request-Method": "GET",
},
},
env as unknown as Env,
);
}

it("does not reflect localhost on /me in production", async () => {
const res = await loopbackPreflight("/me/workspaces", { ENVIRONMENT: "production" });
expect(res.headers.get("Access-Control-Allow-Origin")).toBeNull();
});

it("still allows the configured WEB_ORIGIN on /me in production", async () => {
const res = await app.request(
"https://api.uploads.sh/me/workspaces",
{
method: "OPTIONS",
headers: {
Origin: "https://uploads.sh",
"Access-Control-Request-Method": "GET",
},
},
{ ENVIRONMENT: "production" } as unknown as Env,
);
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("https://uploads.sh");
expect(res.headers.get("Access-Control-Allow-Credentials")).toBe("true");
});

it("reflects localhost on /me when ENVIRONMENT is unset (dev)", async () => {
const res = await loopbackPreflight("/me/workspaces", {});
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("http://localhost:5173");
expect(res.headers.get("Access-Control-Allow-Credentials")).toBe("true");
});

it("does not reflect localhost on /v1/workspaces in production", async () => {
const res = await loopbackPreflight("/v1/workspaces", { ENVIRONMENT: "production" });
expect(res.headers.get("Access-Control-Allow-Origin")).toBeNull();
});

it("still allows the configured WEB_ORIGIN on /v1/workspaces in production", async () => {
const res = await app.request(
"https://api.uploads.sh/v1/workspaces",
{
method: "OPTIONS",
headers: {
Origin: "https://uploads.sh",
"Access-Control-Request-Method": "POST",
},
},
{ ENVIRONMENT: "production" } as unknown as Env,
);
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("https://uploads.sh");
expect(res.headers.get("Access-Control-Allow-Credentials")).toBe("true");
});

it("reflects localhost on /v1/workspaces when ENVIRONMENT is unset (dev)", async () => {
const res = await loopbackPreflight("/v1/workspaces", {});
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("http://localhost:5173");
expect(res.headers.get("Access-Control-Allow-Credentials")).toBe("true");
});
});
4 changes: 3 additions & 1 deletion apps/api/src/files-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import {
budgetDenialError,
checkPutBudget,
enforcedMaxStorageBytes,
resolveBudgetLimits,
storageBudgetDenial,
uploadBudgetDenial,
Expand Down Expand Up @@ -440,7 +441,8 @@
// bytes before the R2 write. Reservations ARE the ledger increments for
// those fields, so post-put recordUsageSafe must not count them again; a
// failed write releases both.
const { maxUploadsPerPeriod, maxStorageBytes } = resolveBudgetLimits(ws);
const { maxUploadsPerPeriod } = resolveBudgetLimits(ws);
const maxStorageBytes = enforcedMaxStorageBytes(ws);
const uploadReservation = await reserveUploads(env.DB, workspaceName, 1, maxUploadsPerPeriod);
if (!uploadReservation.ok) {
throw budgetDenialError(
Expand Down Expand Up @@ -825,7 +827,7 @@
// that paginate must follow the cursor, not stop on a short page.
items: result.items
.filter((item) => !item.key.startsWith(INTERNAL_KEY_PREFIX))
.map((item) => {

Check warning on line 830 in apps/api/src/files-core.ts

View workflow job for this annotation

GitHub Actions / Lint & Format

oxc(no-map-spread)

Spreading to modify object properties in `map` calls is inefficient
const visibility = objectVisibility(item.metadata ?? undefined);
const urls = objectPublicUrls(env, cfg, item.key);
return {
Expand Down
37 changes: 22 additions & 15 deletions apps/api/src/gallery-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
} from "./galleries";
import { resolveTitles, withPublicTitleBudget, type TitleInfo } from "./github-titles";
import { objectPublicUrls, storage, storageConfig } from "./storage";
import { objectVisibility } from "./visibility";
import { webOrigin } from "./web-url";
import type { WorkspaceRecord } from "./workspace";

Expand All @@ -39,7 +40,7 @@ export interface GalleryItemDto {
caption: string | null;
altText: string | null;
createdAt: string;
status: "available" | "missing";
status: "available" | "missing" | "withheld";
url: string | null;
/** Same object on the embed host when dual-host policy applies; for GitHub markdown. */
embedUrl: string | null;
Expand All @@ -61,7 +62,7 @@ export interface PublicGalleryItemDto {
position: number;
caption: string | null;
altText: string | null;
status: "available" | "missing";
status: "available" | "missing" | "withheld";
url: string | null;
embedUrl: string | null;
contentType: string | null;
Expand Down Expand Up @@ -267,6 +268,7 @@ export async function hydrateGalleryItems(
env: Env,
workspace: WorkspaceRecord,
items: GalleryItemRecord[],
opts: { audience: "owner" | "public" } = { audience: "owner" },
): Promise<Omit<GalleryItemDto, "pageUrl">[]> {
let store: Awaited<ReturnType<typeof storage>>;
let config: Awaited<ReturnType<typeof storageConfig>>;
Expand All @@ -293,26 +295,29 @@ export async function hydrateGalleryItems(
cause,
});
}
const urls = meta
? objectPublicUrls(env, config, item.object_key)
: { url: null, embedUrl: null };
if (meta && urls.url === null)
const isPrivate = meta ? objectVisibility(meta.metadata) === "private" : false;
const withheld = opts.audience === "public" && isPrivate;
const urls =
meta && !withheld
? objectPublicUrls(env, config, item.object_key)
: { url: null, embedUrl: null };
if (meta && !withheld && urls.url === null)
throw new ServiceUnavailableError("Gallery object is not publicly served.", {
code: "gallery_object_not_public",
});
const dates = meta ? publicObjectDateFields(meta) : {};
const dates = meta && !withheld ? publicObjectDateFields(meta) : {};
return {
id: item.id,
objectKey: item.object_key,
position: item.position,
caption: item.caption,
altText: item.alt_text,
createdAt: item.created_at,
status: meta ? "available" : "missing",
status: withheld ? "withheld" : meta ? "available" : "missing",
url: urls.url,
embedUrl: urls.embedUrl,
contentType: meta?.type ?? null,
size: meta?.size ?? null,
contentType: withheld ? null : (meta?.type ?? null),
size: withheld ? null : (meta?.size ?? null),
uploaded: dates.uploaded ?? null,
modified: dates.modified ?? null,
};
Expand Down Expand Up @@ -409,10 +414,12 @@ export async function hydrateOwnerGallery(
version: record.version,
createdAt: record.created_at,
updatedAt: record.updated_at,
items: (await hydrateGalleryItems(env, workspace, items)).map((item) => ({
...item,
pageUrl: galleryItemUrl(env, record.id, item.id),
})),
items: (await hydrateGalleryItems(env, workspace, items, { audience: "owner" })).map(
(item) => ({
...item,
pageUrl: galleryItemUrl(env, record.id, item.id),
}),
),
};
}

Expand Down Expand Up @@ -469,7 +476,7 @@ export async function hydratePublicGallery(
references: GalleryExternalReferenceRecord[] = [],
): Promise<PublicGalleryDto> {
const [hydrated, publicReferences] = await Promise.all([
hydrateGalleryItems(env, workspace, items),
hydrateGalleryItems(env, workspace, items, { audience: "public" }),
enrichPublicReferences(env, references),
]);
return {
Expand Down
11 changes: 9 additions & 2 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,20 @@ import { internalBilling } from "./routes/internal-billing";
import { protectedResourceMetadata, requestOrigin } from "./well-known";
import { ROBOTS_TXT } from "./robots";

/** Loopback origins are trusted only outside production — mirrors
* apps/auth/src/trusted-origins.ts. */
function devOriginAllowed(origin: string, env: { ENVIRONMENT?: string }): boolean {
if (env.ENVIRONMENT === "production") return false;
return /^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin);
}

// Lets the browser console on the web origin (and local dev) call the token-
// authenticated endpoints. CORS is not the security boundary — bearer tokens
// are — but without these headers the preflight for Authorization fails.
const consoleCors = cors({
origin: (origin, c) => {
if (origin === (c.env.WEB_ORIGIN || "https://uploads.sh")) return origin;
if (/^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) return origin;
if (devOriginAllowed(origin, c.env)) return origin;
return null;
},
// PATCH is used by browser console clients for file metadata + galleries.
Expand All @@ -58,7 +65,7 @@ const consoleCors = cors({
const adminUiCors = cors({
origin: (origin, c) => {
if (origin === (c.env.WEB_ORIGIN || "https://uploads.sh")) return origin;
if (/^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) return origin;
if (devOriginAllowed(origin, c.env)) return origin;
return null;
},
credentials: true,
Expand Down
13 changes: 10 additions & 3 deletions apps/api/src/routes/public-galleries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { downloadResponse } from "../files-core";
import { listExternalReferences, listGalleryItems, resolvePublicGallery } from "../galleries";
import { galleryItemFilename, hydratePublicGallery } from "../gallery-service";
import { objectPublicUrls, storage, storageConfig } from "../storage";
import { objectVisibility } from "../visibility";
import { loadWorkspaceRecord, type WorkspaceVars } from "../workspace";

/** Runs `action`, mapping any thrown error to the 503 the gallery storage routes commit to. */
Expand Down Expand Up @@ -46,9 +47,15 @@ export const publicGalleries = new Hono<WorkspaceVars>()

// Mirrors hydrateGalleryItems (gallery-service.ts): the object may have
// been public at item-add time but the workspace's publicBaseUrl is
// mutable afterward. Withhold the bytes here exactly when the gallery's
// own read path would withhold the URL, so this route can't be used to
// bypass that gate.
// mutable afterward, or it may since have been marked private. Withhold
// the bytes here exactly when the gallery's own read path would withhold
// the item (uniform 404, never a distinct code), so this route can't be
// used to bypass that gate.
const head = await withGalleryStorageErrors(() => store.head(item.object_key));
if (objectVisibility((head as { metadata?: Record<string, string> } | null)?.metadata)) {
throw new NotFoundError("Gallery item not found.", { code: "gallery_item_not_found" });
}

const config = await withGalleryStorageErrors(() => storageConfig(c.env, workspace));
const urls = objectPublicUrls(c.env, config, item.object_key);
if (!urls.url) {
Expand Down
Loading
Loading