mirror of
https://github.com/Kong/insomnia.git
synced 2026-08-04 11:52:33 -04:00
chore(sandbox): Added more smoke tests
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
import path from 'node:path';
|
||||
|
||||
import { expect } from '@playwright/test';
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
|
||||
import { getFixturePath, loadFixture } from '../../playwright/paths';
|
||||
import { test } from '../../playwright/test';
|
||||
@@ -682,6 +682,19 @@ test.describe('unhappy paths', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Race the expected pre-toggle error text against a 200 OK response. Abort immediately on failure instead of waiting for the error locator to time out.
|
||||
async function expectBlockedBeforeOk(page: Page, errorText: string) {
|
||||
const errorLocator = page.getByTestId('response-pane').getByText(errorText);
|
||||
const okLocator = page.locator('[data-testid="response-status-tag"]:visible', { hasText: '200 OK' });
|
||||
const testResponseCode = await Promise.race([
|
||||
errorLocator.waitFor({ state: 'visible' }).then(() => 'blocked' as const),
|
||||
okLocator.waitFor({ state: 'visible' }).then(() => 'ok' as const),
|
||||
]);
|
||||
if (testResponseCode !== 'blocked') {
|
||||
throw new Error(`expected the script to be blocked with "${errorText}", but a 200 OK response arrived first`);
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('sandbox features', () => {
|
||||
test.slow(process.platform === 'darwin' || process.platform === 'win32', 'Slow app start on these platforms');
|
||||
|
||||
@@ -709,11 +722,7 @@ test.describe('sandbox features', () => {
|
||||
await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 150)));
|
||||
|
||||
await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
// verify blocked-root error
|
||||
await expect
|
||||
.soft(page.getByTestId('response-pane'))
|
||||
.toContainText("The script was blocked because it used 'this'.");
|
||||
await expectBlockedBeforeOk(page, "The script was blocked because it used 'this'.");
|
||||
|
||||
// navigate to Settings → Scripting, disable the "Scopes" blocked roots group
|
||||
await page.getByTestId('settings-button').click();
|
||||
@@ -746,11 +755,7 @@ test.describe('sandbox features', () => {
|
||||
await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 150)));
|
||||
|
||||
await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
// verify blocked-property error
|
||||
await expect
|
||||
.soft(page.getByTestId('response-pane'))
|
||||
.toContainText("The script was blocked because it used the property 'prototype'.");
|
||||
await expectBlockedBeforeOk(page, "The script was blocked because it used the property 'prototype'.");
|
||||
|
||||
// navigate to Settings → Scripting, disable the "Prototype Mutation" blocked properties group
|
||||
await page.getByTestId('settings-button').click();
|
||||
@@ -786,8 +791,7 @@ test.describe('sandbox features', () => {
|
||||
|
||||
// send — Function masked to undefined → V8 uses the identifier name: "Function is not a constructor"
|
||||
await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
await expect.soft(page.getByTestId('response-pane')).toContainText('Function is not a constructor');
|
||||
await expectBlockedBeforeOk(page, 'Function is not a constructor');
|
||||
|
||||
// navigate to Settings → Scripting, disable the "Runtime APIs" mask group
|
||||
await page.getByTestId('settings-button').click();
|
||||
@@ -817,11 +821,7 @@ test.describe('sandbox features', () => {
|
||||
await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 150)));
|
||||
|
||||
await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
// verify blocked-root error
|
||||
await expect
|
||||
.soft(page.getByTestId('response-pane'))
|
||||
.toContainText("The script was blocked because it used 'process'.");
|
||||
await expectBlockedBeforeOk(page, "The script was blocked because it used 'process'.");
|
||||
|
||||
// navigate to Settings → Scripting, disable only the "Node.js Internals" BLOCKED ROOTS group.
|
||||
await page.getByTestId('settings-button').click();
|
||||
@@ -842,4 +842,237 @@ test.describe('sandbox features', () => {
|
||||
.not.toContainText("The script was blocked because it used 'process'.");
|
||||
await expect.soft(page.locator('[data-testid="response-status-tag"]:visible')).toContainText('200 OK');
|
||||
});
|
||||
|
||||
// Master sandbox toggle: turning it off disables all static checks.
|
||||
test("master 'Enable script sandbox' toggle", async ({ page }) => {
|
||||
await page.getByLabel('Request Collection').getByTestId('echo pre-request script result').press('Enter');
|
||||
|
||||
await page.getByRole('tab', { name: 'Scripts' }).click();
|
||||
const editor = page.getByTestId('CodeEditor').getByRole('textbox');
|
||||
await editor.fill(`insomnia.environment.set('result', String(this?.x));`);
|
||||
await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 150)));
|
||||
|
||||
await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click();
|
||||
await expectBlockedBeforeOk(page, "The script was blocked because it used 'this'.");
|
||||
|
||||
await page.getByTestId('settings-button').click();
|
||||
await page.locator('text=Insomnia Preferences').first().click();
|
||||
await page.getByRole('tab', { name: 'Scripting' }).click();
|
||||
|
||||
const masterSwitch = page.locator(
|
||||
'xpath=//span[normalize-space(text())="Enable script sandbox"]/ancestor::div[contains(@class,"justify-between")][1]//label[@data-react-aria-pressable]',
|
||||
);
|
||||
await masterSwitch.scrollIntoViewIfNeeded();
|
||||
await masterSwitch.click();
|
||||
await expect.soft(masterSwitch).not.toHaveAttribute('data-selected');
|
||||
|
||||
// child toggles become disabled when the master is off
|
||||
const strictSwitch = page.locator(
|
||||
'xpath=//span[normalize-space(text())="use strict"]/ancestor::div[contains(@class,"justify-between")][1]//label[@data-react-aria-pressable]',
|
||||
);
|
||||
await expect.soft(strictSwitch).toHaveAttribute('data-disabled');
|
||||
|
||||
await page.locator('.app').press('Escape');
|
||||
|
||||
await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click();
|
||||
await expect
|
||||
.soft(page.getByTestId('response-pane'))
|
||||
.not.toContainText("The script was blocked because it used 'this'.");
|
||||
await expect.soft(page.locator('[data-testid="response-status-tag"]:visible')).toContainText('200 OK');
|
||||
});
|
||||
|
||||
// 'use strict' toggle: strict mode causes assignment to undeclared identifier to throw.
|
||||
test("'use strict' toggle", async ({ page }) => {
|
||||
await page.getByLabel('Request Collection').getByTestId('echo pre-request script result').press('Enter');
|
||||
|
||||
await page.getByRole('tab', { name: 'Scripts' }).click();
|
||||
const editor = page.getByTestId('CodeEditor').getByRole('textbox');
|
||||
await editor.fill(`function f(){ undeclared = 1; return undeclared; } insomnia.environment.set('result', String(f()));`);
|
||||
await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 150)));
|
||||
|
||||
await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click();
|
||||
await expectBlockedBeforeOk(page, 'undeclared is not defined');
|
||||
|
||||
await page.getByTestId('settings-button').click();
|
||||
await page.locator('text=Insomnia Preferences').first().click();
|
||||
await page.getByRole('tab', { name: 'Scripting' }).click();
|
||||
const strictSwitch = page.locator(
|
||||
'xpath=//span[normalize-space(text())="use strict"]/ancestor::div[contains(@class,"justify-between")][1]//label[@data-react-aria-pressable]',
|
||||
);
|
||||
await strictSwitch.scrollIntoViewIfNeeded();
|
||||
await strictSwitch.click();
|
||||
await expect.soft(strictSwitch).not.toHaveAttribute('data-selected');
|
||||
await page.locator('.app').press('Escape');
|
||||
|
||||
await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click();
|
||||
await expect.soft(page.getByTestId('response-pane')).not.toContainText('undeclared is not defined');
|
||||
await expect.soft(page.locator('[data-testid="response-status-tag"]:visible')).toContainText('200 OK');
|
||||
});
|
||||
|
||||
// 'block unresolvable properties' toggle: dynamic computed access is statically blocked.
|
||||
test("'block unresolvable properties' toggle", async ({ page }) => {
|
||||
await page.getByLabel('Request Collection').getByTestId('echo pre-request script result').press('Enter');
|
||||
|
||||
await page.getByRole('tab', { name: 'Scripts' }).click();
|
||||
const editor = page.getByTestId('CodeEditor').getByRole('textbox');
|
||||
await editor.fill(`const k = 'foo'; const o = { foo: 42 }; insomnia.environment.set('result', String(o[k]));`);
|
||||
await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 150)));
|
||||
|
||||
await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click();
|
||||
await expectBlockedBeforeOk(page, 'dynamic computed property access that cannot be statically verified');
|
||||
|
||||
await page.getByTestId('settings-button').click();
|
||||
await page.locator('text=Insomnia Preferences').first().click();
|
||||
await page.getByRole('tab', { name: 'Scripting' }).click();
|
||||
const blockUnresolvableSwitch = page.locator(
|
||||
'xpath=//span[normalize-space(text())="block unresolvable properties"]/ancestor::div[contains(@class,"justify-between")][1]//label[@data-react-aria-pressable]',
|
||||
);
|
||||
await blockUnresolvableSwitch.scrollIntoViewIfNeeded();
|
||||
await blockUnresolvableSwitch.click();
|
||||
await expect.soft(blockUnresolvableSwitch).not.toHaveAttribute('data-selected');
|
||||
await page.locator('.app').press('Escape');
|
||||
|
||||
await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click();
|
||||
await expect
|
||||
.soft(page.getByTestId('response-pane'))
|
||||
.not.toContainText('dynamic computed property access that cannot be statically verified');
|
||||
await expect.soft(page.locator('[data-testid="response-status-tag"]:visible')).toContainText('200 OK');
|
||||
});
|
||||
|
||||
// Mask Rules / Async Scheduling group: setImmediate is masked to undefined at runtime.
|
||||
test('Mask Rules / Async Scheduling group', async ({ page }) => {
|
||||
await page.getByLabel('Request Collection').getByTestId('echo pre-request script result').press('Enter');
|
||||
|
||||
await page.getByRole('tab', { name: 'Scripts' }).click();
|
||||
const editor = page.getByTestId('CodeEditor').getByRole('textbox');
|
||||
await editor.fill(`setImmediate(() => {}); insomnia.environment.set('result', 'ok');`);
|
||||
await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 150)));
|
||||
|
||||
await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click();
|
||||
await expectBlockedBeforeOk(page, 'setImmediate is not a function');
|
||||
|
||||
await page.getByTestId('settings-button').click();
|
||||
await page.locator('text=Insomnia Preferences').first().click();
|
||||
await page.getByRole('tab', { name: 'Scripting' }).click();
|
||||
const asyncSchedulingSwitch = page.locator(
|
||||
'div:has(> h4:has-text("Async Scheduling")) label[data-react-aria-pressable]',
|
||||
);
|
||||
await asyncSchedulingSwitch.scrollIntoViewIfNeeded();
|
||||
await asyncSchedulingSwitch.click();
|
||||
await expect.soft(asyncSchedulingSwitch).not.toHaveAttribute('data-selected');
|
||||
await page.locator('.app').press('Escape');
|
||||
|
||||
await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click();
|
||||
await expect.soft(page.getByTestId('response-pane')).not.toContainText('setImmediate is not a function');
|
||||
await expect.soft(page.locator('[data-testid="response-status-tag"]:visible')).toContainText('200 OK');
|
||||
});
|
||||
|
||||
// Blocked Properties / Stack Inspection group: 'captureStackTrace' is blocked.
|
||||
test('Blocked Properties / Stack Inspection group', async ({ page }) => {
|
||||
await page.getByLabel('Request Collection').getByTestId('echo pre-request script result').press('Enter');
|
||||
|
||||
await page.getByRole('tab', { name: 'Scripts' }).click();
|
||||
const editor = page.getByTestId('CodeEditor').getByRole('textbox');
|
||||
await editor.fill(`insomnia.environment.set('result', String(typeof Error.captureStackTrace));`);
|
||||
await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 150)));
|
||||
|
||||
await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click();
|
||||
await expectBlockedBeforeOk(page, "The script was blocked because it used the property 'captureStackTrace'.");
|
||||
|
||||
await page.getByTestId('settings-button').click();
|
||||
await page.locator('text=Insomnia Preferences').first().click();
|
||||
await page.getByRole('tab', { name: 'Scripting' }).click();
|
||||
const stackInspectionSwitch = page.locator(
|
||||
'div:has(> h4:has-text("Stack Inspection")) label[data-react-aria-pressable]',
|
||||
);
|
||||
await stackInspectionSwitch.scrollIntoViewIfNeeded();
|
||||
await stackInspectionSwitch.click();
|
||||
await expect.soft(stackInspectionSwitch).not.toHaveAttribute('data-selected');
|
||||
await page.locator('.app').press('Escape');
|
||||
|
||||
await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click();
|
||||
await expect
|
||||
.soft(page.getByTestId('response-pane'))
|
||||
.not.toContainText("The script was blocked because it used the property 'captureStackTrace'.");
|
||||
await expect.soft(page.locator('[data-testid="response-status-tag"]:visible')).toContainText('200 OK');
|
||||
});
|
||||
|
||||
// Blocked Properties / Accessor Helpers group: 'defineProperty' is blocked.
|
||||
test('Blocked Properties / Accessor Helpers group', async ({ page }) => {
|
||||
await page.getByLabel('Request Collection').getByTestId('echo pre-request script result').press('Enter');
|
||||
|
||||
await page.getByRole('tab', { name: 'Scripts' }).click();
|
||||
const editor = page.getByTestId('CodeEditor').getByRole('textbox');
|
||||
await editor.fill(`const o = {}; Object.defineProperty(o, 'a', { value: 1 }); insomnia.environment.set('result', String(o.a));`);
|
||||
await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 150)));
|
||||
|
||||
await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click();
|
||||
await expectBlockedBeforeOk(page, "The script was blocked because it used the property 'defineProperty'.");
|
||||
|
||||
await page.getByTestId('settings-button').click();
|
||||
await page.locator('text=Insomnia Preferences').first().click();
|
||||
await page.getByRole('tab', { name: 'Scripting' }).click();
|
||||
const accessorHelpersSwitch = page.locator(
|
||||
'div:has(> h4:has-text("Accessor Helpers")) label[data-react-aria-pressable]',
|
||||
);
|
||||
await accessorHelpersSwitch.scrollIntoViewIfNeeded();
|
||||
await accessorHelpersSwitch.click();
|
||||
await expect.soft(accessorHelpersSwitch).not.toHaveAttribute('data-selected');
|
||||
await page.locator('.app').press('Escape');
|
||||
|
||||
await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click();
|
||||
await expect
|
||||
.soft(page.getByTestId('response-pane'))
|
||||
.not.toContainText("The script was blocked because it used the property 'defineProperty'.");
|
||||
await expect.soft(page.locator('[data-testid="response-status-tag"]:visible')).toContainText('200 OK');
|
||||
});
|
||||
|
||||
// Blocked Roots / Global Object Aliases group: 'globalThis' is blocked.
|
||||
test('Blocked Roots / Global Object Aliases group', async ({ page }) => {
|
||||
await page.getByLabel('Request Collection').getByTestId('echo pre-request script result').press('Enter');
|
||||
|
||||
await page.getByRole('tab', { name: 'Scripts' }).click();
|
||||
const editor = page.getByTestId('CodeEditor').getByRole('textbox');
|
||||
await editor.fill(`insomnia.environment.set('result', String(globalThis.Object));`);
|
||||
await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 150)));
|
||||
|
||||
await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click();
|
||||
await expectBlockedBeforeOk(page, "The script was blocked because it used 'globalThis'.");
|
||||
|
||||
await page.getByTestId('settings-button').click();
|
||||
await page.locator('text=Insomnia Preferences').first().click();
|
||||
await page.getByRole('tab', { name: 'Scripting' }).click();
|
||||
const globalAliasesSwitch = page.locator(
|
||||
'div:has(> h4:has-text("Global Object Aliases")) label[data-react-aria-pressable]',
|
||||
);
|
||||
await globalAliasesSwitch.scrollIntoViewIfNeeded();
|
||||
await globalAliasesSwitch.click();
|
||||
await expect.soft(globalAliasesSwitch).not.toHaveAttribute('data-selected');
|
||||
await page.locator('.app').press('Escape');
|
||||
|
||||
// Static rule passes, but the mask still resolves `globalThis` to undefined,
|
||||
// so `globalThis.Object` throws — verify we now hit the next security layer.
|
||||
await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click();
|
||||
await expect
|
||||
.soft(page.getByTestId('response-pane'))
|
||||
.toContainText("Cannot read properties of undefined (reading 'Object')");
|
||||
|
||||
// Disable the matching mask group so `globalThis` resolves to the real host global.
|
||||
await page.getByTestId('settings-button').click();
|
||||
await page.locator('text=Insomnia Preferences').first().click();
|
||||
await page.getByRole('tab', { name: 'Scripting' }).click();
|
||||
const globalMaskSwitch = page.locator(
|
||||
'div:has(> h4:has-text("Global & Node.js Internals")) label[data-react-aria-pressable]',
|
||||
);
|
||||
await globalMaskSwitch.scrollIntoViewIfNeeded();
|
||||
await globalMaskSwitch.click();
|
||||
await expect.soft(globalMaskSwitch).not.toHaveAttribute('data-selected');
|
||||
await page.locator('.app').press('Escape');
|
||||
|
||||
await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click();
|
||||
await expect
|
||||
.soft(page.getByTestId('response-pane'))
|
||||
.not.toContainText("Cannot read properties of undefined (reading 'Object')");
|
||||
await expect.soft(page.locator('[data-testid="response-status-tag"]:visible')).toContainText('200 OK');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,16 +25,13 @@ export interface SandboxContext {
|
||||
bridgeOps: BridgeOps;
|
||||
}
|
||||
|
||||
// Derive the default blocked sets from the canonical rule lists in script-security-policy.
|
||||
const SANDBOX_BLOCKED_PROPERTIES = new Set(blockedPropertyRules.map(r => r.name));
|
||||
const SANDBOX_BLOCKED_ROOTS = new Set(blockedRootRules.map(r => r.name));
|
||||
|
||||
// These interceptor rules always apply — they cannot be disabled via settings and run even when
|
||||
// the sandbox is turned off, because they gate access to critical host APIs (require, window, eval).
|
||||
// The original (v12.5.0) interceptor rules always apply.
|
||||
const ALWAYS_ON_INTERCEPTORS = new Set(['require', 'window', 'eval']);
|
||||
|
||||
// Sentinel returned by getMemberPropertyName when the computed key cannot be statically resolved.
|
||||
// Callers must treat this as a hard block — unknown dynamic keys are rejected by policy.
|
||||
// AST check operates based on getMemberPropertyName, some keys may not be statically resolved. We can drop all unresolvable properties to resolve this.
|
||||
const UNRESOLVABLE = Symbol('unresolvable');
|
||||
|
||||
// Walks a MemberExpression down to its root Identifier.
|
||||
@@ -44,8 +41,7 @@ function getMemberRoot(node: any): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Returns MemberExpression property name, or UNRESOLVABLE when the computed key cannot be
|
||||
// statically determined (e.g. BinaryExpression, dynamic TemplateLiteral).
|
||||
// Returns MemberExpression property name, or UNRESOLVABLE when the computed key cannot statically determined.
|
||||
function getMemberPropertyName(node: acorn.MemberExpression): string | typeof UNRESOLVABLE | null {
|
||||
if (!node.computed && node.property.type === 'Identifier') {
|
||||
return (node.property as acorn.Identifier).name;
|
||||
@@ -392,33 +388,22 @@ export async function prepareSandbox(
|
||||
({ names: maskNames, values: maskValues } = alwaysOnPolicy.buildMaskScope(checkSandboxViolations));
|
||||
}
|
||||
|
||||
// Replace the placeholder eval interceptor with one that carries the full parameter mask.
|
||||
// The rule-level interceptor uses (0,eval) (indirect eval in global scope), which bypasses
|
||||
// parameter masking and lets scripts access real globals like require, Function, process.
|
||||
// Here we patch maskValues to use a new AsyncFunction with the same params and a direct eval,
|
||||
// so eval'd code inherits the masked parameter bindings.
|
||||
// Wrap eval so user-supplied source is checked and then evaluated in a scope that inherits the same masked bindings as the outer sandbox.
|
||||
const evalIdx = maskNames.indexOf('eval');
|
||||
if (evalIdx !== -1) {
|
||||
const strictModeEnabled = context.settings.scriptStrictModeEnabled !== false;
|
||||
const evalBody = strictModeEnabled
|
||||
? '"use strict"; return eval(__eval_script__);'
|
||||
: 'return eval(__eval_script__);';
|
||||
// Exclude 'eval' from the parameter list: naming a parameter 'eval' is illegal in strict mode,
|
||||
// and the function needs to call the real eval (not the interceptor) for direct-eval scoping.
|
||||
// Use a synchronous Function (not AsyncFunction) to preserve the synchronous return contract —
|
||||
// AsyncFunction always returns a Promise, which breaks postMessage structured-clone for sync scripts.
|
||||
const nonEvalMaskNames = maskNames.filter(n => n !== 'eval');
|
||||
const nonEvalMaskValues = maskValues.filter((_, i) => maskNames[i] !== 'eval');
|
||||
const scopedEvalFn = new Function(
|
||||
...nonEvalMaskNames, '__eval_script__',
|
||||
evalBody,
|
||||
);
|
||||
const useStrict = context.settings.scriptStrictModeEnabled !== false;
|
||||
const body = `${useStrict ? '"use strict"; ' : ''}return eval(__eval_script__);`;
|
||||
// 'eval' is excluded from params: it's illegal as a strict-mode param name,
|
||||
// and the wrapper must call the real eval for direct-eval scoping to apply.
|
||||
const paramNames = maskNames.filter(n => n !== 'eval');
|
||||
const paramValues = maskValues.filter((_, i) => maskNames[i] !== 'eval');
|
||||
const scopedEvalFn = new Function(...paramNames, '__eval_script__', body);
|
||||
maskValues[evalIdx] = (script: string) => {
|
||||
if (!script || typeof script !== 'string') {
|
||||
throw new Error('eval is called with invalid or empty value');
|
||||
}
|
||||
evalViolationCheck(script);
|
||||
return scopedEvalFn(...nonEvalMaskValues, script);
|
||||
return scopedEvalFn(...paramValues, script);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user