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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@
"hookified": "^3.0.4",
"html-escaper": "^3.0.3",
"keyv": "^6.1.0",
"mongodb": "7.7.0",
"pino": "^10.4.0",
"pino-pretty": "^13.2.0"
},
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

57 changes: 57 additions & 0 deletions site/docs/bins.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,63 @@ curl http://localhost:3000/bins/abc123def456/requests
| DELETE | `/bins/:id/requests` | Clear all captured requests in a bin |
| DELETE | `/bins/:id` | Delete a bin |
| ANY | `/b/:id` and `/b/:id/*` | Capture requests sent to a bin |
| GET | `/healthz` | Check whether bin storage is reachable |

## Storage health

`GET /healthz` is on the Node server and the Worker. This route does not create a bin.

A memory store sends HTTP 200:

```json
{
"status": "ok",
"bins": {
"store": "memory",
"connected": true,
"latencyMs": 1,
"uriPresent": false,
"scheme": null
}
}
```

The Worker uses MongoDB when `MONGODB_URI` is set. The check opens a client, sends an admin ping, and then closes the client. The timeout is short. The check does not keep the client after a failure.

A successful ping sends HTTP 200. `store` is `mongo`. `scheme` is `mongodb` or `mongodb+srv`.

A failed ping sends HTTP 503:

```json
{
"status": "error",
"bins": {
"store": "mongo",
"connected": false,
"uriPresent": true,
"scheme": "mongodb+srv",
"error": {
"name": "MongoParseError",
"message": "Invalid connection string. Percent-encode reserved characters in the username and password (@ : / ? # [ ]).",
"code": null
}
}
}
```

The response does not contain the connection string, the username, the password, or the host. The logs do not contain them. `uriPresent` is a boolean. `scheme` is `mongodb`, `mongodb+srv`, or `null`.

`Invalid connection string` means that the URI has a second `@`. It also means that you did not encode a reserved character in the username or the password. Encode these characters:

- `@` as `%40`
- `:` as `%3A`
- `/` as `%2F`
- `?` as `%3F`
- `#` as `%23`
- `[` as `%5B`
- `]` as `%5D`

A correct `mongodb+srv://` URI does not fail at parse time. A DNS failure or a network failure has a different error name.

## End-to-End Webhook Debugging Example

Expand Down
8 changes: 7 additions & 1 deletion site/docs/how-to-deploy/cloudflare.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,13 @@ The Worker starts the same MockHTTP app as Node.js, with a few options set for t
| `logging` | `false` | Request logging is off. |
| `autoDetectPort` | `false` | The app always listens on port 3000 inside the isolate. |

The rate limiter key is the `cf-connecting-ip` header, then the first address in `x-forwarded-for`, then `unknown`. A limited request returns `429` with `retry-after: 60`. That limiter is shared across isolates. Bins are not. The default store is in memory inside the isolate that handled the request, so a bin created on one request can be missing when another isolate or location handles the next one.
The rate limiter key is the `cf-connecting-ip` header, then the first address in `x-forwarded-for`, then `unknown`. A limited request returns `429` with `retry-after: 60`. That limiter is shared across isolates.

Bins stay in memory in the isolate when `MONGODB_URI` is not set. A bin from one request can be absent when a different isolate handles the next request. When `MONGODB_URI` is set, bins are stored in MongoDB. The client connects on the first bin operation. It does not connect while the isolate starts.

Percent-encode reserved characters in the username and the password. `@` is `%40`. An unencoded `@` makes the driver throw `Invalid connection string` before it contacts Atlas.

`GET /healthz` reports the bin store. A successful admin ping returns HTTP 200 and `bins.connected: true`. A failed ping returns HTTP 503 with the driver error name, a redacted message, and a code. The body has `uriPresent` (boolean) and `scheme` (`mongodb` or `mongodb+srv`). The body does not have the URI, the username, the password, or the host. On the Node server, this route reports the memory store. Connection failures are also one JSON line in the Worker logs (`mockhttp.bin-store`). Those logs use the same redaction.

Taps are a library API (`mock.taps.inject`). They have no HTTP routes, so a deployed Worker has no way to inject one.

