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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ in-memory database. Set `DESTROYER_DB_PATH` to retain data elsewhere. Production
requires `DESTROYER_JWT_PRIVATE_KEY` containing an RSA private JWK; `DESTROYER_JWT_KID`, `HOST`, and
`PORT` are configurable.

Contact and authentication rate limits use the TCP peer address authenticated by `@askrjs/node`.
Client-supplied `X-Forwarded-For` and `x-askr-client-address` values are never trusted; the Node
adapter overwrites its reserved header from the socket. A deployment behind a reverse proxy is
therefore limited by the proxy peer unless a separate, explicit trusted-proxy boundary is added.
Do not enable original-client forwarding by reading `X-Forwarded-For` directly in application code.

## Askr packages

Destroyer installs ranged releases from the npm registry for `@askrjs/askr`, auth, charts, lucide,
Expand Down
6 changes: 3 additions & 3 deletions package-lock.json

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

3 changes: 2 additions & 1 deletion src/server/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { AskrAppApi } from "@askrjs/server/askr";
import { security } from "@askrjs/server/openapi";
import type { AppDependencies } from "./contracts";
import { RepositoryConflictError } from "./contracts";
import { clientAddress } from "./client-address";

export function defineOperationsApi(api: AskrAppApi<AppDependencies>) {
const Summary = api.schema(
Expand Down Expand Up @@ -196,7 +197,7 @@ export function defineOperationsApi(api: AskrAppApi<AppDependencies>) {
input: { body: { schema: ContactInput, mediaTypes: ["application/json"] } },
documentation: { body: { required: true } },
async handler(ctx, input, deps) {
const address = ctx.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "local";
const address = clientAddress(ctx.headers);
const limit = await deps.rateLimits.consume(
`contact:${address}:${input.body.email.toLowerCase()}`,
3,
Expand Down
3 changes: 2 additions & 1 deletion src/server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { SESSION_COOKIE } from "./dependencies";
import { RepositoryConflictError } from "./contracts";
import { createQueryRegistry } from "./queries";
import { settingsActionHandlers } from "./actions";
import { clientAddress } from "./client-address";

export function createApp(deps: AppDependencies, issuer: JwtIssuer) {
const principalSchema: Schema = {
Expand Down Expand Up @@ -59,7 +60,7 @@ export function createApp(deps: AppDependencies, issuer: JwtIssuer) {
allowAttempt: async (ctx, operation, email) =>
(
await deps.rateLimits.consume(
`auth:${operation}:${email}:${ctx.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "local"}`,
`auth:${operation}:${email}:${clientAddress(ctx.headers)}`,
5,
15 * 60_000,
)
Expand Down
6 changes: 6 additions & 0 deletions src/server/client-address.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { CLIENT_ADDRESS_HEADER } from "@askrjs/node";

/** Returns the Node-adapter-authenticated TCP peer used by IP-keyed security controls. */
export function clientAddress(headers: Headers): string {
return headers.get(CLIENT_ADDRESS_HEADER) ?? "unknown";
}
77 changes: 77 additions & 0 deletions tests/full-stack.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createJwtIssuer } from "@askrjs/auth/jwt";
import { CLIENT_ADDRESS_HEADER, listen } from "@askrjs/node";
import { generateKeyPairSync } from "node:crypto";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
Expand Down Expand Up @@ -150,6 +151,82 @@ describe("Destroyer full stack", () => {
expect(await deps.contacts.count()).toBe(3);
});

it("should hold contact and authentication limits against spoofed forwarding addresses", async () => {
const deps = dependencies();
const app = testApp(deps);
const server = await listen(app, { host: "127.0.0.1" });
const address = server.address();
if (!address || typeof address === "string") throw new Error("Expected TCP address");
const origin = `http://127.0.0.1:${address.port}`;
const post = (path: string, body: unknown, spoofed: string) =>
fetch(`${origin}${path}`, {
method: "POST",
redirect: "manual",
headers: {
"content-type": "application/json",
origin,
[CLIENT_ADDRESS_HEADER]: spoofed,
"x-forwarded-for": spoofed,
},
body: JSON.stringify(body),
});

try {
const contactStatuses: number[] = [];
for (let index = 0; index < 4; index += 1) {
contactStatuses.push(
(
await post(
"/api/contact",
{
email: "spoof-proof@example.test",
subject: "Need help",
message: "A spoof-boundary request from the integration suite.",
},
`198.51.100.${index + 1}`,
)
).status,
);
}
expect(
(
await post(
"/auth/v1/accounts",
{ email: "rate-limited@example.test", password: "destroyer" },
"203.0.113.1",
)
).status,
).toBe(303);
const loginStatuses: number[] = [];
const emailVariants = [
"rate-limited@example.test",
"Rate-Limited@example.test",
"RATE-LIMITED@example.test",
"rate-limited@EXAMPLE.test",
"rate-limited@example.TEST",
"RATE-limited@EXAMPLE.TEST",
];
for (let index = 0; index < 6; index += 1) {
loginStatuses.push(
(
await post(
"/auth/v1/session",
{ email: emailVariants[index], password: "incorrect" },
`203.0.113.${index + 10}`,
)
).status,
);
}
expect({ contactStatuses, loginStatuses, persisted: await deps.contacts.count() }).toEqual({
contactStatuses: [201, 201, 201, 429],
loginStatuses: [401, 401, 401, 401, 401, 429],
persisted: 3,
});
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});

it("should reject malformed support payloads given invalid contact input", async () => {
const response = await testApp(dependencies()).fetch(
new Request("http://destroyer.test/api/contact", {
Expand Down