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
61 changes: 29 additions & 32 deletions src/proxy/forward.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,9 @@ export async function forwardToCouch(
): Promise<Response> {
try {
const upstream = await fetchFromCouch(c, config, options);
return toClientResponse(upstream, {
return toClientResponseFromCouch(upstream, config, {
keepEncoding: options?.keepEncoding,
stripHeaders: options?.stripResponseHeaders,
rewriteLocation: {
fromOrigin: new URL(config.couch.url).origin,
},
});
} catch (err) {
if (err instanceof BodyTooLargeError) {
Expand Down Expand Up @@ -175,41 +172,41 @@ export async function fetchFromCouch(
status: response.status,
});
}
const location = response.headers.get("location");
if (!location) return response;
// Return the raw upstream Response. Location rewriting belongs only in
// toClientResponse / toClientResponseFromCouch — wrapping here and again
// there double-attaches the body stream and can throw
// "Response body object should not be disturbed or locked".
return response;
}

// Couch commonly emits absolute redirects using its private upstream
// origin. Exposing that URL can let a client leave the ACL proxy when Couch
// is also reachable on an internal or development network. Preserve
// same-origin redirects as origin-relative client locations.
try {
const target = new URL(location, url);
if (target.origin !== couchBase.origin) return response;
const headers = new Headers(response.headers);
headers.set("Location", `${target.pathname}${target.search}${target.hash}`);
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
} catch {
return response;
}
export type ClientResponseOptions = {
keepEncoding?: boolean;
body?: ReadableStream<Uint8Array> | string | null;
stripHeaders?: string[];
rewriteLocation?: { fromOrigin: string };
};

/**
* Convert an upstream Couch Response into a client Response, rewriting
* absolute same-origin Location headers to path-only form so the private
* Couch origin is never advertised.
*/
export function toClientResponseFromCouch(
upstream: Response,
config: AppConfig,
options?: Omit<ClientResponseOptions, "rewriteLocation">,
): Response {
return toClientResponse(upstream, {
...options,
rewriteLocation: { fromOrigin: new URL(config.couch.url).origin },
});
}

/**
* Convert an upstream Response into a client Response, stripping hop-by-hop
* headers and (by default) content-encoding so Node can re-encode if needed.
*/
export function toClientResponse(
upstream: Response,
options?: {
keepEncoding?: boolean;
body?: ReadableStream<Uint8Array> | string | null;
stripHeaders?: string[];
rewriteLocation?: { fromOrigin: string };
},
): Response {
export function toClientResponse(upstream: Response, options?: ClientResponseOptions): Response {
const responseHeaders = new Headers();
const stripHeaders = new Set((options?.stripHeaders ?? []).map((header) => header.toLowerCase()));
const decoded = !options?.keepEncoding && upstream.headers.has("content-encoding");
Expand Down
67 changes: 32 additions & 35 deletions src/routes/actors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
fetchFromCouch,
forwardToCouch,
jsonResponse,
toClientResponse,
toClientResponseFromCouch,
} from "../proxy/forward.js";
import { filterBulkGet, filterRows, type RowsResponse } from "../proxy/filterRows.js";
import {
Expand Down Expand Up @@ -610,19 +610,13 @@ export const actors: Record<string, Actor> = {
newEditsFalse,
});
}
return toClientResponse(upstream, {
rewriteLocation: {
fromOrigin: new URL(config.couch.url).origin,
},
});
return toClientResponseFromCouch(upstream, config);
}
if (principal.admin && directDocumentWrite && id) {
const config = c.get("config");
const upstream = await fetchFromCouch(c, config);
if (upstream.ok) await refreshWrittenDoc(c, state, id, true);
return toClientResponse(upstream, {
rewriteLocation: { fromOrigin: new URL(config.couch.url).origin },
});
return toClientResponseFromCouch(upstream, config);
}
await next();
},
Expand Down Expand Up @@ -662,9 +656,10 @@ export const actors: Record<string, Actor> = {
if (!allowed) {
return couchError("forbidden", "ACL", 403);
}
const upstream = await fetchFromCouch(c, c.get("config"));
const config = c.get("config");
const upstream = await fetchFromCouch(c, config);
if (upstream.ok) await refreshWrittenDoc(c, state, id, true);
return toClientResponse(upstream);
return toClientResponseFromCouch(upstream, config);
},

