alvin-bot 4.12.1 → 4.12.2
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/CHANGELOG.md +81 -0
- package/README.md +42 -5
- package/dist/index.js +75 -3
- package/dist/providers/claude-sdk-provider.js +4 -1
- package/dist/services/allowed-users-gate.js +56 -0
- package/dist/services/cron.js +17 -0
- package/dist/services/exec-guard.js +26 -1
- package/dist/services/fallback-order.js +4 -1
- package/dist/services/file-permissions.js +93 -0
- package/dist/services/session-persistence.js +14 -2
- package/dist/services/subagents.js +23 -5
- package/dist/services/timing-safe-bearer.js +51 -0
- package/dist/web/doctor-api.js +8 -2
- package/dist/web/server.js +7 -3
- package/dist/web/setup-api.js +5 -2
- package/docs/security.md +279 -0
- package/package.json +4 -1
- package/test/allowed-users-gate.test.ts +98 -0
- package/test/exec-guard-metachars.test.ts +110 -0
- package/test/file-permissions.test.ts +130 -0
- package/test/subagent-toolset-allowlist.test.ts +146 -0
- package/test/subagents-toolset.test.ts +22 -2
- package/test/timing-safe-bearer.test.ts +65 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v4.12.2 — File permissions hardening.
|
|
3
|
+
*
|
|
4
|
+
* Sensitive files (.env, sessions.json, memory files) must be chmod 0o600
|
|
5
|
+
* so that on multi-user Dev-Server installations, other users on the same
|
|
6
|
+
* machine can't read Alvin's secrets or conversation history.
|
|
7
|
+
*
|
|
8
|
+
* This module provides pure helpers for ensuring files get 0o600 on write,
|
|
9
|
+
* plus a startup repair routine that fixes permissions on existing files.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
12
|
+
import fs from "fs";
|
|
13
|
+
import os from "os";
|
|
14
|
+
import { resolve } from "path";
|
|
15
|
+
import { ensureSecureMode, writeSecure, auditSensitiveFiles } from "../src/services/file-permissions.js";
|
|
16
|
+
|
|
17
|
+
const TEST_DIR = resolve(os.tmpdir(), `alvin-fileperm-${process.pid}-${Date.now()}`);
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true, force: true });
|
|
21
|
+
fs.mkdirSync(TEST_DIR, { recursive: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
try { fs.rmSync(TEST_DIR, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("file-permissions (v4.12.2)", () => {
|
|
29
|
+
describe("writeSecure", () => {
|
|
30
|
+
it("creates a file with mode 0o600", () => {
|
|
31
|
+
const file = resolve(TEST_DIR, "secret.txt");
|
|
32
|
+
writeSecure(file, "sensitive content");
|
|
33
|
+
const mode = fs.statSync(file).mode & 0o777;
|
|
34
|
+
expect(mode).toBe(0o600);
|
|
35
|
+
expect(fs.readFileSync(file, "utf-8")).toBe("sensitive content");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("overwrites an existing file and enforces mode 0o600 even if it was 0o644", () => {
|
|
39
|
+
const file = resolve(TEST_DIR, "existing.txt");
|
|
40
|
+
fs.writeFileSync(file, "old content", "utf-8");
|
|
41
|
+
fs.chmodSync(file, 0o644);
|
|
42
|
+
|
|
43
|
+
writeSecure(file, "new content");
|
|
44
|
+
|
|
45
|
+
const mode = fs.statSync(file).mode & 0o777;
|
|
46
|
+
expect(mode).toBe(0o600);
|
|
47
|
+
expect(fs.readFileSync(file, "utf-8")).toBe("new content");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("accepts Buffer content", () => {
|
|
51
|
+
const file = resolve(TEST_DIR, "buf.bin");
|
|
52
|
+
writeSecure(file, Buffer.from([1, 2, 3]));
|
|
53
|
+
expect(fs.statSync(file).mode & 0o777).toBe(0o600);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("ensureSecureMode", () => {
|
|
58
|
+
it("returns 'already-secure' when file is already 0o600", () => {
|
|
59
|
+
const file = resolve(TEST_DIR, "f.txt");
|
|
60
|
+
fs.writeFileSync(file, "x");
|
|
61
|
+
fs.chmodSync(file, 0o600);
|
|
62
|
+
const result = ensureSecureMode(file);
|
|
63
|
+
expect(result.status).toBe("already-secure");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("repairs a file that is too permissive (0o644 → 0o600)", () => {
|
|
67
|
+
const file = resolve(TEST_DIR, "f.txt");
|
|
68
|
+
fs.writeFileSync(file, "x");
|
|
69
|
+
fs.chmodSync(file, 0o644);
|
|
70
|
+
const result = ensureSecureMode(file);
|
|
71
|
+
expect(result.status).toBe("repaired");
|
|
72
|
+
expect(result.previousMode).toBe("644");
|
|
73
|
+
expect(fs.statSync(file).mode & 0o777).toBe(0o600);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("returns 'missing' for a nonexistent file without erroring", () => {
|
|
77
|
+
const result = ensureSecureMode(resolve(TEST_DIR, "nope.txt"));
|
|
78
|
+
expect(result.status).toBe("missing");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("is idempotent: calling twice on a 0o644 file still ends at 0o600", () => {
|
|
82
|
+
const file = resolve(TEST_DIR, "f.txt");
|
|
83
|
+
fs.writeFileSync(file, "x");
|
|
84
|
+
fs.chmodSync(file, 0o644);
|
|
85
|
+
ensureSecureMode(file);
|
|
86
|
+
const second = ensureSecureMode(file);
|
|
87
|
+
expect(second.status).toBe("already-secure");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("does NOT try to loosen a stricter-than-needed mode (e.g. 0o400)", () => {
|
|
91
|
+
const file = resolve(TEST_DIR, "f.txt");
|
|
92
|
+
fs.writeFileSync(file, "x");
|
|
93
|
+
fs.chmodSync(file, 0o400);
|
|
94
|
+
const result = ensureSecureMode(file);
|
|
95
|
+
expect(result.status).toBe("already-secure");
|
|
96
|
+
expect(fs.statSync(file).mode & 0o777).toBe(0o400);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("auditSensitiveFiles", () => {
|
|
101
|
+
it("reports a list of files checked and their status", () => {
|
|
102
|
+
const envFile = resolve(TEST_DIR, ".env");
|
|
103
|
+
const stateFile = resolve(TEST_DIR, "sessions.json");
|
|
104
|
+
fs.writeFileSync(envFile, "SECRET=1");
|
|
105
|
+
fs.chmodSync(envFile, 0o644); // insecure
|
|
106
|
+
fs.writeFileSync(stateFile, "{}");
|
|
107
|
+
fs.chmodSync(stateFile, 0o600); // secure
|
|
108
|
+
|
|
109
|
+
const report = auditSensitiveFiles([envFile, stateFile]);
|
|
110
|
+
expect(report).toHaveLength(2);
|
|
111
|
+
|
|
112
|
+
const env = report.find(r => r.path === envFile);
|
|
113
|
+
const state = report.find(r => r.path === stateFile);
|
|
114
|
+
expect(env!.status).toBe("repaired");
|
|
115
|
+
expect(state!.status).toBe("already-secure");
|
|
116
|
+
|
|
117
|
+
expect(fs.statSync(envFile).mode & 0o777).toBe(0o600);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("skips nonexistent files gracefully", () => {
|
|
121
|
+
const report = auditSensitiveFiles([
|
|
122
|
+
resolve(TEST_DIR, "nope.env"),
|
|
123
|
+
resolve(TEST_DIR, "also-nope.json"),
|
|
124
|
+
]);
|
|
125
|
+
expect(report).toHaveLength(2);
|
|
126
|
+
expect(report[0].status).toBe("missing");
|
|
127
|
+
expect(report[1].status).toBe("missing");
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v4.12.2 — Sub-agent toolset allowlist (Task G).
|
|
3
|
+
*
|
|
4
|
+
* Sub-agents can now be spawned with a toolset preset that restricts which
|
|
5
|
+
* tools Claude has access to:
|
|
6
|
+
* - "full" — all tools (default, matches pre-v4.12.2 behavior)
|
|
7
|
+
* - "readonly" — Read, Glob, Grep (analyze, no write, no shell, no net)
|
|
8
|
+
* - "research" — Read, Glob, Grep, WebSearch, WebFetch (no write, no shell)
|
|
9
|
+
*
|
|
10
|
+
* This test verifies that the preset → allowedTools mapping is correct
|
|
11
|
+
* and that the provider honors the override. The integration path
|
|
12
|
+
* (spawnSubAgent → registry.queryWithFallback → claude-sdk-provider) is
|
|
13
|
+
* exercised via mocked SDK.
|
|
14
|
+
*/
|
|
15
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
16
|
+
import type { StreamChunk } from "../src/providers/types.js";
|
|
17
|
+
|
|
18
|
+
beforeEach(() => vi.resetModules());
|
|
19
|
+
|
|
20
|
+
describe("claude-sdk-provider honors options.allowedTools (v4.12.2)", () => {
|
|
21
|
+
it("uses the default full toolset when options.allowedTools is undefined", async () => {
|
|
22
|
+
let capturedOpts: Record<string, unknown> | undefined;
|
|
23
|
+
vi.doMock("../src/find-claude-binary.js", () => ({
|
|
24
|
+
findClaudeBinary: () => "/usr/bin/false",
|
|
25
|
+
}));
|
|
26
|
+
vi.doMock("@anthropic-ai/claude-agent-sdk", () => ({
|
|
27
|
+
query: (opts: { options: Record<string, unknown> }) => {
|
|
28
|
+
capturedOpts = opts.options;
|
|
29
|
+
return (async function* () {
|
|
30
|
+
yield { type: "system", subtype: "init", session_id: "s1" };
|
|
31
|
+
yield { type: "result", session_id: "s1", total_cost_usd: 0, usage: null };
|
|
32
|
+
})();
|
|
33
|
+
},
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
const { ClaudeSDKProvider } = await import("../src/providers/claude-sdk-provider.js");
|
|
37
|
+
const provider = new ClaudeSDKProvider();
|
|
38
|
+
|
|
39
|
+
for await (const _c of provider.query({ prompt: "test", systemPrompt: "test" })) {
|
|
40
|
+
void _c;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
expect(capturedOpts).toBeDefined();
|
|
44
|
+
expect(capturedOpts!.allowedTools).toEqual([
|
|
45
|
+
"Read", "Write", "Edit", "Bash", "Glob", "Grep",
|
|
46
|
+
"WebSearch", "WebFetch", "Task",
|
|
47
|
+
]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("overrides allowedTools when caller passes a restricted list (readonly preset)", async () => {
|
|
51
|
+
let capturedOpts: Record<string, unknown> | undefined;
|
|
52
|
+
vi.doMock("../src/find-claude-binary.js", () => ({
|
|
53
|
+
findClaudeBinary: () => "/usr/bin/false",
|
|
54
|
+
}));
|
|
55
|
+
vi.doMock("@anthropic-ai/claude-agent-sdk", () => ({
|
|
56
|
+
query: (opts: { options: Record<string, unknown> }) => {
|
|
57
|
+
capturedOpts = opts.options;
|
|
58
|
+
return (async function* () {
|
|
59
|
+
yield { type: "system", subtype: "init", session_id: "s1" };
|
|
60
|
+
yield { type: "result", session_id: "s1", total_cost_usd: 0, usage: null };
|
|
61
|
+
})();
|
|
62
|
+
},
|
|
63
|
+
}));
|
|
64
|
+
|
|
65
|
+
const { ClaudeSDKProvider } = await import("../src/providers/claude-sdk-provider.js");
|
|
66
|
+
const provider = new ClaudeSDKProvider();
|
|
67
|
+
|
|
68
|
+
const readonlyTools = ["Read", "Glob", "Grep"];
|
|
69
|
+
for await (const _c of provider.query({
|
|
70
|
+
prompt: "test",
|
|
71
|
+
systemPrompt: "test",
|
|
72
|
+
allowedTools: readonlyTools,
|
|
73
|
+
})) {
|
|
74
|
+
void _c;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
expect(capturedOpts!.allowedTools).toEqual(readonlyTools);
|
|
78
|
+
// Critically: Bash, Write, Edit are NOT in the list
|
|
79
|
+
expect(capturedOpts!.allowedTools).not.toContain("Bash");
|
|
80
|
+
expect(capturedOpts!.allowedTools).not.toContain("Write");
|
|
81
|
+
expect(capturedOpts!.allowedTools).not.toContain("Edit");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("overrides allowedTools with research preset (adds web tools)", async () => {
|
|
85
|
+
let capturedOpts: Record<string, unknown> | undefined;
|
|
86
|
+
vi.doMock("../src/find-claude-binary.js", () => ({
|
|
87
|
+
findClaudeBinary: () => "/usr/bin/false",
|
|
88
|
+
}));
|
|
89
|
+
vi.doMock("@anthropic-ai/claude-agent-sdk", () => ({
|
|
90
|
+
query: (opts: { options: Record<string, unknown> }) => {
|
|
91
|
+
capturedOpts = opts.options;
|
|
92
|
+
return (async function* () {
|
|
93
|
+
yield { type: "system", subtype: "init", session_id: "s1" };
|
|
94
|
+
yield { type: "result", session_id: "s1", total_cost_usd: 0, usage: null };
|
|
95
|
+
})();
|
|
96
|
+
},
|
|
97
|
+
}));
|
|
98
|
+
|
|
99
|
+
const { ClaudeSDKProvider } = await import("../src/providers/claude-sdk-provider.js");
|
|
100
|
+
const provider = new ClaudeSDKProvider();
|
|
101
|
+
|
|
102
|
+
const researchTools = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"];
|
|
103
|
+
for await (const _c of provider.query({
|
|
104
|
+
prompt: "test",
|
|
105
|
+
systemPrompt: "test",
|
|
106
|
+
allowedTools: researchTools,
|
|
107
|
+
})) {
|
|
108
|
+
void _c;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
expect(capturedOpts!.allowedTools).toEqual(researchTools);
|
|
112
|
+
expect(capturedOpts!.allowedTools).toContain("WebSearch");
|
|
113
|
+
expect(capturedOpts!.allowedTools).not.toContain("Bash");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("empty allowedTools array is honored as such (no tools at all)", async () => {
|
|
117
|
+
let capturedOpts: Record<string, unknown> | undefined;
|
|
118
|
+
vi.doMock("../src/find-claude-binary.js", () => ({
|
|
119
|
+
findClaudeBinary: () => "/usr/bin/false",
|
|
120
|
+
}));
|
|
121
|
+
vi.doMock("@anthropic-ai/claude-agent-sdk", () => ({
|
|
122
|
+
query: (opts: { options: Record<string, unknown> }) => {
|
|
123
|
+
capturedOpts = opts.options;
|
|
124
|
+
return (async function* () {
|
|
125
|
+
yield { type: "system", subtype: "init", session_id: "s1" };
|
|
126
|
+
yield { type: "result", session_id: "s1", total_cost_usd: 0, usage: null };
|
|
127
|
+
})();
|
|
128
|
+
},
|
|
129
|
+
}));
|
|
130
|
+
|
|
131
|
+
const { ClaudeSDKProvider } = await import("../src/providers/claude-sdk-provider.js");
|
|
132
|
+
const provider = new ClaudeSDKProvider();
|
|
133
|
+
|
|
134
|
+
for await (const _c of provider.query({
|
|
135
|
+
prompt: "test",
|
|
136
|
+
systemPrompt: "test",
|
|
137
|
+
allowedTools: [],
|
|
138
|
+
})) {
|
|
139
|
+
void _c;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Empty array → no tools. Note: JS ?? operator treats [] as truthy,
|
|
143
|
+
// so this IS honored as "empty allowlist" not "use default".
|
|
144
|
+
expect(capturedOpts!.allowedTools).toEqual([]);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -21,7 +21,7 @@ vi.mock("../src/engine.js", () => ({
|
|
|
21
21
|
}),
|
|
22
22
|
}));
|
|
23
23
|
|
|
24
|
-
describe("sub-agents toolset (G1)", () => {
|
|
24
|
+
describe("sub-agents toolset (G1, extended v4.12.2)", () => {
|
|
25
25
|
it("accepts toolset='full'", async () => {
|
|
26
26
|
const mod = await import("../src/services/subagents.js");
|
|
27
27
|
const id = await mod.spawnSubAgent({
|
|
@@ -38,13 +38,33 @@ describe("sub-agents toolset (G1)", () => {
|
|
|
38
38
|
expect(typeof id).toBe("string");
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
+
it("accepts toolset='readonly' (v4.12.2 — read-only sub-agents)", async () => {
|
|
42
|
+
const mod = await import("../src/services/subagents.js");
|
|
43
|
+
const id = await mod.spawnSubAgent({
|
|
44
|
+
name: "tool-readonly",
|
|
45
|
+
prompt: "hi",
|
|
46
|
+
toolset: "readonly",
|
|
47
|
+
});
|
|
48
|
+
expect(typeof id).toBe("string");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("accepts toolset='research' (v4.12.2 — readonly + web)", async () => {
|
|
52
|
+
const mod = await import("../src/services/subagents.js");
|
|
53
|
+
const id = await mod.spawnSubAgent({
|
|
54
|
+
name: "tool-research",
|
|
55
|
+
prompt: "hi",
|
|
56
|
+
toolset: "research",
|
|
57
|
+
});
|
|
58
|
+
expect(typeof id).toBe("string");
|
|
59
|
+
});
|
|
60
|
+
|
|
41
61
|
it("rejects unknown toolset values at runtime", async () => {
|
|
42
62
|
const mod = await import("../src/services/subagents.js");
|
|
43
63
|
await expect(
|
|
44
64
|
mod.spawnSubAgent({
|
|
45
65
|
name: "tool-bogus",
|
|
46
66
|
prompt: "hi",
|
|
47
|
-
toolset: "
|
|
67
|
+
toolset: "nonsense-preset" as unknown as "full",
|
|
48
68
|
}),
|
|
49
69
|
).rejects.toThrow(/toolset/i);
|
|
50
70
|
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v4.12.2 — Timing-safe bearer token comparison.
|
|
3
|
+
*
|
|
4
|
+
* The webhook auth check at src/web/server.ts:127 previously used naive
|
|
5
|
+
* string equality on the Authorization header. That's vulnerable (in
|
|
6
|
+
* principle) to timing side-channel attacks where an attacker measures
|
|
7
|
+
* response times to leak the token character by character.
|
|
8
|
+
*
|
|
9
|
+
* Real-world exploitability over network is low due to jitter, but
|
|
10
|
+
* crypto.timingSafeEqual is the right tool regardless.
|
|
11
|
+
*
|
|
12
|
+
* This test covers the pure helper; the integration is in server.ts.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, it, expect } from "vitest";
|
|
15
|
+
import { timingSafeBearerMatch } from "../src/services/timing-safe-bearer.js";
|
|
16
|
+
|
|
17
|
+
describe("timing-safe bearer token comparison (v4.12.2)", () => {
|
|
18
|
+
it("matches a correct token", () => {
|
|
19
|
+
expect(timingSafeBearerMatch("Bearer abc123xyz", "abc123xyz")).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("rejects an incorrect token", () => {
|
|
23
|
+
expect(timingSafeBearerMatch("Bearer wrong", "abc123xyz")).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("rejects when Bearer prefix is missing", () => {
|
|
27
|
+
expect(timingSafeBearerMatch("abc123xyz", "abc123xyz")).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("rejects when auth header is empty", () => {
|
|
31
|
+
expect(timingSafeBearerMatch("", "abc123xyz")).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("rejects when auth header is undefined", () => {
|
|
35
|
+
expect(timingSafeBearerMatch(undefined, "abc123xyz")).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("rejects when expected token is empty (prevents accidental auth bypass)", () => {
|
|
39
|
+
expect(timingSafeBearerMatch("Bearer anything", "")).toBe(false);
|
|
40
|
+
expect(timingSafeBearerMatch("Bearer ", "")).toBe(false);
|
|
41
|
+
expect(timingSafeBearerMatch("", "")).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("rejects tokens of different lengths without revealing prefix match", () => {
|
|
45
|
+
expect(timingSafeBearerMatch("Bearer abc", "abcdefg")).toBe(false);
|
|
46
|
+
expect(timingSafeBearerMatch("Bearer abcdefg", "abc")).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("handles unicode tokens (not that we'd use them, but correctness)", () => {
|
|
50
|
+
expect(timingSafeBearerMatch("Bearer 🔒xyz", "🔒xyz")).toBe(true);
|
|
51
|
+
expect(timingSafeBearerMatch("Bearer 🔒xyz", "🔒xYz")).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("case-sensitive comparison (tokens are opaque)", () => {
|
|
55
|
+
expect(timingSafeBearerMatch("Bearer AbCdEf", "abcdef")).toBe(false);
|
|
56
|
+
expect(timingSafeBearerMatch("Bearer AbCdEf", "AbCdEf")).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("rejects Bearer with leading/trailing whitespace mismatches the expected format", () => {
|
|
60
|
+
// RFC 6750 says: Authorization: Bearer <token>
|
|
61
|
+
// Exactly one space between "Bearer" and the token.
|
|
62
|
+
expect(timingSafeBearerMatch("Bearer abc", "abc")).toBe(false); // double space
|
|
63
|
+
expect(timingSafeBearerMatch(" Bearer abc", "abc")).toBe(false); // leading space
|
|
64
|
+
});
|
|
65
|
+
});
|