Expand Down
2 changes: 1 addition & 1 deletion site/docs/library.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ Register image routes (jpeg, png, svg, webp) with content negotiation support.

#### `async registerBinRoutes(fastifyInstance?)`

Register the request bin routes — management at `/bins` and capture at `/b/:id`.
Register the request bin routes. Management is at `/bins` and capture is at `/b/:id`. `GET /healthz` reports whether bin storage is reachable.

## Bins (Request Capture)

Expand Down
281 changes: 281 additions & 0 deletions src/bin-store-diagnostics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,281 @@
/**
* Diagnostics for the bin store.
* Logs and HTTP bodies can show the error name and a safe message.
* They can show if the URI is present.
* They cannot show the URI, the username, the password, or the host.
*/

export type BinStoreScheme = "mongodb" | "mongodb+srv";

export type BinStoreErrorInfo = {
name: string;
message: string;
code: string | number | null;
};

export type BinHealth =
| {
status: "ok";
bins: {
store: "mongo" | "memory";
connected: true;
latencyMs: number;
uriPresent: boolean;
scheme: BinStoreScheme | null;
};
}
| {
status: "error";
bins: {
store: "mongo" | "memory";
connected: false;
uriPresent: boolean;
scheme: BinStoreScheme | null;
error: BinStoreErrorInfo;
};
};

export type UriShape = {
whitespace: boolean;
wrappedQuotes: boolean;
extraAt: boolean;
};

const ENCODING_HINT =
"Percent-encode reserved characters in the username and password (@ : / ? # [ ]).";

const loggedPhases = new WeakMap<object, Set<string>>();

/** Error whose message is already safe to return to clients. */
export class BinStoreFailure extends Error {
readonly binStoreFailure = true;

constructor(message: string, cause?: unknown) {
super(message, cause === undefined ? undefined : { cause });
this.name = "BinStoreFailure";
}
}

export function isBinStoreFailure(error: unknown): error is BinStoreFailure {
return (
error instanceof BinStoreFailure ||
(typeof error === "object" &&
error !== null &&
"binStoreFailure" in error &&
(error as { binStoreFailure?: boolean }).binStoreFailure === true)
);
}

export function mongoUriFacts(uri: string | undefined): {
present: boolean;
scheme: BinStoreScheme | null;
} {
if (!uri) {
return { present: false, scheme: null };
}
if (uri.startsWith("mongodb+srv://")) {
return { present: true, scheme: "mongodb+srv" };
}
if (uri.startsWith("mongodb://")) {
return { present: true, scheme: "mongodb" };
}
return { present: true, scheme: null };
}

/**
* Shape flags only. A normal URI has one `@` or zero `@`.
* A second `@` makes the driver throw `Invalid connection string` before the network call.
*/
export function uriShape(uri: string): UriShape {
return {
whitespace: /\s/.test(uri),
wrappedQuotes:
uri.startsWith('"') ||
uri.startsWith("'") ||
uri.endsWith('"') ||
uri.endsWith("'"),
extraAt: uri.split("@").length - 1 > 1,
};
}

export function isBinStoreConnectionFailure(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
}
if (/^Mongo(Parse|Network|ServerSelection|API)/.test(error.name)) {
return true;
}
const code =
"code" in error && (error as { code?: unknown }).code != null
? String((error as { code?: unknown }).code)
: "";
if (
code === "ENOTFOUND" ||
code === "ECONNREFUSED" ||
code === "ETIMEDOUT" ||
code === "EAI_AGAIN" ||
code === "ENETUNREACH"
) {
return true;
}
return /invalid connection string|unescaped characters|querySrv|server selection|proxy request failed|ECONNREFUSED|ENOTFOUND/i.test(
error.message,
);
}

function replaceSecret(message: string, secret: string): string {
if (secret.length < 3) {
return message;
}
// Short alphabetic tokens are substrings of words like "username".
if (/^[a-z]+$/i.test(secret) && secret.length < 8) {
return message;
Comment on lines +130 to +132

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Redact short credentials and host names

When a URI contains a short alphabetic username, password, or host, this branch leaves that value in public health responses and logs. For example, mongodb://bob:foo@mongo/db does not redact bob, foo, or mongo from a driver error. This breaks the stated rule that these values never appear.

Useful? React with 👍 / 👎.

}
return message.split(secret).join("[redacted]");
}