/**
Expand Down Expand Up @@ -722,9 +717,10 @@ export const actors: Record<string, Actor> = {
user: principal.name,
flags,
});
const upstream = await fetchFromCouch(c, c.get("config"));
const config = c.get("config");
const upstream = await fetchFromCouch(c, config);
if (upstream.ok) await refreshWrittenDoc(c, state, id, true);
return toClientResponse(upstream);
return toClientResponseFromCouch(upstream, config);
},

/**
Expand Down Expand Up @@ -808,9 +804,7 @@ export const actors: Record<string, Actor> = {
const config = c.get("config");
const upstream = await fetchFromCouch(c, config);
if (upstream.ok) await refreshWrittenDoc(c, state, destId, false);
return toClientResponse(upstream, {
rewriteLocation: { fromOrigin: new URL(config.couch.url).origin },
});
return toClientResponseFromCouch(upstream, config);
},

/** Proxy then filter `_all_docs` / view rows by read ACL. */
Expand Down Expand Up @@ -921,7 +915,7 @@ export const actors: Record<string, Actor> = {
...(query != null ? { query } : {}),
...(forwardBody !== undefined ? { body: forwardBody } : {}),
});
if (!upstream.ok) return toClientResponse(upstream);
if (!upstream.ok) return toClientResponseFromCouch(upstream, config);

