@remit/backend 0.0.58 → 0.0.60
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dev-server/server.ts +47 -5
- package/dev-server/upload-handler.ts +132 -0
- package/dev-server/upload-route.test.ts +196 -0
- package/package.json +1 -1
- package/src/derive/contentSignature.test.ts +26 -0
- package/src/derive/contentSignature.ts +24 -41
- package/src/handlers/account-guards.ts +1 -0
- package/src/handlers/account-overrides.test.ts +19 -0
- package/src/handlers/account-overrides.ts +51 -1
- package/src/handlers/account.ts +9 -0
- package/src/handlers/index.ts +6 -1
- package/src/handlers/outbox-attachment.test.ts +763 -0
- package/src/handlers/outbox-attachment.ts +97 -0
- package/src/handlers/outbox.test.ts +247 -1
- package/src/handlers/outbox.ts +53 -4
- package/src/service/compose-sqlite.ts +2 -0
- package/src/service/create-remit-client.ts +15 -0
- package/src/types.ts +7 -0
package/dev-server/server.ts
CHANGED
|
@@ -7,7 +7,10 @@ import {
|
|
|
7
7
|
renderMetrics,
|
|
8
8
|
setAccountSyncAges,
|
|
9
9
|
} from "@remit/logger-lambda/metrics";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
isStorageNotFoundError,
|
|
12
|
+
UPLOAD_ROUTE_PREFIX,
|
|
13
|
+
} from "@remit/storage-service";
|
|
11
14
|
import type { APIGatewayProxyResult } from "aws-lambda";
|
|
12
15
|
import { env } from "expect-env";
|
|
13
16
|
import express, {
|
|
@@ -25,6 +28,7 @@ import { parseAllowedOrigins, resolveAllowOrigin } from "./cors.js";
|
|
|
25
28
|
import { createLambdaContext, createLambdaEvent } from "./lambda-helpers.js";
|
|
26
29
|
import { checkRelationalStore } from "./relational-health.js";
|
|
27
30
|
import { collectAccountSyncAges } from "./sync-age.js";
|
|
31
|
+
import { receiveUpload } from "./upload-handler.js";
|
|
28
32
|
|
|
29
33
|
const app = express();
|
|
30
34
|
|
|
@@ -100,9 +104,9 @@ if (isSelfHostBackend) {
|
|
|
100
104
|
app.all(/^\/api\/auth\//, toNodeHandler(auth));
|
|
101
105
|
}
|
|
102
106
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
107
|
+
// Ahead of the body parsers, not after them: a body refused for its size never
|
|
108
|
+
// reaches a route, and without these headers the browser reads that refusal as
|
|
109
|
+
// a network failure instead of the reason it carries.
|
|
106
110
|
app.use((req: Request, res: Response, next: NextFunction) => {
|
|
107
111
|
const allowOrigin = resolveAllowOrigin(
|
|
108
112
|
req.headers.origin,
|
|
@@ -128,6 +132,42 @@ app.use((req: Request, res: Response, next: NextFunction) => {
|
|
|
128
132
|
}
|
|
129
133
|
});
|
|
130
134
|
|
|
135
|
+
// The self-hosted upload receiver, the write-side twin of /content below. The
|
|
136
|
+
// URL that reaches here was minted by the storage backend and carries its own
|
|
137
|
+
// authority: an HMAC over the storage key, an expiry and the exact byte count.
|
|
138
|
+
// No bearer token, for the same reason /content has none — the grant has to
|
|
139
|
+
// travel in the URL itself. A hosted deployment presigns against block storage
|
|
140
|
+
// instead and nothing arrives here at all.
|
|
141
|
+
app.put(
|
|
142
|
+
new RegExp(`^${UPLOAD_ROUTE_PREFIX}.+$`),
|
|
143
|
+
async (req: Request, res: Response) => {
|
|
144
|
+
const storageKey = req.path.slice(UPLOAD_ROUTE_PREFIX.length);
|
|
145
|
+
const client = await getClient();
|
|
146
|
+
|
|
147
|
+
const result = await receiveUpload(client.storage, {
|
|
148
|
+
storageKey,
|
|
149
|
+
exp: typeof req.query.exp === "string" ? req.query.exp : undefined,
|
|
150
|
+
max: typeof req.query.max === "string" ? req.query.max : undefined,
|
|
151
|
+
sig: typeof req.query.sig === "string" ? req.query.sig : undefined,
|
|
152
|
+
body: req,
|
|
153
|
+
nowSeconds: Math.floor(Date.now() / 1000),
|
|
154
|
+
secret: process.env.BETTER_AUTH_SECRET,
|
|
155
|
+
findLiveReservation: (accountConfigId, outboxAttachmentId) =>
|
|
156
|
+
client.outboxAttachment.hasLiveReservation(
|
|
157
|
+
accountConfigId,
|
|
158
|
+
outboxAttachmentId,
|
|
159
|
+
),
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
res.setHeader("x-remit-upload-reason", result.reason);
|
|
163
|
+
res.status(result.status);
|
|
164
|
+
res.send(result.status === 204 ? undefined : result.reason);
|
|
165
|
+
},
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
app.use(express.json({ limit: "10mb" }));
|
|
169
|
+
app.use(express.urlencoded({ extended: true }));
|
|
170
|
+
|
|
131
171
|
app.get("/.well-known/appspecific/com.chrome.devtools.json", () => ({}));
|
|
132
172
|
|
|
133
173
|
app.get("/health", async (_req: Request, res: Response) => {
|
|
@@ -319,7 +359,9 @@ const port = env.SERVER_PORT;
|
|
|
319
359
|
//
|
|
320
360
|
// This is also the everyday `npm run dev` server, so the addresses stay whole
|
|
321
361
|
// and clickable — `url`, not a port a developer has to assemble one themselves.
|
|
322
|
-
app
|
|
362
|
+
// Exported so a test that drives this exact app can shut the listener down
|
|
363
|
+
// afterwards; nothing else has any business holding it.
|
|
364
|
+
export const listener = app.listen(Number(port), "0.0.0.0", () => {
|
|
323
365
|
// biome-ignore lint/plugin/no-logger-info: the configuration a container came up on is an audit-grade signal
|
|
324
366
|
logger.info(
|
|
325
367
|
{
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { Readable } from "node:stream";
|
|
2
|
+
import {
|
|
3
|
+
authorizeUploadRequest,
|
|
4
|
+
parseOutboxAttachmentKey,
|
|
5
|
+
type StorageService,
|
|
6
|
+
} from "@remit/storage-service";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The self-hosted receiver for an attachment upload.
|
|
10
|
+
*
|
|
11
|
+
* A hosted deployment mints a presigned PUT and none of this runs: the browser
|
|
12
|
+
* writes to block storage and the size is bound by the storage service itself.
|
|
13
|
+
* Where there is no storage service to presign against, this route is the
|
|
14
|
+
* equivalent, and it has to bind the same things — one attachment, one size,
|
|
15
|
+
* for a while — on its own.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export type CollectedBody =
|
|
19
|
+
| { readonly outcome: "Collected"; readonly content: Buffer }
|
|
20
|
+
| { readonly outcome: "TooLarge" };
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Read a request body, refusing it the moment it passes what was reserved.
|
|
24
|
+
*
|
|
25
|
+
* The count is checked per chunk and the stream destroyed on the one that
|
|
26
|
+
* crosses the line, so a client that reserved a megabyte and started sending a
|
|
27
|
+
* hundred is cut off after the first megabyte rather than after all of it.
|
|
28
|
+
*/
|
|
29
|
+
export const collectLimitedBody = async (
|
|
30
|
+
stream: Readable,
|
|
31
|
+
maxBytes: number,
|
|
32
|
+
): Promise<CollectedBody> => {
|
|
33
|
+
const chunks: Buffer[] = [];
|
|
34
|
+
let received = 0;
|
|
35
|
+
|
|
36
|
+
for await (const chunk of stream) {
|
|
37
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
38
|
+
received += buffer.length;
|
|
39
|
+
if (received > maxBytes) {
|
|
40
|
+
// Stop reading, but leave the socket alone: destroying it here races the
|
|
41
|
+
// 413 the caller is about to write, and the client sees a connection
|
|
42
|
+
// reset instead of the reason it was refused.
|
|
43
|
+
stream.pause();
|
|
44
|
+
return { outcome: "TooLarge" };
|
|
45
|
+
}
|
|
46
|
+
chunks.push(buffer);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return { outcome: "Collected", content: Buffer.concat(chunks) };
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export interface ReceiveUploadInput {
|
|
53
|
+
/** Decoded storage key, the `/outbox-upload/` prefix already stripped. */
|
|
54
|
+
storageKey: string;
|
|
55
|
+
exp: string | undefined;
|
|
56
|
+
max: string | undefined;
|
|
57
|
+
sig: string | undefined;
|
|
58
|
+
body: Readable;
|
|
59
|
+
nowSeconds: number;
|
|
60
|
+
secret: string | undefined;
|
|
61
|
+
/**
|
|
62
|
+
* Whether the database still holds a live reservation for this attachment.
|
|
63
|
+
* Injected rather than reached for, so the receiver stays a function of its
|
|
64
|
+
* request and the storage it writes to.
|
|
65
|
+
*/
|
|
66
|
+
findLiveReservation: (
|
|
67
|
+
accountConfigId: string,
|
|
68
|
+
outboxAttachmentId: string,
|
|
69
|
+
nowSeconds: number,
|
|
70
|
+
) => Promise<boolean>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface UploadResult {
|
|
74
|
+
status: number;
|
|
75
|
+
reason: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const receiveUpload = async (
|
|
79
|
+
storage: StorageService,
|
|
80
|
+
input: ReceiveUploadInput,
|
|
81
|
+
): Promise<UploadResult> => {
|
|
82
|
+
const target = parseOutboxAttachmentKey(input.storageKey);
|
|
83
|
+
if (!target) return { status: 404, reason: "not-an-attachment-key" };
|
|
84
|
+
|
|
85
|
+
const auth = authorizeUploadRequest({
|
|
86
|
+
secret: input.secret,
|
|
87
|
+
relativePath: input.storageKey,
|
|
88
|
+
exp: input.exp,
|
|
89
|
+
max: input.max,
|
|
90
|
+
sig: input.sig,
|
|
91
|
+
nowSeconds: input.nowSeconds,
|
|
92
|
+
});
|
|
93
|
+
if (!auth.authorized) {
|
|
94
|
+
return { status: auth.status, reason: auth.reason };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// A signed URL outlives the draft it was minted for, so possession of one is
|
|
98
|
+
// not enough: the database has to still hold a live reservation for this
|
|
99
|
+
// attachment. Without this a URL minted before a discard writes bytes back
|
|
100
|
+
// under a draft that no longer exists. The hosted shape cannot make this
|
|
101
|
+
// check — block storage receives the PUT directly — which is what the sweep
|
|
102
|
+
// exists for.
|
|
103
|
+
const reserved = await input.findLiveReservation(
|
|
104
|
+
target.accountConfigId,
|
|
105
|
+
target.outboxAttachmentId,
|
|
106
|
+
input.nowSeconds,
|
|
107
|
+
);
|
|
108
|
+
if (!reserved) return { status: 409, reason: "no-live-reservation" };
|
|
109
|
+
|
|
110
|
+
const collected = await collectLimitedBody(input.body, auth.maxBytes);
|
|
111
|
+
if (collected.outcome === "TooLarge") {
|
|
112
|
+
return { status: 413, reason: "over-reserved-size" };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Exactly the reserved size, not merely under it — the same thing S3 binds
|
|
116
|
+
// when it recomputes a presigned PUT's signature over the content-length it
|
|
117
|
+
// was sent. A short body is a failed upload, and the completion would refuse
|
|
118
|
+
// it anyway; refusing here means no partial object is left to refuse.
|
|
119
|
+
if (collected.content.length !== auth.maxBytes) {
|
|
120
|
+
return { status: 400, reason: "size-mismatch" };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
await storage.storeOutboxAttachment({
|
|
124
|
+
accountConfigId: target.accountConfigId,
|
|
125
|
+
accountId: target.accountId,
|
|
126
|
+
outboxMessageId: target.outboxMessageId,
|
|
127
|
+
outboxAttachmentId: target.outboxAttachmentId,
|
|
128
|
+
content: collected.content,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
return { status: 204, reason: "stored" };
|
|
132
|
+
};
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue #679: the upload route through the real dev-server app.
|
|
3
|
+
*
|
|
4
|
+
* Not a copy of its middleware order — the app object itself, imported from
|
|
5
|
+
* `server.ts`. A copy is what let a JSON-typed upload silently read zero bytes:
|
|
6
|
+
* `express.json` drains the stream before any route below it sees the body, and
|
|
7
|
+
* a test that rebuilds the stack stays green when the real one is reordered.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
12
|
+
import type { Server } from "node:http";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { after, before, describe, it } from "node:test";
|
|
15
|
+
import type {
|
|
16
|
+
IOutboxAttachmentRepository,
|
|
17
|
+
OutboxAttachmentItem,
|
|
18
|
+
} from "@remit/data-ports";
|
|
19
|
+
import { NotFoundError } from "@remit/data-ports/errors";
|
|
20
|
+
import { OutboxAttachmentService } from "@remit/mailbox-service";
|
|
21
|
+
import {
|
|
22
|
+
type StorageService,
|
|
23
|
+
UPLOAD_ROUTE_PREFIX,
|
|
24
|
+
} from "@remit/storage-service";
|
|
25
|
+
import { createFilesystemStorageService } from "@remit/storage-service/filesystem";
|
|
26
|
+
import {
|
|
27
|
+
_resetForTest,
|
|
28
|
+
type RemitClient,
|
|
29
|
+
setClient,
|
|
30
|
+
} from "../src/service/data-client.js";
|
|
31
|
+
|
|
32
|
+
const SECRET = "a-signing-secret-of-at-least-32-characters";
|
|
33
|
+
const CFG = "cfg-upload";
|
|
34
|
+
const ACC = "acc-upload";
|
|
35
|
+
const DRAFT = "draft-upload";
|
|
36
|
+
|
|
37
|
+
let storage: StorageService;
|
|
38
|
+
let basePath: string;
|
|
39
|
+
let rows: Map<string, OutboxAttachmentItem>;
|
|
40
|
+
let server: Server;
|
|
41
|
+
let port: number;
|
|
42
|
+
|
|
43
|
+
const repository = (): IOutboxAttachmentRepository =>
|
|
44
|
+
({
|
|
45
|
+
get: async (accountConfigId: string, outboxAttachmentId: string) => {
|
|
46
|
+
const row = rows.get(outboxAttachmentId);
|
|
47
|
+
if (!row || row.accountConfigId !== accountConfigId) {
|
|
48
|
+
throw new NotFoundError("gone");
|
|
49
|
+
}
|
|
50
|
+
return row;
|
|
51
|
+
},
|
|
52
|
+
listByOutboxMessage: async () => [...rows.values()],
|
|
53
|
+
}) as unknown as IOutboxAttachmentRepository;
|
|
54
|
+
|
|
55
|
+
const reserve = async (
|
|
56
|
+
outboxAttachmentId: string,
|
|
57
|
+
sizeBytes: number,
|
|
58
|
+
): Promise<string> => {
|
|
59
|
+
const expiresAt = Math.floor(Date.now() / 1000) + 900;
|
|
60
|
+
rows.set(outboxAttachmentId, {
|
|
61
|
+
outboxAttachmentId,
|
|
62
|
+
outboxMessageId: DRAFT,
|
|
63
|
+
accountId: ACC,
|
|
64
|
+
accountConfigId: CFG,
|
|
65
|
+
filename: `${outboxAttachmentId}.bin`,
|
|
66
|
+
contentType: "application/octet-stream",
|
|
67
|
+
sizeBytes,
|
|
68
|
+
state: "Pending",
|
|
69
|
+
storageKey: "",
|
|
70
|
+
reservationExpiresAt: expiresAt,
|
|
71
|
+
createdAt: 0,
|
|
72
|
+
updatedAt: 0,
|
|
73
|
+
});
|
|
74
|
+
const { uploadUrl } = await storage.createOutboxAttachmentUploadUrl({
|
|
75
|
+
accountConfigId: CFG,
|
|
76
|
+
accountId: ACC,
|
|
77
|
+
outboxMessageId: DRAFT,
|
|
78
|
+
outboxAttachmentId,
|
|
79
|
+
sizeBytes,
|
|
80
|
+
expiresAt,
|
|
81
|
+
});
|
|
82
|
+
const url = new URL(uploadUrl);
|
|
83
|
+
return `http://127.0.0.1:${port}${url.pathname}${url.search}`;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
before(async () => {
|
|
87
|
+
// Repo-local scratch, not the machine's shared temp directory.
|
|
88
|
+
basePath = await mkdtemp(join(process.cwd(), ".tmp-upload-route-"));
|
|
89
|
+
storage = createFilesystemStorageService(basePath, {
|
|
90
|
+
origin: "http://127.0.0.1",
|
|
91
|
+
signingSecret: SECRET,
|
|
92
|
+
});
|
|
93
|
+
rows = new Map();
|
|
94
|
+
|
|
95
|
+
setClient({
|
|
96
|
+
storage,
|
|
97
|
+
outboxAttachment: new OutboxAttachmentService({
|
|
98
|
+
outboxMessageService: {} as never,
|
|
99
|
+
outboxAttachmentService: repository(),
|
|
100
|
+
storage,
|
|
101
|
+
}),
|
|
102
|
+
} as unknown as RemitClient);
|
|
103
|
+
|
|
104
|
+
process.env.BETTER_AUTH_SECRET = SECRET;
|
|
105
|
+
// Port 0: the app binds whatever is free and tells us which.
|
|
106
|
+
process.env.SERVER_PORT = "0";
|
|
107
|
+
process.env.CORS_ALLOWED_ORIGINS = "*";
|
|
108
|
+
|
|
109
|
+
const imported = await import("./server.js");
|
|
110
|
+
server = imported.listener;
|
|
111
|
+
if (!server.listening) {
|
|
112
|
+
await new Promise<void>((resolve) => server.once("listening", resolve));
|
|
113
|
+
}
|
|
114
|
+
const address = server.address();
|
|
115
|
+
port = typeof address === "object" && address ? address.port : 0;
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
after(async () => {
|
|
119
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
120
|
+
await rm(basePath, { recursive: true, force: true });
|
|
121
|
+
_resetForTest();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("PUT /outbox-upload through the dev-server's own app", () => {
|
|
125
|
+
it("stores every byte of a file the browser labelled application/json", async () => {
|
|
126
|
+
const content = Buffer.from(JSON.stringify({ note: "x".repeat(100) }));
|
|
127
|
+
const url = await reserve("attjson", content.length);
|
|
128
|
+
|
|
129
|
+
const response = await fetch(url, {
|
|
130
|
+
method: "PUT",
|
|
131
|
+
body: content,
|
|
132
|
+
// What `fetch(url, { body: file })` sends for a .json file: File.type.
|
|
133
|
+
headers: { "content-type": "application/json" },
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
assert.equal(response.status, 204);
|
|
137
|
+
assert.equal(
|
|
138
|
+
(await storage.statOutboxAttachment(CFG, ACC, DRAFT, "attjson"))
|
|
139
|
+
?.sizeBytes,
|
|
140
|
+
content.length,
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("stores every byte of a form-urlencoded-looking upload too", async () => {
|
|
145
|
+
const content = Buffer.from(`a=1&b=2&c=${"z".repeat(200)}`);
|
|
146
|
+
const url = await reserve("attform", content.length);
|
|
147
|
+
|
|
148
|
+
const response = await fetch(url, {
|
|
149
|
+
method: "PUT",
|
|
150
|
+
body: content,
|
|
151
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
assert.equal(response.status, 204);
|
|
155
|
+
assert.equal(
|
|
156
|
+
(await storage.statOutboxAttachment(CFG, ACC, DRAFT, "attform"))
|
|
157
|
+
?.sizeBytes,
|
|
158
|
+
content.length,
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("stores binary unchanged", async () => {
|
|
163
|
+
const content = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0xff, 0x10]);
|
|
164
|
+
const url = await reserve("attpng", content.length);
|
|
165
|
+
|
|
166
|
+
const response = await fetch(url, {
|
|
167
|
+
method: "PUT",
|
|
168
|
+
body: content,
|
|
169
|
+
headers: { "content-type": "image/png" },
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
assert.equal(response.status, 204);
|
|
173
|
+
assert.equal(
|
|
174
|
+
(await storage.statOutboxAttachment(CFG, ACC, DRAFT, "attpng"))
|
|
175
|
+
?.sizeBytes,
|
|
176
|
+
content.length,
|
|
177
|
+
);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("refuses a PUT once the draft no longer has a live reservation", async () => {
|
|
181
|
+
const url = await reserve("attgone", 8);
|
|
182
|
+
rows.delete("attgone");
|
|
183
|
+
|
|
184
|
+
const response = await fetch(url, {
|
|
185
|
+
method: "PUT",
|
|
186
|
+
body: Buffer.alloc(8),
|
|
187
|
+
headers: { "content-type": "application/octet-stream" },
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
assert.equal(response.status, 409);
|
|
191
|
+
assert.equal(
|
|
192
|
+
await storage.statOutboxAttachment(CFG, ACC, DRAFT, "attgone"),
|
|
193
|
+
null,
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { afterEach, describe, it } from "node:test";
|
|
3
3
|
import {
|
|
4
|
+
CONTENT_URL_TTL_SECONDS,
|
|
4
5
|
createContentSigner,
|
|
5
6
|
getContentSigner,
|
|
6
7
|
verifyContentSignature,
|
|
@@ -133,3 +134,28 @@ describe("getContentSigner", () => {
|
|
|
133
134
|
assert.throws(() => getContentSigner(), /BETTER_AUTH_SECRET/);
|
|
134
135
|
});
|
|
135
136
|
});
|
|
137
|
+
|
|
138
|
+
describe("the wire format is pinned, not merely self-consistent", () => {
|
|
139
|
+
/**
|
|
140
|
+
* Every other test here round-trips through this module, so a change to the
|
|
141
|
+
* canonical message or the key derivation passes them all while breaking every
|
|
142
|
+
* `/content` URL already in a browser. This vector was computed once, by hand,
|
|
143
|
+
* from the scheme as shipped: HMAC-SHA256 with a subkey derived from the
|
|
144
|
+
* master secret under `remit-content-url-signing-v1`, over `path\nexp`,
|
|
145
|
+
* base64url. If it fails, the format moved and in-flight URLs moved with it.
|
|
146
|
+
*/
|
|
147
|
+
it("still produces the signature it produced when the scheme shipped", () => {
|
|
148
|
+
const signer = createContentSigner(
|
|
149
|
+
"a-signing-secret-of-at-least-32-characters",
|
|
150
|
+
);
|
|
151
|
+
const originalNow = Date.now;
|
|
152
|
+
Date.now = () => 1_800_000_000_000 - CONTENT_URL_TTL_SECONDS * 1000;
|
|
153
|
+
try {
|
|
154
|
+
const { exp, sig } = signer("accounts/cfg1/acc1/messages/msg1/parts/1.2");
|
|
155
|
+
assert.equal(exp, 1_800_000_000);
|
|
156
|
+
assert.equal(sig, "eFKwOYf42a6DWwaZahIm7aVtWuqMOfhOAPoyxtpFybo");
|
|
157
|
+
} finally {
|
|
158
|
+
Date.now = originalNow;
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
});
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type SignedPathFailure,
|
|
3
|
+
signStoragePath,
|
|
4
|
+
verifyStoragePath,
|
|
5
|
+
} from "@remit/storage-service/signed-path";
|
|
2
6
|
import { usesBetterAuthJwt } from "../data-backend.js";
|
|
3
7
|
|
|
4
8
|
/**
|
|
@@ -20,6 +24,10 @@ import { usesBetterAuthJwt } from "../data-backend.js";
|
|
|
20
24
|
* valid signature for another account. This is the presigned-URL capability
|
|
21
25
|
* model the export flow already uses: possession of the URL grants access until
|
|
22
26
|
* it expires, bounded by a short TTL.
|
|
27
|
+
*
|
|
28
|
+
* The HMAC itself lives in `@remit/storage-service/signed-path`, shared with the
|
|
29
|
+
* write side (an attachment upload URL) under a different derivation label. This
|
|
30
|
+
* module is the read side's label, TTL and public shape.
|
|
23
31
|
*/
|
|
24
32
|
|
|
25
33
|
const KEY_DERIVATION_LABEL = "remit-content-url-signing-v1";
|
|
@@ -32,28 +40,6 @@ const KEY_DERIVATION_LABEL = "remit-content-url-signing-v1";
|
|
|
32
40
|
*/
|
|
33
41
|
export const CONTENT_URL_TTL_SECONDS = 3600;
|
|
34
42
|
|
|
35
|
-
/**
|
|
36
|
-
* Derive a purpose-specific signing subkey from the master secret. Domain
|
|
37
|
-
* separation via a fixed label keeps the content-signing key distinct from the
|
|
38
|
-
* raw better-auth secret, so a compromise of one signing space does not reveal
|
|
39
|
-
* the other. Reusing `BETTER_AUTH_SECRET` as the master means no new secret has
|
|
40
|
-
* to be provisioned on the self-host stack — it is already required there.
|
|
41
|
-
*/
|
|
42
|
-
const deriveSigningKey = (masterSecret: string): Buffer =>
|
|
43
|
-
createHmac("sha256", masterSecret).update(KEY_DERIVATION_LABEL).digest();
|
|
44
|
-
|
|
45
|
-
const canonicalMessage = (relativePath: string, exp: number): string =>
|
|
46
|
-
`${relativePath}\n${exp}`;
|
|
47
|
-
|
|
48
|
-
const computeSignature = (
|
|
49
|
-
key: Buffer,
|
|
50
|
-
relativePath: string,
|
|
51
|
-
exp: number,
|
|
52
|
-
): string =>
|
|
53
|
-
createHmac("sha256", key)
|
|
54
|
-
.update(canonicalMessage(relativePath, exp))
|
|
55
|
-
.digest("base64url");
|
|
56
|
-
|
|
57
43
|
export interface ContentSignature {
|
|
58
44
|
exp: number;
|
|
59
45
|
sig: string;
|
|
@@ -69,10 +55,14 @@ export const createContentSigner = (
|
|
|
69
55
|
masterSecret: string,
|
|
70
56
|
ttlSeconds: number = CONTENT_URL_TTL_SECONDS,
|
|
71
57
|
): ContentSigner => {
|
|
72
|
-
const key = deriveSigningKey(masterSecret);
|
|
73
58
|
return (relativePath) => {
|
|
74
59
|
const exp = Math.floor(Date.now() / 1000) + ttlSeconds;
|
|
75
|
-
return {
|
|
60
|
+
return {
|
|
61
|
+
exp,
|
|
62
|
+
sig: signStoragePath(masterSecret, KEY_DERIVATION_LABEL, relativePath, [
|
|
63
|
+
exp,
|
|
64
|
+
]),
|
|
65
|
+
};
|
|
76
66
|
};
|
|
77
67
|
};
|
|
78
68
|
|
|
@@ -96,11 +86,7 @@ export const getContentSigner = (): ContentSigner | undefined => {
|
|
|
96
86
|
return createContentSigner(secret);
|
|
97
87
|
};
|
|
98
88
|
|
|
99
|
-
export type ContentSignatureFailure =
|
|
100
|
-
| "missing"
|
|
101
|
-
| "malformed"
|
|
102
|
-
| "expired"
|
|
103
|
-
| "bad-signature";
|
|
89
|
+
export type ContentSignatureFailure = SignedPathFailure;
|
|
104
90
|
|
|
105
91
|
export type ContentSignatureResult =
|
|
106
92
|
| { valid: true }
|
|
@@ -124,16 +110,13 @@ export const verifyContentSignature = (
|
|
|
124
110
|
if (!Number.isInteger(exp) || exp <= 0) {
|
|
125
111
|
return { valid: false, reason: "malformed" };
|
|
126
112
|
}
|
|
127
|
-
if (exp < nowSeconds) return { valid: false, reason: "expired" };
|
|
128
113
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
}
|
|
138
|
-
return { valid: true };
|
|
114
|
+
return verifyStoragePath(
|
|
115
|
+
masterSecret,
|
|
116
|
+
KEY_DERIVATION_LABEL,
|
|
117
|
+
relativePath,
|
|
118
|
+
[exp],
|
|
119
|
+
sig,
|
|
120
|
+
nowSeconds,
|
|
121
|
+
);
|
|
139
122
|
};
|
|
@@ -122,6 +122,7 @@ export const toAccountResponse = (
|
|
|
122
122
|
smtpUsername: account.smtpUsername ?? "",
|
|
123
123
|
signaturePlainText: signature.plainText,
|
|
124
124
|
signatureHtml: signature.html,
|
|
125
|
+
composeLanguages: overrides.composeLanguages,
|
|
125
126
|
isActive: account.isActive,
|
|
126
127
|
connectionState: account.connectionState,
|
|
127
128
|
lastConnectedAt: account.lastConnectedAt,
|
|
@@ -33,6 +33,25 @@ describe("groupAccountOverrides", () => {
|
|
|
33
33
|
assert.equal(grouped.get("acc-2")?.displayName, "Bob");
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
+
it("groups the compose-language list by accountId", () => {
|
|
37
|
+
const settings = [
|
|
38
|
+
setting("AccountComposeLanguages#acc-1", {
|
|
39
|
+
kind: "StringList",
|
|
40
|
+
value: ["nl", "en"],
|
|
41
|
+
}),
|
|
42
|
+
stringSetting("AccountDisplayName#acc-1", "Alice"),
|
|
43
|
+
];
|
|
44
|
+
const grouped = groupAccountOverrides(settings);
|
|
45
|
+
assert.deepEqual(grouped.get("acc-1")?.composeLanguages, ["nl", "en"]);
|
|
46
|
+
assert.equal(grouped.get("acc-1")?.displayName, "Alice");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("ignores a compose-language row stored under the wrong value kind", () => {
|
|
50
|
+
const settings = [stringSetting("AccountComposeLanguages#acc-1", "nl")];
|
|
51
|
+
const grouped = groupAccountOverrides(settings);
|
|
52
|
+
assert.equal(grouped.get("acc-1"), undefined);
|
|
53
|
+
});
|
|
54
|
+
|
|
36
55
|
it("ignores a leftover MailboxRole#<mailboxId> row instead of throwing", () => {
|
|
37
56
|
// Leftover rows from the #963/#964 backfill (superseded by
|
|
38
57
|
// FolderRoleAppointment, RFC 032 exclusive-folder-appointment #976) must
|