function sensitiveParts(uri: string): string[] {
const parts: string[] = [];
const body = uri.replace(/^mongodb(?:\+srv)?:\/\//i, "");
const at = body.lastIndexOf("@");
if (at >= 0) {
const userinfo = body.slice(0, at);
const colon = userinfo.indexOf(":");
if (colon >= 0) {
parts.push(userinfo.slice(0, colon), userinfo.slice(colon + 1));
} else if (userinfo) {
parts.push(userinfo);
}
const host = body.slice(at + 1).split(/[/?]/)[0] ?? "";
const hostname = host.split(":")[0] ?? "";
if (hostname) {
parts.push(hostname);
}
} else {
const host = body.split(/[/?]/)[0] ?? "";
const hostname = host.split(":")[0] ?? "";
if (hostname) {
parts.push(hostname);
}
}
return parts;
}

export function redactSecrets(message: string, uri?: string): string {
let out = message;
if (uri) {
out = out.split(uri).join("[redacted-uri]");
for (const part of sensitiveParts(uri)) {
out = replaceSecret(out, part);
}
}
out = out.replace(/mongodb(?:\+srv)?:\/\/\S+/gi, "[redacted-uri]");
out = out.replace(/\/\/([^/\s:@]+):([^/\s@]+)@/g, "//[redacted]:[redacted]@");
return out;
}

function withEncodingHint(
message: string,
shape: UriShape | undefined,
): string {
const needsHint =
shape?.extraAt === true ||
/invalid connection string|unescaped characters/i.test(message);
if (!needsHint || message.includes("Percent-encode")) {
return message;
}
return `${message}. ${ENCODING_HINT}`;
}

export function redactStoreError(
error: unknown,
uri?: string,
): BinStoreErrorInfo {
const name = error instanceof Error && error.name ? error.name : "Error";
const raw =
error instanceof Error ? error.message : "Bin store request failed";
const codeValue =
typeof error === "object" &&
error !== null &&
"code" in error &&
(error as { code?: unknown }).code != null
? (error as { code?: unknown }).code
: null;
const code =
typeof codeValue === "string" || typeof codeValue === "number"
? codeValue
: null;
const shape = uri ? uriShape(uri) : undefined;
return {
name,
message: withEncodingHint(redactSecrets(raw, uri), shape),
code,
};
}

export function toSafeStoreError(
error: unknown,
uri?: string,
): BinStoreFailure {
const info = redactStoreError(error, uri);
return new BinStoreFailure(info.message);
}

type LogBinStoreInput = {
phase: string;
store: string;
uri?: string;
error?: unknown;
level?: "error" | "info";
connected?: boolean;
latencyMs?: number;
};

function alreadyLogged(error: unknown, phase: string): boolean {
if (typeof error !== "object" || error === null) {
return false;
}
let phases = loggedPhases.get(error);
if (!phases) {
phases = new Set();
loggedPhases.set(error, phases);
}
if (phases.has(phase)) {
return true;
}
phases.add(phase);
return false;
}

/** One JSON line. Safe to print in Workers logs. */
export function logBinStore(input: LogBinStoreInput): void {
if (input.error !== undefined && alreadyLogged(input.error, input.phase)) {
return;
}
const facts = mongoUriFacts(input.uri);
const payload: Record<string, unknown> = {
source: "mockhttp.bin-store",
phase: input.phase,
store: input.store,
uriPresent: facts.present,
scheme: facts.scheme,
};
if (input.uri) {
payload.uriShape = uriShape(input.uri);
}
if (input.connected !== undefined) {
payload.connected = input.connected;
}
if (input.latencyMs !== undefined) {
payload.latencyMs = input.latencyMs;
}
if (input.error !== undefined) {
payload.error = redactStoreError(input.error, input.uri);
}
const line = JSON.stringify(payload);
if (input.level === "info") {
console.info(line);
return;
}
console.error(line);
}
Loading
Loading