@remit/backend 0.0.1
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/.env.test +7 -0
- package/dev-server/content-auth.test.ts +113 -0
- package/dev-server/content-auth.ts +54 -0
- package/dev-server/content-handler.test.ts +105 -0
- package/dev-server/content-handler.ts +79 -0
- package/dev-server/content-path.test.ts +37 -0
- package/dev-server/content-path.ts +27 -0
- package/dev-server/cors.test.ts +48 -0
- package/dev-server/cors.ts +25 -0
- package/dev-server/lambda-helpers.ts +69 -0
- package/dev-server/server.ts +289 -0
- package/package.json +82 -0
- package/src/auth.ts +92 -0
- package/src/config/msoauth.ts +60 -0
- package/src/data-backend.test.ts +25 -0
- package/src/data-backend.ts +20 -0
- package/src/derive/autoMoved.ts +28 -0
- package/src/derive/contentSignature.test.ts +153 -0
- package/src/derive/contentSignature.ts +139 -0
- package/src/derive/contentUrl.test.ts +156 -0
- package/src/derive/contentUrl.ts +70 -0
- package/src/derive/enrichThreadRows.ts +155 -0
- package/src/derive/filterThreadCriteria.test.ts +119 -0
- package/src/derive/filterThreadCriteria.ts +60 -0
- package/src/derive/pendingMoveCounts.ts +90 -0
- package/src/derive/senderTrust.test.ts +67 -0
- package/src/derive/senderTrust.ts +23 -0
- package/src/error.ts +55 -0
- package/src/handlers/account-guards.ts +137 -0
- package/src/handlers/account-oauth.test.ts +383 -0
- package/src/handlers/account-oauth.ts +452 -0
- package/src/handlers/account-overrides.test.ts +69 -0
- package/src/handlers/account-overrides.ts +286 -0
- package/src/handlers/account-ownership.ts +25 -0
- package/src/handlers/account-signature.ts +129 -0
- package/src/handlers/account.ts +524 -0
- package/src/handlers/address.ts +136 -0
- package/src/handlers/config.test.ts +184 -0
- package/src/handlers/config.ts +219 -0
- package/src/handlers/ensure-account-config.ts +30 -0
- package/src/handlers/filter.ts +294 -0
- package/src/handlers/folder-role-appointments.test.ts +250 -0
- package/src/handlers/folder-role-appointments.ts +272 -0
- package/src/handlers/folder-role.ts +76 -0
- package/src/handlers/index.ts +48 -0
- package/src/handlers/mailbox.ts +411 -0
- package/src/handlers/me.ts +190 -0
- package/src/handlers/message.ts +886 -0
- package/src/handlers/organize.test.ts +43 -0
- package/src/handlers/organize.ts +196 -0
- package/src/handlers/outbox.ts +218 -0
- package/src/handlers/search.test.ts +182 -0
- package/src/handlers/search.ts +105 -0
- package/src/handlers/sync-progress.test.ts +158 -0
- package/src/handlers/sync-progress.ts +64 -0
- package/src/handlers/sync.test.ts +146 -0
- package/src/handlers/sync.ts +119 -0
- package/src/handlers/thread.ts +361 -0
- package/src/handlers/unified-threads.ts +196 -0
- package/src/handlers/vip-suggestions.ts +21 -0
- package/src/index.ts +170 -0
- package/src/json.ts +11 -0
- package/src/jwt-auth.test.ts +89 -0
- package/src/jwt-auth.ts +95 -0
- package/src/request-context.test.ts +61 -0
- package/src/request-context.ts +29 -0
- package/src/request.ts +10 -0
- package/src/response.test.ts +107 -0
- package/src/response.ts +80 -0
- package/src/service/compose-postgres.ts +72 -0
- package/src/service/compose-sqlite.ts +80 -0
- package/src/service/create-remit-client.ts +358 -0
- package/src/service/dynamodb.test.ts +100 -0
- package/src/service/dynamodb.ts +58 -0
- package/src/service/filter.ts +55 -0
- package/src/service/fire-and-forget.test.ts +126 -0
- package/src/service/fire-and-forget.ts +79 -0
- package/src/service/localhost-env-config.test.ts +40 -0
- package/src/service/organize.test.ts +384 -0
- package/src/service/organize.ts +354 -0
- package/src/service/semantic-capability.test.ts +64 -0
- package/src/service/semantic-capability.ts +62 -0
- package/src/service/sqs.ts +4 -0
- package/src/service/trigger-sync.test.ts +211 -0
- package/src/service/trigger-sync.ts +102 -0
- package/src/types.ts +161 -0
- package/tsconfig.json +7 -0
package/.env.test
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { createContentSigner } from "../src/derive/contentSignature.js";
|
|
4
|
+
import { authorizeContentRequest } from "./content-auth.js";
|
|
5
|
+
|
|
6
|
+
const SECRET = "test-master-secret-at-least-32-chars-long";
|
|
7
|
+
const PATH = "accounts/cfg-alice/acc-alice/messages/msg-1/parts/1.2";
|
|
8
|
+
const now = () => Math.floor(Date.now() / 1000);
|
|
9
|
+
|
|
10
|
+
describe("authorizeContentRequest", () => {
|
|
11
|
+
it("is a no-op outside Postgres mode (AWS serves unsigned URLs)", () => {
|
|
12
|
+
const result = authorizeContentRequest({
|
|
13
|
+
dataBackend: undefined,
|
|
14
|
+
secret: undefined,
|
|
15
|
+
relativePath: PATH,
|
|
16
|
+
exp: undefined,
|
|
17
|
+
sig: undefined,
|
|
18
|
+
nowSeconds: now(),
|
|
19
|
+
});
|
|
20
|
+
assert.deepEqual(result, { authorized: true });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("authorizes a validly signed request in Postgres mode", () => {
|
|
24
|
+
const { exp, sig } = createContentSigner(SECRET)(PATH);
|
|
25
|
+
const result = authorizeContentRequest({
|
|
26
|
+
dataBackend: "postgres",
|
|
27
|
+
secret: SECRET,
|
|
28
|
+
relativePath: PATH,
|
|
29
|
+
exp: String(exp),
|
|
30
|
+
sig,
|
|
31
|
+
nowSeconds: now(),
|
|
32
|
+
});
|
|
33
|
+
assert.deepEqual(result, { authorized: true });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("returns 401 when the signature is absent", () => {
|
|
37
|
+
const result = authorizeContentRequest({
|
|
38
|
+
dataBackend: "postgres",
|
|
39
|
+
secret: SECRET,
|
|
40
|
+
relativePath: PATH,
|
|
41
|
+
exp: undefined,
|
|
42
|
+
sig: undefined,
|
|
43
|
+
nowSeconds: now(),
|
|
44
|
+
});
|
|
45
|
+
assert.equal(result.authorized, false);
|
|
46
|
+
assert.equal(result.authorized === false && result.status, 401);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("returns 403 for a signature minted for another account's path", () => {
|
|
50
|
+
const { exp, sig } = createContentSigner(SECRET)(PATH);
|
|
51
|
+
const result = authorizeContentRequest({
|
|
52
|
+
dataBackend: "postgres",
|
|
53
|
+
secret: SECRET,
|
|
54
|
+
relativePath: "accounts/cfg-bob/acc-bob/messages/msg-9/parts/1.2",
|
|
55
|
+
exp: String(exp),
|
|
56
|
+
sig,
|
|
57
|
+
nowSeconds: now(),
|
|
58
|
+
});
|
|
59
|
+
assert.equal(result.authorized, false);
|
|
60
|
+
assert.equal(result.authorized === false && result.status, 403);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("returns 403 for an expired signature", () => {
|
|
64
|
+
const { exp, sig } = createContentSigner(SECRET, -20)(PATH);
|
|
65
|
+
const result = authorizeContentRequest({
|
|
66
|
+
dataBackend: "postgres",
|
|
67
|
+
secret: SECRET,
|
|
68
|
+
relativePath: PATH,
|
|
69
|
+
exp: String(exp),
|
|
70
|
+
sig,
|
|
71
|
+
nowSeconds: now(),
|
|
72
|
+
});
|
|
73
|
+
assert.equal(result.authorized, false);
|
|
74
|
+
assert.equal(result.authorized === false && result.status, 403);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("fails closed with 500 in Postgres mode when no signing secret is configured", () => {
|
|
78
|
+
const result = authorizeContentRequest({
|
|
79
|
+
dataBackend: "postgres",
|
|
80
|
+
secret: undefined,
|
|
81
|
+
relativePath: PATH,
|
|
82
|
+
exp: "123",
|
|
83
|
+
sig: "whatever",
|
|
84
|
+
nowSeconds: now(),
|
|
85
|
+
});
|
|
86
|
+
assert.equal(result.authorized, false);
|
|
87
|
+
assert.equal(result.authorized === false && result.status, 500);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("enforces signatures in SQLite mode the same as Postgres", () => {
|
|
91
|
+
const { exp, sig } = createContentSigner(SECRET)(PATH);
|
|
92
|
+
const valid = authorizeContentRequest({
|
|
93
|
+
dataBackend: "sqlite",
|
|
94
|
+
secret: SECRET,
|
|
95
|
+
relativePath: PATH,
|
|
96
|
+
exp: String(exp),
|
|
97
|
+
sig,
|
|
98
|
+
nowSeconds: now(),
|
|
99
|
+
});
|
|
100
|
+
assert.deepEqual(valid, { authorized: true });
|
|
101
|
+
|
|
102
|
+
const unsigned = authorizeContentRequest({
|
|
103
|
+
dataBackend: "sqlite",
|
|
104
|
+
secret: SECRET,
|
|
105
|
+
relativePath: PATH,
|
|
106
|
+
exp: undefined,
|
|
107
|
+
sig: undefined,
|
|
108
|
+
nowSeconds: now(),
|
|
109
|
+
});
|
|
110
|
+
assert.equal(unsigned.authorized, false);
|
|
111
|
+
assert.equal(unsigned.authorized === false && unsigned.status, 401);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { verifyContentSignature } from "../src/derive/contentSignature.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Decide whether a `/content` request is authorized. Enforcement applies on the
|
|
5
|
+
* self-host SQL backends (postgres and sqlite), where this server is the
|
|
6
|
+
* deployed backend container and content URLs are signed. On AWS-local dev
|
|
7
|
+
* (`DATA_BACKEND` unset) `/content` is served straight from the filesystem
|
|
8
|
+
* stand-in for CloudFront and URLs are unsigned, so the check is a no-op.
|
|
9
|
+
*
|
|
10
|
+
* Pure so the decision can be unit-tested without a live server. `relativePath`
|
|
11
|
+
* is the decoded storage path (`accounts/{cfg}/{acc}/messages/{msg}/parts/{part}`)
|
|
12
|
+
* the signature was minted over — the caller strips the `/content/` prefix.
|
|
13
|
+
*/
|
|
14
|
+
export interface AuthorizeContentInput {
|
|
15
|
+
dataBackend: string | undefined;
|
|
16
|
+
secret: string | undefined;
|
|
17
|
+
relativePath: string;
|
|
18
|
+
exp: string | undefined;
|
|
19
|
+
sig: string | undefined;
|
|
20
|
+
nowSeconds: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const authorizeContentRequest = (
|
|
24
|
+
input: AuthorizeContentInput,
|
|
25
|
+
):
|
|
26
|
+
| { authorized: true }
|
|
27
|
+
| { authorized: false; status: number; reason: string } => {
|
|
28
|
+
if (input.dataBackend !== "postgres" && input.dataBackend !== "sqlite") {
|
|
29
|
+
return { authorized: true };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!input.secret || input.secret.length === 0) {
|
|
33
|
+
// A self-host SQL backend with no signing secret means every content URL
|
|
34
|
+
// is unsigned and unverifiable. Fail closed rather than serve mail bytes
|
|
35
|
+
// without auth.
|
|
36
|
+
return {
|
|
37
|
+
authorized: false,
|
|
38
|
+
status: 500,
|
|
39
|
+
reason: "content-signing-not-configured",
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const result = verifyContentSignature(
|
|
44
|
+
input.relativePath,
|
|
45
|
+
input.exp,
|
|
46
|
+
input.sig,
|
|
47
|
+
input.secret,
|
|
48
|
+
input.nowSeconds,
|
|
49
|
+
);
|
|
50
|
+
if (result.valid) return { authorized: true };
|
|
51
|
+
|
|
52
|
+
const status = result.reason === "missing" ? 401 : 403;
|
|
53
|
+
return { authorized: false, status, reason: result.reason };
|
|
54
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { gzipSync } from "node:zlib";
|
|
4
|
+
import {
|
|
5
|
+
parseContentStorageKey,
|
|
6
|
+
type ServeContentDeps,
|
|
7
|
+
serveContent,
|
|
8
|
+
} from "./content-handler.js";
|
|
9
|
+
|
|
10
|
+
const PART_KEY = "accounts/cfg-1/acc-1/messages/msg-1/parts/1.2";
|
|
11
|
+
|
|
12
|
+
const baseDeps = (
|
|
13
|
+
over: Partial<ServeContentDeps> = {},
|
|
14
|
+
): { deps: ServeContentDeps; cues: Array<Record<string, unknown>> } => {
|
|
15
|
+
const cues: Array<Record<string, unknown>> = [];
|
|
16
|
+
const deps: ServeContentDeps = {
|
|
17
|
+
readObject: async () => null,
|
|
18
|
+
lookupMessage: async () => ({ mailboxId: "mbx-1", uid: 42 }),
|
|
19
|
+
requestBodySync: async (input) => {
|
|
20
|
+
cues.push(input);
|
|
21
|
+
},
|
|
22
|
+
...over,
|
|
23
|
+
};
|
|
24
|
+
return { deps, cues };
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
describe("parseContentStorageKey", () => {
|
|
28
|
+
it("extracts account + message ids from a part key", () => {
|
|
29
|
+
assert.deepEqual(parseContentStorageKey(PART_KEY), {
|
|
30
|
+
accountConfigId: "cfg-1",
|
|
31
|
+
accountId: "acc-1",
|
|
32
|
+
messageId: "msg-1",
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("returns null for a non-matching key", () => {
|
|
37
|
+
assert.equal(parseContentStorageKey("nope/whatever"), null);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("serveContent", () => {
|
|
42
|
+
it("returns 200 with decompressed bytes when the object is present", async () => {
|
|
43
|
+
const { deps, cues } = baseDeps({
|
|
44
|
+
readObject: async () => gzipSync(Buffer.from("hello body")),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const result = await serveContent(deps, {
|
|
48
|
+
fullPath: "/x/parts/1.2",
|
|
49
|
+
storageKey: PART_KEY,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
assert.equal(result.status, 200);
|
|
53
|
+
assert.equal(result.headers["content-type"], "application/octet-stream");
|
|
54
|
+
assert.equal(result.body.toString(), "hello body");
|
|
55
|
+
assert.equal(cues.length, 0);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("returns 202 + Retry-After and re-arms the cue when the object is missing", async () => {
|
|
59
|
+
const { deps, cues } = baseDeps({ readObject: async () => null });
|
|
60
|
+
|
|
61
|
+
const result = await serveContent(deps, {
|
|
62
|
+
fullPath: "/x/parts/1.2",
|
|
63
|
+
storageKey: PART_KEY,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
assert.equal(result.status, 202);
|
|
67
|
+
assert.equal(result.headers["Retry-After"], "1");
|
|
68
|
+
assert.equal(cues.length, 1, "cue re-armed exactly once");
|
|
69
|
+
assert.deepEqual(cues[0], {
|
|
70
|
+
accountId: "acc-1",
|
|
71
|
+
mailboxId: "mbx-1",
|
|
72
|
+
messageId: "msg-1",
|
|
73
|
+
uid: 42,
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("still answers 202 (no cue) when the message row is gone", async () => {
|
|
78
|
+
const { deps, cues } = baseDeps({
|
|
79
|
+
readObject: async () => null,
|
|
80
|
+
lookupMessage: async () => null,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const result = await serveContent(deps, {
|
|
84
|
+
fullPath: "/x/parts/1.2",
|
|
85
|
+
storageKey: PART_KEY,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
assert.equal(result.status, 202);
|
|
89
|
+
assert.equal(cues.length, 0);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("propagates a non-missing read error so the route 500s", async () => {
|
|
93
|
+
const { deps } = baseDeps({
|
|
94
|
+
readObject: async () => {
|
|
95
|
+
throw new Error("EACCES");
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
await assert.rejects(
|
|
100
|
+
() =>
|
|
101
|
+
serveContent(deps, { fullPath: "/x/parts/1.2", storageKey: PART_KEY }),
|
|
102
|
+
/EACCES/,
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { gunzipSync } from "node:zlib";
|
|
2
|
+
import { parseContentStorageKey } from "@remit/storage-service";
|
|
3
|
+
|
|
4
|
+
// Re-exported for existing same-package consumers (server.ts,
|
|
5
|
+
// content-handler.test.ts). The key-shape parser itself lives in
|
|
6
|
+
// `@remit/storage-service` next to the `build*Key` functions it
|
|
7
|
+
// inverts, so non-backend consumers (e.g. the content-selfheal worker) can
|
|
8
|
+
// depend on it without pulling in the rest of `@remit/backend`.
|
|
9
|
+
export { parseContentStorageKey };
|
|
10
|
+
|
|
11
|
+
export interface ContentResult {
|
|
12
|
+
status: number;
|
|
13
|
+
headers: Record<string, string>;
|
|
14
|
+
body: string | Buffer;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ServeContentDeps {
|
|
18
|
+
/**
|
|
19
|
+
* Reads the stored bytes, or returns null when the object is genuinely
|
|
20
|
+
* missing (ENOENT / NoSuchKey). Any other read error must throw so the route
|
|
21
|
+
* 500s — a permission/infra failure is never masked as a missing body.
|
|
22
|
+
*/
|
|
23
|
+
readObject(fullPath: string): Promise<Buffer | null>;
|
|
24
|
+
/** Resolves the message's mailbox + uid so the cue can issue one FETCH. */
|
|
25
|
+
lookupMessage(
|
|
26
|
+
messageId: string,
|
|
27
|
+
): Promise<{ mailboxId: string; uid: number } | null>;
|
|
28
|
+
requestBodySync(input: {
|
|
29
|
+
accountId: string;
|
|
30
|
+
mailboxId: string;
|
|
31
|
+
messageId: string;
|
|
32
|
+
uid?: number;
|
|
33
|
+
}): Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Local stand-in for the CloudFront `/content/*` behavior. Body-fetch outcomes:
|
|
38
|
+
*
|
|
39
|
+
* - Object present → 200 with the (decompressed) bytes.
|
|
40
|
+
* - Object missing → 202 + `Retry-After: 1`, and re-arm the SYNC_MESSAGE_BODY
|
|
41
|
+
* cue for that message so a retry succeeds once the worker stores the body.
|
|
42
|
+
* - Any other read failure throws, so the route 500s loudly.
|
|
43
|
+
*/
|
|
44
|
+
export const serveContent = async (
|
|
45
|
+
deps: ServeContentDeps,
|
|
46
|
+
args: { fullPath: string; storageKey: string },
|
|
47
|
+
): Promise<ContentResult> => {
|
|
48
|
+
const raw = await deps.readObject(args.fullPath);
|
|
49
|
+
|
|
50
|
+
if (raw) {
|
|
51
|
+
// The filesystem/S3 backends gzip every part; decompress here since there
|
|
52
|
+
// is no CloudFront to auto-decode locally.
|
|
53
|
+
const buffer = raw[0] === 0x1f && raw[1] === 0x8b ? gunzipSync(raw) : raw;
|
|
54
|
+
return {
|
|
55
|
+
status: 200,
|
|
56
|
+
headers: { "content-type": "application/octet-stream" },
|
|
57
|
+
body: buffer,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const ids = parseContentStorageKey(args.storageKey);
|
|
62
|
+
if (ids) {
|
|
63
|
+
const message = await deps.lookupMessage(ids.messageId);
|
|
64
|
+
if (message) {
|
|
65
|
+
await deps.requestBodySync({
|
|
66
|
+
accountId: ids.accountId,
|
|
67
|
+
mailboxId: message.mailboxId,
|
|
68
|
+
messageId: ids.messageId,
|
|
69
|
+
uid: message.uid,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
status: 202,
|
|
76
|
+
headers: { "Retry-After": "1" },
|
|
77
|
+
body: "body not yet synced",
|
|
78
|
+
};
|
|
79
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { sep } from "node:path";
|
|
3
|
+
import { describe, it } from "node:test";
|
|
4
|
+
import { resolveContentPath } from "./content-path.js";
|
|
5
|
+
|
|
6
|
+
describe("resolveContentPath — dev-server /content/* path safety (#310 review P1)", () => {
|
|
7
|
+
const ROOT = "/x/storage";
|
|
8
|
+
|
|
9
|
+
it("returns the absolute path for a normal nested storage key", () => {
|
|
10
|
+
assert.equal(
|
|
11
|
+
resolveContentPath(ROOT, "accounts/cfg/acc/messages/m/parts/1"),
|
|
12
|
+
"/x/storage/accounts/cfg/acc/messages/m/parts/1",
|
|
13
|
+
);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("rejects a sibling-prefix escape (`startsWith` was the original bug)", () => {
|
|
17
|
+
// `/x/storage-evil/y` shares the prefix `/x/storage` with the root,
|
|
18
|
+
// so the naive `fullPath.startsWith(ROOT)` check would pass it.
|
|
19
|
+
assert.equal(resolveContentPath(ROOT, `..${sep}storage-evil${sep}y`), null);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("rejects parent-directory traversal via `..`", () => {
|
|
23
|
+
assert.equal(resolveContentPath(ROOT, `..${sep}etc${sep}passwd`), null);
|
|
24
|
+
assert.equal(
|
|
25
|
+
resolveContentPath(ROOT, `accounts${sep}..${sep}..${sep}etc`),
|
|
26
|
+
null,
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("rejects an absolute key that resolves outside the root", () => {
|
|
31
|
+
assert.equal(resolveContentPath(ROOT, "/etc/passwd"), null);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("rejects an empty key (would expose the root listing)", () => {
|
|
35
|
+
assert.equal(resolveContentPath(ROOT, ""), null);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { relative, resolve, sep } from "node:path";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolve a `/content/*` URL path to an absolute filesystem path under the
|
|
5
|
+
* storage root, or return null if the resolved path would escape the root.
|
|
6
|
+
*
|
|
7
|
+
* The naive `fullPath.startsWith(STORAGE_BASE)` check fails on a sibling
|
|
8
|
+
* directory whose name is a prefix of the base — e.g. `/x/storage` and
|
|
9
|
+
* `/x/storage-evil` — so we use `path.relative` and reject any result that
|
|
10
|
+
* starts with `..` or is itself absolute (#310 review P1). Empty keys are
|
|
11
|
+
* also rejected so the root directory is never served.
|
|
12
|
+
*/
|
|
13
|
+
export const resolveContentPath = (
|
|
14
|
+
storageBase: string,
|
|
15
|
+
storageKey: string,
|
|
16
|
+
): string | null => {
|
|
17
|
+
const fullPath = resolve(storageBase, storageKey);
|
|
18
|
+
const rel = relative(storageBase, fullPath);
|
|
19
|
+
if (rel === "" || rel.startsWith("..") || rel.includes(`..${sep}`)) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
if (resolve(rel) === rel) {
|
|
23
|
+
// `rel` is absolute → escaped the root via an absolute storageKey.
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
return fullPath;
|
|
27
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { parseAllowedOrigins, resolveAllowOrigin } from "./cors.js";
|
|
4
|
+
|
|
5
|
+
describe("parseAllowedOrigins", () => {
|
|
6
|
+
it("splits, trims, and drops empties", () => {
|
|
7
|
+
assert.deepEqual(
|
|
8
|
+
parseAllowedOrigins("https://a.example, https://b.example ,"),
|
|
9
|
+
["https://a.example", "https://b.example"],
|
|
10
|
+
);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("returns an empty list for undefined or blank", () => {
|
|
14
|
+
assert.deepEqual(parseAllowedOrigins(undefined), []);
|
|
15
|
+
assert.deepEqual(parseAllowedOrigins(" "), []);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("resolveAllowOrigin", () => {
|
|
20
|
+
it("returns '*' when the allowlist contains a wildcard", () => {
|
|
21
|
+
assert.equal(resolveAllowOrigin("https://any.example", ["*"]), "*");
|
|
22
|
+
assert.equal(resolveAllowOrigin(undefined, ["*"]), "*");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("reflects an allowlisted origin", () => {
|
|
26
|
+
assert.equal(
|
|
27
|
+
resolveAllowOrigin("https://app.example", [
|
|
28
|
+
"https://app.example",
|
|
29
|
+
"https://other.example",
|
|
30
|
+
]),
|
|
31
|
+
"https://app.example",
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("returns undefined for an origin outside the allowlist", () => {
|
|
36
|
+
assert.equal(
|
|
37
|
+
resolveAllowOrigin("https://evil.example", ["https://app.example"]),
|
|
38
|
+
undefined,
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("returns undefined when no origin is presented and no wildcard", () => {
|
|
43
|
+
assert.equal(
|
|
44
|
+
resolveAllowOrigin(undefined, ["https://app.example"]),
|
|
45
|
+
undefined,
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export const parseAllowedOrigins = (raw: string | undefined): string[] =>
|
|
2
|
+
(raw ?? "")
|
|
3
|
+
.split(",")
|
|
4
|
+
.map((origin) => origin.trim())
|
|
5
|
+
.filter((origin) => origin.length > 0);
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Resolve the `Access-Control-Allow-Origin` value for a request against the
|
|
9
|
+
* configured allowlist.
|
|
10
|
+
*
|
|
11
|
+
* - a `*` entry allows any origin (browsers reject `*` with credentials, so this
|
|
12
|
+
* stays safe for a bearer-token API);
|
|
13
|
+
* - otherwise the request `Origin` is reflected only when it is in the allowlist;
|
|
14
|
+
* - an origin outside the allowlist yields `undefined` — no CORS header is sent.
|
|
15
|
+
*/
|
|
16
|
+
export const resolveAllowOrigin = (
|
|
17
|
+
requestOrigin: string | undefined,
|
|
18
|
+
allowedOrigins: string[],
|
|
19
|
+
): string | undefined => {
|
|
20
|
+
if (allowedOrigins.includes("*")) return "*";
|
|
21
|
+
if (requestOrigin && allowedOrigins.includes(requestOrigin)) {
|
|
22
|
+
return requestOrigin;
|
|
23
|
+
}
|
|
24
|
+
return undefined;
|
|
25
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
APIGatewayProxyEvent,
|
|
3
|
+
APIGatewayProxyEventPathParameters,
|
|
4
|
+
Context,
|
|
5
|
+
} from "aws-lambda";
|
|
6
|
+
import type { Request } from "express";
|
|
7
|
+
|
|
8
|
+
export const createLambdaEvent = (req: Request): APIGatewayProxyEvent => ({
|
|
9
|
+
httpMethod: req.method,
|
|
10
|
+
path: req.path,
|
|
11
|
+
pathParameters: (req.params as APIGatewayProxyEventPathParameters) || null,
|
|
12
|
+
queryStringParameters: (req.query as { [name: string]: string }) || null,
|
|
13
|
+
headers: (req.headers as { [name: string]: string }) || {},
|
|
14
|
+
body: req.body ? JSON.stringify(req.body) : null,
|
|
15
|
+
isBase64Encoded: false,
|
|
16
|
+
requestContext: {
|
|
17
|
+
requestId: `local-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
18
|
+
stage: "local",
|
|
19
|
+
httpMethod: req.method,
|
|
20
|
+
path: req.path,
|
|
21
|
+
protocol: "HTTP/1.1",
|
|
22
|
+
requestTime: new Date().toISOString(),
|
|
23
|
+
requestTimeEpoch: Date.now(),
|
|
24
|
+
identity: {
|
|
25
|
+
sourceIp: req.ip || "127.0.0.1",
|
|
26
|
+
userAgent: req.get("User-Agent") || "local-test",
|
|
27
|
+
accessKey: null,
|
|
28
|
+
accountId: null,
|
|
29
|
+
apiKey: null,
|
|
30
|
+
apiKeyId: null,
|
|
31
|
+
caller: null,
|
|
32
|
+
cognitoAuthenticationProvider: null,
|
|
33
|
+
cognitoAuthenticationType: null,
|
|
34
|
+
cognitoIdentityId: null,
|
|
35
|
+
cognitoIdentityPoolId: null,
|
|
36
|
+
principalOrgId: null,
|
|
37
|
+
user: null,
|
|
38
|
+
userArn: null,
|
|
39
|
+
clientCert: null,
|
|
40
|
+
},
|
|
41
|
+
accountId: "123456789012",
|
|
42
|
+
apiId: "local",
|
|
43
|
+
domainName: "localhost",
|
|
44
|
+
domainPrefix: "local",
|
|
45
|
+
resourceId: "local",
|
|
46
|
+
resourcePath: req.path,
|
|
47
|
+
authorizer: undefined,
|
|
48
|
+
},
|
|
49
|
+
resource: req.path,
|
|
50
|
+
multiValueHeaders: {},
|
|
51
|
+
multiValueQueryStringParameters: null,
|
|
52
|
+
stageVariables: null,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
export const createLambdaContext = (): Context => ({
|
|
56
|
+
callbackWaitsForEmptyEventLoop: false,
|
|
57
|
+
functionName: "remit-backend-local",
|
|
58
|
+
functionVersion: "$LATEST",
|
|
59
|
+
invokedFunctionArn:
|
|
60
|
+
"arn:aws:lambda:local:123456789012:function:remit-backend-local",
|
|
61
|
+
memoryLimitInMB: "128",
|
|
62
|
+
awsRequestId: `local-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
63
|
+
logGroupName: "/aws/lambda/remit-backend-local",
|
|
64
|
+
logStreamName: `${new Date().toISOString().split("T")[0]}/[$LATEST]${Math.random().toString(36).substr(2, 9)}`,
|
|
65
|
+
getRemainingTimeInMillis: () => 30000,
|
|
66
|
+
done: () => {},
|
|
67
|
+
fail: () => {},
|
|
68
|
+
succeed: () => {},
|
|
69
|
+
});
|