fix: harden server reliability and OAuth security
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { createServer } from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { loadConfig } from "../src/config.js";
|
||||
import { startHttpServer } from "../src/http-server.js";
|
||||
import { createServices } from "../src/mcp-server.js";
|
||||
import { RemoteDevOAuthProvider } from "../src/oauth.js";
|
||||
|
||||
async function reservePort(): Promise<number> {
|
||||
const server = createServer();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const port = (server.address() as AddressInfo).port;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
return port;
|
||||
}
|
||||
|
||||
describe("OAuth endpoint security boundaries", () => {
|
||||
it("does not trust spoofed forwarded IPs unless a proxy is explicitly configured", async () => {
|
||||
const temporaryDirectory = await mkdtemp(
|
||||
path.join(os.tmpdir(), "cokacremote-auth-boundary-test-"),
|
||||
);
|
||||
const port = await reservePort();
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
const config = loadConfig(
|
||||
{
|
||||
MCP_OAUTH_ENABLED: "true",
|
||||
MCP_OAUTH_APPROVAL_KEY: "oauth-approval-key",
|
||||
MCP_PUBLIC_URL: baseUrl,
|
||||
MCP_OAUTH_STATE_FILE: path.join(temporaryDirectory, "oauth-state.json"),
|
||||
MCP_HOST: "127.0.0.1",
|
||||
MCP_PORT: String(port),
|
||||
MCP_DEFAULT_CWD: temporaryDirectory,
|
||||
},
|
||||
temporaryDirectory,
|
||||
);
|
||||
const running = await startHttpServer(config, createServices(config));
|
||||
|
||||
try {
|
||||
const approvalKeyAsBearer = await fetch(`${baseUrl}/mcp`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: "Bearer oauth-approval-key",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }),
|
||||
});
|
||||
expect(approvalKeyAsBearer.status).toBe(401);
|
||||
|
||||
const statuses: number[] = [];
|
||||
for (let index = 1; index <= 21; index += 1) {
|
||||
const response = await fetch(`${baseUrl}/register`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-forwarded-for": `203.0.113.${index}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
redirect_uris: ["https://chatgpt.com/connector/oauth/security-test"],
|
||||
token_endpoint_auth_method: "none",
|
||||
grant_types: ["authorization_code", "refresh_token"],
|
||||
response_types: ["code"],
|
||||
client_name: `security-test-${index}`,
|
||||
scope: "mcp:tools",
|
||||
}),
|
||||
});
|
||||
statuses.push(response.status);
|
||||
}
|
||||
|
||||
expect(statuses.slice(0, 20)).toEqual(Array(20).fill(201));
|
||||
expect(statuses[20]).toBe(429);
|
||||
} finally {
|
||||
await running.close();
|
||||
await rm(temporaryDirectory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rolls back failed state writes and revokes an entire token grant", async () => {
|
||||
const temporaryDirectory = await mkdtemp(
|
||||
path.join(os.tmpdir(), "cokacremote-oauth-store-test-"),
|
||||
);
|
||||
const baseEnvironment = {
|
||||
MCP_OAUTH_ENABLED: "true",
|
||||
MCP_OAUTH_APPROVAL_KEY: "oauth-approval-key",
|
||||
MCP_PUBLIC_URL: "http://127.0.0.1:34567",
|
||||
};
|
||||
const metadata = {
|
||||
client_id: "security-store-client",
|
||||
redirect_uris: ["https://chatgpt.com/connector/oauth/security-store-test"],
|
||||
token_endpoint_auth_method: "none",
|
||||
grant_types: ["authorization_code", "refresh_token"],
|
||||
response_types: ["code"],
|
||||
client_name: "security store test",
|
||||
scope: "mcp:tools",
|
||||
};
|
||||
|
||||
try {
|
||||
const blockedDirectory = path.join(temporaryDirectory, "blocked-state-directory");
|
||||
await mkdir(blockedDirectory);
|
||||
const failedProvider = new RemoteDevOAuthProvider(
|
||||
loadConfig(
|
||||
{
|
||||
...baseEnvironment,
|
||||
MCP_OAUTH_STATE_FILE: path.join(blockedDirectory, "state.json"),
|
||||
},
|
||||
temporaryDirectory,
|
||||
),
|
||||
);
|
||||
await expect(
|
||||
failedProvider.clientsStore.getClient(metadata.client_id),
|
||||
).resolves.toBeUndefined();
|
||||
await rm(blockedDirectory, { recursive: true, force: true });
|
||||
await writeFile(blockedDirectory, "block state persistence");
|
||||
await expect(
|
||||
failedProvider.clientsStore.registerClient(
|
||||
metadata as Parameters<typeof failedProvider.clientsStore.registerClient>[0],
|
||||
),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
failedProvider.clientsStore.getClient(metadata.client_id),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
const stateFile = path.join(temporaryDirectory, "valid-state.json");
|
||||
const provider = new RemoteDevOAuthProvider(
|
||||
loadConfig(
|
||||
{ ...baseEnvironment, MCP_OAUTH_STATE_FILE: stateFile },
|
||||
temporaryDirectory,
|
||||
),
|
||||
);
|
||||
const client = await provider.clientsStore.registerClient(
|
||||
metadata as Parameters<typeof provider.clientsStore.registerClient>[0],
|
||||
);
|
||||
const resource = "http://127.0.0.1:34567/mcp";
|
||||
const pair = await provider.clientsStore.issueTokenPair(
|
||||
client.client_id,
|
||||
["mcp:tools"],
|
||||
resource,
|
||||
);
|
||||
expect(pair.refresh_token).toBeTypeOf("string");
|
||||
|
||||
await provider.clientsStore.revoke(pair.refresh_token!, client.client_id);
|
||||
|
||||
await expect(
|
||||
provider.clientsStore.getAccessToken(pair.access_token),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
provider.clientsStore.rotateRefreshToken(
|
||||
pair.refresh_token!,
|
||||
client.client_id,
|
||||
resource,
|
||||
undefined,
|
||||
),
|
||||
).resolves.toMatchObject({ status: "invalid" });
|
||||
} finally {
|
||||
await rm(temporaryDirectory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -22,11 +22,23 @@ describe("loadConfig", () => {
|
||||
expect(config).toMatchObject({
|
||||
port: 4321,
|
||||
defaultCwd: "/",
|
||||
trustProxyHops: 0,
|
||||
authToken: "secret",
|
||||
allowedHosts: ["mcp.example.com", "localhost"],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects partial integers and ports outside the valid range", () => {
|
||||
for (const value of ["3000oops", "3000.9", "70000"]) {
|
||||
expect(() =>
|
||||
loadConfig({ MCP_AUTH_TOKEN: "secret", MCP_PORT: value }, "/tmp"),
|
||||
).toThrow("MCP_PORT must be an integer between 1 and 65535");
|
||||
}
|
||||
expect(
|
||||
loadConfig({ MCP_AUTH_TOKEN: "secret", MCP_PORT: " 4321 " }, "/tmp").port,
|
||||
).toBe(4321);
|
||||
});
|
||||
|
||||
it("requires public HTTPS metadata when OAuth is enabled", () => {
|
||||
expect(() =>
|
||||
loadConfig({ MCP_AUTH_TOKEN: "secret", MCP_OAUTH_ENABLED: "true" }, "/tmp"),
|
||||
@@ -43,9 +55,54 @@ describe("loadConfig", () => {
|
||||
);
|
||||
expect(config).toMatchObject({
|
||||
oauthEnabled: true,
|
||||
oauthApprovalKey: "secret",
|
||||
oauthIssuerUrl: "https://mcp.example.com/",
|
||||
oauthResourceUrl: "https://mcp.example.com/mcp",
|
||||
oauthStateFile: "/tmp/oauth-state.json",
|
||||
});
|
||||
});
|
||||
|
||||
it("supports OAuth-only authentication with a separate approval key", () => {
|
||||
const config = loadConfig(
|
||||
{
|
||||
MCP_OAUTH_ENABLED: "true",
|
||||
MCP_OAUTH_APPROVAL_KEY: "separate-oauth-approval-key",
|
||||
MCP_PUBLIC_URL: "https://mcp.example.com",
|
||||
MCP_TRUST_PROXY_HOPS: "1",
|
||||
},
|
||||
"/tmp",
|
||||
);
|
||||
|
||||
expect(config).toMatchObject({
|
||||
authToken: undefined,
|
||||
oauthApprovalKey: "separate-oauth-approval-key",
|
||||
trustProxyHops: 1,
|
||||
});
|
||||
expect(() =>
|
||||
loadConfig(
|
||||
{
|
||||
MCP_OAUTH_ENABLED: "true",
|
||||
MCP_PUBLIC_URL: "https://mcp.example.com",
|
||||
},
|
||||
"/tmp",
|
||||
),
|
||||
).toThrow("MCP_OAUTH_APPROVAL_KEY");
|
||||
});
|
||||
|
||||
it("rejects unsafe proxy trust and OAuth URL settings", () => {
|
||||
expect(() =>
|
||||
loadConfig({ MCP_AUTH_TOKEN: "secret", MCP_TRUST_PROXY_HOPS: "17" }, "/tmp"),
|
||||
).toThrow("MCP_TRUST_PROXY_HOPS must be an integer between 0 and 16");
|
||||
expect(() =>
|
||||
loadConfig(
|
||||
{
|
||||
MCP_AUTH_TOKEN: "secret",
|
||||
MCP_OAUTH_ENABLED: "true",
|
||||
MCP_OAUTH_ISSUER: "https://user:password@mcp.example.com",
|
||||
MCP_OAUTH_RESOURCE: "https://mcp.example.com/mcp",
|
||||
},
|
||||
"/tmp",
|
||||
),
|
||||
).toThrow("must not contain user credentials");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -204,6 +204,22 @@ describe("FileService", () => {
|
||||
"base64",
|
||||
);
|
||||
expect(binary.content).toBe("//4=");
|
||||
|
||||
const invalidEdit = Buffer.from([0xff, 0x78]);
|
||||
await files.writeFileContent(
|
||||
"invalid-edit.bin",
|
||||
undefined,
|
||||
invalidEdit.toString("base64"),
|
||||
"base64",
|
||||
"overwrite",
|
||||
true,
|
||||
);
|
||||
await expect(
|
||||
files.replaceInFile("invalid-edit.bin", undefined, "x", "y", false, 1),
|
||||
).rejects.toThrow("not valid UTF-8");
|
||||
expect(await readFile(path.join(temporaryDirectory, "invalid-edit.bin"))).toEqual(
|
||||
invalidEdit,
|
||||
);
|
||||
});
|
||||
|
||||
it("applies an explicit file mode when overwriting an existing file", async () => {
|
||||
@@ -256,4 +272,22 @@ describe("FileService", () => {
|
||||
expect(await readFile(path.join(temporaryDirectory, "destination/value.txt"), "utf8"))
|
||||
.toBe("destination");
|
||||
});
|
||||
|
||||
it("preserves an existing move destination when the source is missing", async () => {
|
||||
await files.writeFileContent(
|
||||
"destination.txt",
|
||||
undefined,
|
||||
"valuable",
|
||||
"utf8",
|
||||
"overwrite",
|
||||
true,
|
||||
);
|
||||
|
||||
await expect(
|
||||
files.movePath("missing.txt", "destination.txt", undefined, true),
|
||||
).rejects.toThrow(/ENOENT|no such file/i);
|
||||
expect(await readFile(path.join(temporaryDirectory, "destination.txt"), "utf8")).toBe(
|
||||
"valuable",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,6 +67,25 @@ describe("remote development MCP server", () => {
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it("authenticates MCP requests before parsing their JSON body", async () => {
|
||||
const unauthenticated = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: "{",
|
||||
});
|
||||
expect(unauthenticated.status).toBe(401);
|
||||
|
||||
const authenticated = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: "Bearer integration-secret",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: "{",
|
||||
});
|
||||
expect(authenticated.status).toBe(400);
|
||||
});
|
||||
|
||||
it("lists tools and executes script and file workflows", async () => {
|
||||
const client = new Client({ name: "integration-test", version: "1.0.0" });
|
||||
const transport = new StreamableHTTPClientTransport(endpoint, {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readFile, rm, stat } from "node:fs/promises";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { createServer } from "node:net";
|
||||
import os from "node:os";
|
||||
@@ -40,14 +40,16 @@ describe("OAuth 2.1 MCP authorization", () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
temporaryDirectory = await mkdtemp(path.join(os.tmpdir(), "remote-dev-mcp-oauth-test-"));
|
||||
stateFile = path.join(temporaryDirectory, "oauth", "state.json");
|
||||
const stateDirectory = path.join(temporaryDirectory, "oauth");
|
||||
await mkdir(stateDirectory, { mode: 0o755 });
|
||||
stateFile = path.join(stateDirectory, "state.json");
|
||||
const port = await reservePort();
|
||||
baseUrl = `http://127.0.0.1:${port}`;
|
||||
resourceUrl = `${baseUrl}/mcp`;
|
||||
config = loadConfig(
|
||||
{
|
||||
MCP_AUTH_TOKEN: "oauth-login-secret",
|
||||
MCP_OAUTH_ENABLED: "true",
|
||||
MCP_OAUTH_APPROVAL_KEY: "oauth-login-secret",
|
||||
MCP_PUBLIC_URL: baseUrl,
|
||||
MCP_OAUTH_STATE_FILE: stateFile,
|
||||
MCP_HOST: "127.0.0.1",
|
||||
@@ -107,9 +109,54 @@ describe("OAuth 2.1 MCP authorization", () => {
|
||||
registration_endpoint: `${baseUrl}/register`,
|
||||
code_challenge_methods_supported: ["S256"],
|
||||
token_endpoint_auth_methods_supported: expect.arrayContaining(["none"]),
|
||||
revocation_endpoint_auth_methods_supported: expect.arrayContaining(["none"]),
|
||||
});
|
||||
|
||||
const redirectUri = "https://chatgpt.com/connector/oauth/test-callback";
|
||||
for (const invalidMetadata of [
|
||||
{
|
||||
redirect_uris: ["http://attacker.example/callback"],
|
||||
token_endpoint_auth_method: "none",
|
||||
grant_types: ["authorization_code"],
|
||||
response_types: ["code"],
|
||||
},
|
||||
{
|
||||
redirect_uris: [redirectUri],
|
||||
token_endpoint_auth_method: "private_key_jwt",
|
||||
grant_types: ["authorization_code"],
|
||||
response_types: ["code"],
|
||||
},
|
||||
{
|
||||
redirect_uris: [],
|
||||
token_endpoint_auth_method: "none",
|
||||
grant_types: ["authorization_code"],
|
||||
response_types: ["code"],
|
||||
},
|
||||
]) {
|
||||
const invalidRegistration = await fetch(`${baseUrl}/register`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(invalidMetadata),
|
||||
});
|
||||
expect(invalidRegistration.status).toBe(400);
|
||||
expect(await invalidRegistration.json()).toMatchObject({
|
||||
error: "invalid_client_metadata",
|
||||
});
|
||||
}
|
||||
|
||||
const defaultedRegistration = await fetch(`${baseUrl}/register`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ redirect_uris: [redirectUri] }),
|
||||
});
|
||||
expect(defaultedRegistration.status).toBe(201);
|
||||
expect(await defaultedRegistration.json()).toMatchObject({
|
||||
token_endpoint_auth_method: "client_secret_post",
|
||||
grant_types: ["authorization_code"],
|
||||
response_types: ["code"],
|
||||
client_secret: expect.any(String),
|
||||
});
|
||||
|
||||
const registrationResponse = await fetch(`${baseUrl}/register`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
@@ -143,9 +190,7 @@ describe("OAuth 2.1 MCP authorization", () => {
|
||||
redirect: "manual",
|
||||
});
|
||||
expect(loginPage.status).toBe(200);
|
||||
expect(loginPage.headers.get("content-security-policy")).toContain(
|
||||
"form-action 'self' https://chatgpt.com",
|
||||
);
|
||||
expect(loginPage.headers.get("content-security-policy")).toContain("form-action 'self'");
|
||||
expect(await loginPage.text()).toContain("MCP 인증키");
|
||||
|
||||
const rejectedLogin = await fetch(`${baseUrl}/authorize`, {
|
||||
@@ -162,7 +207,7 @@ describe("OAuth 2.1 MCP authorization", () => {
|
||||
body: form({ ...authorizationValues, access_key: "oauth-login-secret" }),
|
||||
redirect: "manual",
|
||||
});
|
||||
expect(approvedLogin.status).toBe(302);
|
||||
expect(approvedLogin.status).toBe(303);
|
||||
const callback = new URL(approvedLogin.headers.get("location")!);
|
||||
expect(callback.origin + callback.pathname).toBe(redirectUri);
|
||||
expect(callback.searchParams.get("state")).toBe("oauth-test-state");
|
||||
@@ -224,6 +269,35 @@ describe("OAuth 2.1 MCP authorization", () => {
|
||||
expect(refreshed.access_token).not.toBe(tokens.access_token);
|
||||
expect(refreshed.refresh_token).not.toBe(tokens.refresh_token);
|
||||
|
||||
await running.close();
|
||||
running = await startHttpServer(config, createServices(config));
|
||||
|
||||
const replayedRefresh = await fetch(`${baseUrl}/token`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body: form({
|
||||
grant_type: "refresh_token",
|
||||
client_id: registered.client_id,
|
||||
refresh_token: tokens.refresh_token,
|
||||
resource: resourceUrl,
|
||||
}),
|
||||
});
|
||||
expect(replayedRefresh.status).toBe(400);
|
||||
expect(await replayedRefresh.json()).toMatchObject({ error: "invalid_grant" });
|
||||
|
||||
const revokedSuccessor = await fetch(`${baseUrl}/token`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body: form({
|
||||
grant_type: "refresh_token",
|
||||
client_id: registered.client_id,
|
||||
refresh_token: refreshed.refresh_token,
|
||||
resource: resourceUrl,
|
||||
}),
|
||||
});
|
||||
expect(revokedSuccessor.status).toBe(400);
|
||||
expect(await revokedSuccessor.json()).toMatchObject({ error: "invalid_grant" });
|
||||
|
||||
const revokeResponse = await fetch(`${baseUrl}/revoke`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
@@ -242,6 +316,7 @@ describe("OAuth 2.1 MCP authorization", () => {
|
||||
expect(revokedRequest.status).toBe(401);
|
||||
|
||||
expect((await stat(stateFile)).mode & 0o777).toBe(0o600);
|
||||
expect((await stat(path.dirname(stateFile))).mode & 0o777).toBe(0o755);
|
||||
const persisted = await readFile(stateFile, "utf8");
|
||||
expect(persisted).not.toContain(tokens.access_token);
|
||||
expect(persisted).not.toContain(tokens.refresh_token);
|
||||
|
||||
@@ -76,4 +76,60 @@ describe("ProcessManager", () => {
|
||||
expect(result.timedOut).toBe(true);
|
||||
expect(result.error).toContain("timeout");
|
||||
});
|
||||
|
||||
it("handles a rejected initial stdin write without crashing the server", async () => {
|
||||
manager = createManager();
|
||||
const sessionId = manager.start({
|
||||
executable: "/bin/bash",
|
||||
args: ["-c", "true"],
|
||||
commandForDisplay: "true",
|
||||
cwd: process.cwd(),
|
||||
stdin: "x".repeat(1024 * 1024),
|
||||
});
|
||||
|
||||
await manager.waitForExit(sessionId, 2000);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
const result = await manager.read(sessionId);
|
||||
|
||||
expect(result.running).toBe(false);
|
||||
expect(result.error).toMatch(/stdin write failed|EPIPE/i);
|
||||
});
|
||||
|
||||
it("rejects a follow-up stdin write without emitting an unhandled error", async () => {
|
||||
manager = createManager();
|
||||
const sessionId = manager.start({
|
||||
executable: "/bin/bash",
|
||||
args: ["-c", "exec 0<&-; printf ready; sleep 2"],
|
||||
commandForDisplay: "closed stdin",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
expect((await manager.read(sessionId, { waitMs: 1000 })).stdout).toContain("ready");
|
||||
|
||||
await expect(manager.write(sessionId, "x".repeat(1024 * 1024))).rejects.toThrow();
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect((await manager.read(sessionId)).error).toMatch(/stdin write failed|EPIPE/i);
|
||||
});
|
||||
|
||||
it("preserves UTF-8 characters across paged process output", async () => {
|
||||
manager = createManager();
|
||||
const expected = `${"a".repeat(16 * 1024 - 1)}😀B`;
|
||||
const encoded = Buffer.from(expected).toString("base64");
|
||||
const sessionId = manager.start({
|
||||
executable: process.execPath,
|
||||
args: ["-e", `process.stdout.write(Buffer.from(${JSON.stringify(encoded)}, "base64"))`],
|
||||
commandForDisplay: "unicode output",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
await manager.waitForExit(sessionId, 2000);
|
||||
const first = await manager.read(sessionId, { maxOutputBytes: 16 * 1024 });
|
||||
const second = await manager.read(sessionId, {
|
||||
afterSeq: first.nextSeq,
|
||||
maxOutputBytes: 16 * 1024,
|
||||
});
|
||||
|
||||
expect(first.hasMore).toBe(true);
|
||||
expect(first.output + second.output).toBe(expected);
|
||||
expect(first.output + second.output).not.toContain("�");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user