let body: RowsResponse;
try {
Expand Down Expand Up @@ -955,7 +949,7 @@ export const actors: Record<string, Actor> = {
filteredRows: filtered.rows.length,
preserveDenied,
});
const response = toClientResponse(upstream, {
const response = toClientResponseFromCouch(upstream, config, {
body: isHead ? null : JSON.stringify(filtered),
stripHeaders: ["etag", "last-modified"],
});
Expand Down Expand Up @@ -990,7 +984,7 @@ export const actors: Record<string, Actor> = {
const upstream = await fetchFromCouch(c, config, {
stripRequestHeaders: ["if-none-match", "if-modified-since"],
});
if (!upstream.ok || !upstream.body) return toClientResponse(upstream);
if (!upstream.ok || !upstream.body) return toClientResponseFromCouch(upstream, config);

const filtered = filterChangesStream(
upstream.body,
Expand All @@ -1001,7 +995,7 @@ export const actors: Record<string, Actor> = {
maxBufferBytes: config.server.maxBodyBytes,
},
);
const response = toClientResponse(upstream, {
const response = toClientResponseFromCouch(upstream, config, {
body: filtered,
stripHeaders: ["etag", "last-modified"],
});
Expand Down Expand Up @@ -1070,11 +1064,12 @@ export const actors: Record<string, Actor> = {
);
}

const upstream = await fetchFromCouch(c, c.get("config"), {
const config = c.get("config");
const upstream = await fetchFromCouch(c, config, {
body: JSON.stringify({ ...filtered.rest, docs: filtered.allowed }),
headers: { "Content-Type": "application/json", Accept: "application/json" },
});
if (!upstream.ok) return toClientResponse(upstream);
if (!upstream.ok) return toClientResponseFromCouch(upstream, config);
let results = (await upstream.json()) as Array<Record<string, unknown>>;
if (!Array.isArray(results)) results = [];
const newEditsFalse = String(body.new_edits) === "false";
Expand All @@ -1091,7 +1086,7 @@ export const actors: Record<string, Actor> = {
});
}
await refreshWrittenDocs(c, state, writes, newEditsFalse);
return toClientResponse(upstream, {
return toClientResponseFromCouch(upstream, config, {
body: JSON.stringify(mergeBulkResults(filtered.slots, results)),
});
},
Expand Down Expand Up @@ -1134,7 +1129,7 @@ export const actors: Record<string, Actor> = {
"Content-Type": c.req.header("content-type") || "application/json",
},
});
if (!upstream.ok) return toClientResponse(upstream);
if (!upstream.ok) return toClientResponseFromCouch(upstream, config);
let body: { results?: Array<{ id: string; docs: unknown[] }> };
try {
body = JSON.parse(
Expand All @@ -1155,7 +1150,7 @@ export const actors: Record<string, Actor> = {
requested: body.results?.length ?? 0,
results: filtered.results?.length ?? 0,
});
return toClientResponse(upstream, {
return toClientResponseFromCouch(upstream, config, {
body: JSON.stringify(filtered),
});
},
Expand Down Expand Up @@ -1194,11 +1189,12 @@ export const actors: Record<string, Actor> = {
requestedKeys: Object.keys(body).length,
allowedKeys: Object.keys(filtered).length,
});
const upstream = await fetchFromCouch(c, c.get("config"), {
const config = c.get("config");
const upstream = await fetchFromCouch(c, config, {
body: JSON.stringify(filtered),
headers: { "Content-Type": "application/json", Accept: "application/json" },
});
return toClientResponse(upstream);
return toClientResponseFromCouch(upstream, config);
},

/**
Expand All @@ -1207,20 +1203,21 @@ export const actors: Record<string, Actor> = {
*/
async dblist(c) {
const principal = c.get("principal");
if (principal.admin) return forwardToCouch(c, c.get("config"));
const config = c.get("config");
if (principal.admin) return forwardToCouch(c, config);

const isHead = c.req.method === "HEAD";
const upstream = await fetchFromCouch(c, c.get("config"), {
const upstream = await fetchFromCouch(c, config, {
...(isHead ? { method: "GET" } : {}),
stripRequestHeaders: ["if-none-match", "if-modified-since"],
headers: { Accept: "application/json" },
});
if (!upstream.ok) return toClientResponse(upstream);
if (!upstream.ok) return toClientResponseFromCouch(upstream, config);

let dbs: string[];
try {
dbs = JSON.parse(
await readResponseTextLimited(upstream, c.get("config").server.maxBodyBytes),
await readResponseTextLimited(upstream, config.server.maxBodyBytes),
) as string[];
} catch (err) {
if (err instanceof BodyTooLargeError) {
Expand All @@ -1229,7 +1226,7 @@ export const actors: Record<string, Actor> = {
throw err;
}
if (!Array.isArray(dbs)) {
return toClientResponse(upstream, {
return toClientResponseFromCouch(upstream, config, {
body: isHead ? null : JSON.stringify(dbs),
stripHeaders: ["etag", "last-modified"],
});
Expand Down Expand Up @@ -1263,7 +1260,7 @@ export const actors: Record<string, Actor> = {
visibleDbs: visible.length,
visible,
});
const response = toClientResponse(upstream, {
const response = toClientResponseFromCouch(upstream, config, {
body: isHead ? null : JSON.stringify(visible),
stripHeaders: ["etag", "last-modified"],
});
Expand Down Expand Up @@ -1315,7 +1312,7 @@ export const actors: Record<string, Actor> = {
"Content-Type": c.req.header("content-type") || "application/json",
},
});
if (!upstream.ok) return toClientResponse(upstream);
if (!upstream.ok) return toClientResponseFromCouch(upstream, config);
let body: FindResponse;
try {
body = JSON.parse(
Expand All @@ -1340,7 +1337,7 @@ export const actors: Record<string, Actor> = {
if (injectedId) {
filtered.docs = filtered.docs.map(({ _id: _injectedId, ...doc }) => doc);
}
return toClientResponse(upstream, { body: JSON.stringify(filtered) });
return toClientResponseFromCouch(upstream, config, { body: JSON.stringify(filtered) });
},

/** Mango index management — admin only. */
Expand Down
23 changes: 23 additions & 0 deletions test/integration/acl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,29 @@ describe("integration ACL", () => {
expect(body.error).toBe("not_found");
});

it("PUT Location is relative and never leaks the Couch origin", async () => {
const headers = authHeaders("basic", "alice", "alice-pass");
const put = await putDoc(`loc-probe-${suffix}`, { creator: "alice", hello: true }, headers);
expect(put.status).toBe(201);
const location = put.headers.get("location");
expect(location).toBeTruthy();
expect(location!).toMatch(new RegExp(`^/${DB}/loc-probe-${suffix}$`));
expect(location!.toLowerCase()).not.toContain("couchdb");
expect(location!).not.toMatch(/^https?:\/\//i);

// Stress the fetchFromCouch → toClientResponse path; double-wrap races are rare.
for (let i = 0; i < 20; i++) {
const id = `loc-loop-${suffix}-${i}`;
const res = await putDoc(id, { creator: "alice", n: i }, headers);
expect(res.status, await res.text()).toBe(201);
const loc = res.headers.get("location");
expect(loc).toBeTruthy();
expect(loc!).not.toMatch(/^https?:\/\//i);
expect(loc!.toLowerCase()).not.toContain("couchdb");
expect(loc!).toBe(`/${DB}/${id}`);
}
});

it("COPY requires source read and destination write", async () => {
const src = `copy-src-${suffix}`;
const dst = `copy-dst-${suffix}`;
Expand Down
Loading
Loading