feat: add safe GitPeji update checks
This commit is contained in:
@@ -0,0 +1,297 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const https = require('node:https');
|
||||||
|
|
||||||
|
const LATEST_RELEASE_URL = 'https://git.pejicorp.com/api/v1/repos/peji/Alta-Proxy-Tool/releases/latest';
|
||||||
|
const RELEASES_PAGE_URL = 'https://git.pejicorp.com/peji/Alta-Proxy-Tool/releases';
|
||||||
|
const REQUEST_TIMEOUT_MS = 5000;
|
||||||
|
const MAX_BODY_BYTES = 64 * 1024;
|
||||||
|
const MAX_RELEASE_NAME_LENGTH = 200;
|
||||||
|
|
||||||
|
// SemVer 2.0.0 without loose forms such as a leading "v" or omitted fields.
|
||||||
|
const SEMVER_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
|
||||||
|
|
||||||
|
class UpdatePolicyError extends Error {
|
||||||
|
constructor(code, message, options) {
|
||||||
|
super(message, options);
|
||||||
|
this.name = 'UpdatePolicyError';
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function policyError(code, message, cause) {
|
||||||
|
return new UpdatePolicyError(code, message, cause ? { cause } : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSemver(version, errorCode = 'INVALID_VERSION') {
|
||||||
|
if (typeof version !== 'string') {
|
||||||
|
throw policyError(errorCode, 'Version must be a strict semantic version string');
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = SEMVER_PATTERN.exec(version);
|
||||||
|
if (!match) {
|
||||||
|
throw policyError(errorCode, 'Version must use strict SemVer 2.0.0 syntax');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
major: BigInt(match[1]),
|
||||||
|
minor: BigInt(match[2]),
|
||||||
|
patch: BigInt(match[3]),
|
||||||
|
prerelease: match[4] === undefined ? null : match[4].split('.'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareIdentifier(left, right) {
|
||||||
|
const leftNumeric = /^\d+$/.test(left);
|
||||||
|
const rightNumeric = /^\d+$/.test(right);
|
||||||
|
|
||||||
|
if (leftNumeric && rightNumeric) {
|
||||||
|
const leftNumber = BigInt(left);
|
||||||
|
const rightNumber = BigInt(right);
|
||||||
|
return leftNumber < rightNumber ? -1 : leftNumber > rightNumber ? 1 : 0;
|
||||||
|
}
|
||||||
|
if (leftNumeric !== rightNumeric) {
|
||||||
|
return leftNumeric ? -1 : 1;
|
||||||
|
}
|
||||||
|
return left < right ? -1 : left > right ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareSemver(leftVersion, rightVersion) {
|
||||||
|
const left = parseSemver(leftVersion);
|
||||||
|
const right = parseSemver(rightVersion);
|
||||||
|
|
||||||
|
for (const key of ['major', 'minor', 'patch']) {
|
||||||
|
if (left[key] < right[key]) return -1;
|
||||||
|
if (left[key] > right[key]) return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (left.prerelease === null && right.prerelease === null) return 0;
|
||||||
|
if (left.prerelease === null) return 1;
|
||||||
|
if (right.prerelease === null) return -1;
|
||||||
|
|
||||||
|
const count = Math.max(left.prerelease.length, right.prerelease.length);
|
||||||
|
for (let index = 0; index < count; index += 1) {
|
||||||
|
if (left.prerelease[index] === undefined) return -1;
|
||||||
|
if (right.prerelease[index] === undefined) return 1;
|
||||||
|
const comparison = compareIdentifier(left.prerelease[index], right.prerelease[index]);
|
||||||
|
if (comparison !== 0) return comparison;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeText(value) {
|
||||||
|
return value
|
||||||
|
.trim()
|
||||||
|
.slice(0, MAX_RELEASE_NAME_LENGTH)
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateRuntimeValue(value, field) {
|
||||||
|
if (typeof value !== 'string' || value.length === 0 || value.length > 64 || !/^[a-zA-Z0-9_-]+$/.test(value)) {
|
||||||
|
throw policyError('INVALID_RUNTIME_METADATA', `Invalid ${field} metadata`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRelease(body) {
|
||||||
|
let release;
|
||||||
|
try {
|
||||||
|
release = JSON.parse(body.toString('utf8'));
|
||||||
|
} catch (error) {
|
||||||
|
throw policyError('INVALID_RESPONSE', 'Release response was not valid JSON', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!release || typeof release !== 'object' || Array.isArray(release)) {
|
||||||
|
throw policyError('INVALID_RESPONSE', 'Release response must be an object');
|
||||||
|
}
|
||||||
|
if (typeof release.tag_name !== 'string' || release.tag_name.length > 128) {
|
||||||
|
throw policyError('INVALID_RESPONSE', 'Release tag_name must be a bounded string');
|
||||||
|
}
|
||||||
|
if (release.name !== undefined && release.name !== null && typeof release.name !== 'string') {
|
||||||
|
throw policyError('INVALID_RESPONSE', 'Release name must be a string when provided');
|
||||||
|
}
|
||||||
|
if (typeof release.name === 'string' && release.name.length > 1000) {
|
||||||
|
throw policyError('INVALID_RESPONSE', 'Release name exceeds the schema limit');
|
||||||
|
}
|
||||||
|
if (release.published_at !== undefined && release.published_at !== null && typeof release.published_at !== 'string') {
|
||||||
|
throw policyError('INVALID_RESPONSE', 'Release published_at must be a string when provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
let publishedAt;
|
||||||
|
if (typeof release.published_at === 'string') {
|
||||||
|
if (release.published_at.length > 64) {
|
||||||
|
throw policyError('INVALID_RESPONSE', 'Release published_at exceeds the schema limit');
|
||||||
|
}
|
||||||
|
const timestamp = new Date(release.published_at);
|
||||||
|
if (Number.isNaN(timestamp.getTime())) {
|
||||||
|
throw policyError('INVALID_RESPONSE', 'Release published_at must be a valid timestamp');
|
||||||
|
}
|
||||||
|
publishedAt = timestamp.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
latestVersion: release.tag_name,
|
||||||
|
releaseName: sanitizeText(release.name || release.tag_name),
|
||||||
|
publishedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultRequest({ url, timeoutMs, maxBodyBytes }) {
|
||||||
|
if (url !== LATEST_RELEASE_URL) {
|
||||||
|
return Promise.reject(policyError('UNTRUSTED_REQUEST_URL', 'Update checks are restricted to GitPeji'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let settled = false;
|
||||||
|
const finishReject = (error) => {
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const request = https.get(url, {
|
||||||
|
headers: {
|
||||||
|
accept: 'application/json',
|
||||||
|
'user-agent': 'Alta-Proxy-Tool-update-check',
|
||||||
|
},
|
||||||
|
agent: false,
|
||||||
|
}, (response) => {
|
||||||
|
const chunks = [];
|
||||||
|
let receivedBytes = 0;
|
||||||
|
const contentLength = Number(response.headers['content-length']);
|
||||||
|
|
||||||
|
if (Number.isFinite(contentLength) && contentLength > maxBodyBytes) {
|
||||||
|
response.destroy();
|
||||||
|
finishReject(policyError('RESPONSE_TOO_LARGE', 'Release response exceeded the size limit'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.on('data', (chunk) => {
|
||||||
|
receivedBytes += chunk.length;
|
||||||
|
if (receivedBytes > maxBodyBytes) {
|
||||||
|
response.destroy();
|
||||||
|
finishReject(policyError('RESPONSE_TOO_LARGE', 'Release response exceeded the size limit'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chunks.push(chunk);
|
||||||
|
});
|
||||||
|
response.on('end', () => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
resolve({
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
headers: response.headers,
|
||||||
|
body: Buffer.concat(chunks),
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
response.on('error', finishReject);
|
||||||
|
});
|
||||||
|
|
||||||
|
request.setTimeout(timeoutMs, () => {
|
||||||
|
request.destroy(policyError('REQUEST_TIMEOUT', 'Release check timed out'));
|
||||||
|
});
|
||||||
|
request.on('error', (error) => {
|
||||||
|
if (error instanceof UpdatePolicyError) {
|
||||||
|
finishReject(error);
|
||||||
|
} else {
|
||||||
|
finishReject(policyError('REQUEST_FAILED', 'Release check failed', error));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkForUpdate({
|
||||||
|
currentVersion,
|
||||||
|
request = defaultRequest,
|
||||||
|
platform = process.platform,
|
||||||
|
arch = process.arch,
|
||||||
|
} = {}) {
|
||||||
|
parseSemver(currentVersion, 'INVALID_CURRENT_VERSION');
|
||||||
|
if (typeof request !== 'function') {
|
||||||
|
throw policyError('INVALID_REQUEST_ADAPTER', 'Request adapter must be a function');
|
||||||
|
}
|
||||||
|
|
||||||
|
const safePlatform = validateRuntimeValue(platform, 'platform');
|
||||||
|
const safeArch = validateRuntimeValue(arch, 'architecture');
|
||||||
|
const baseMetadata = {
|
||||||
|
currentVersion,
|
||||||
|
releasesPageUrl: RELEASES_PAGE_URL,
|
||||||
|
platform: safePlatform,
|
||||||
|
arch: safeArch,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await request({
|
||||||
|
url: LATEST_RELEASE_URL,
|
||||||
|
timeoutMs: REQUEST_TIMEOUT_MS,
|
||||||
|
maxBodyBytes: MAX_BODY_BYTES,
|
||||||
|
redirects: 'error',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response || typeof response !== 'object') {
|
||||||
|
throw policyError('INVALID_RESPONSE', 'Request adapter returned an invalid response');
|
||||||
|
}
|
||||||
|
if (response.url !== LATEST_RELEASE_URL) {
|
||||||
|
throw policyError('UNTRUSTED_RESPONSE_URL', 'Release response did not come from the exact GitPeji endpoint');
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusCode = Number(response.statusCode);
|
||||||
|
if (statusCode >= 300 && statusCode < 400) {
|
||||||
|
throw policyError('REDIRECT_REJECTED', 'Release endpoint redirects are not allowed');
|
||||||
|
}
|
||||||
|
if (statusCode === 404) {
|
||||||
|
return { status: 'no-release', ...baseMetadata };
|
||||||
|
}
|
||||||
|
if (statusCode !== 200) {
|
||||||
|
throw policyError('HTTP_ERROR', `Release endpoint returned HTTP ${statusCode}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers && response.headers['content-type'];
|
||||||
|
if (typeof contentType !== 'string' || !/^application\/json(?:\s*;|$)/i.test(contentType)) {
|
||||||
|
throw policyError('INVALID_CONTENT_TYPE', 'Release endpoint did not return JSON');
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = Buffer.isBuffer(response.body)
|
||||||
|
? response.body
|
||||||
|
: typeof response.body === 'string'
|
||||||
|
? Buffer.from(response.body)
|
||||||
|
: null;
|
||||||
|
if (!body) {
|
||||||
|
throw policyError('INVALID_RESPONSE', 'Release response body must be bytes or text');
|
||||||
|
}
|
||||||
|
if (body.length > MAX_BODY_BYTES) {
|
||||||
|
throw policyError('RESPONSE_TOO_LARGE', 'Release response exceeded the size limit');
|
||||||
|
}
|
||||||
|
|
||||||
|
const release = parseRelease(body);
|
||||||
|
parseSemver(release.latestVersion, 'INVALID_RELEASE_VERSION');
|
||||||
|
|
||||||
|
const metadata = {
|
||||||
|
status: compareSemver(release.latestVersion, currentVersion) > 0
|
||||||
|
? 'update-available'
|
||||||
|
: 'up-to-date',
|
||||||
|
...baseMetadata,
|
||||||
|
latestVersion: release.latestVersion,
|
||||||
|
releaseName: release.releaseName,
|
||||||
|
};
|
||||||
|
if (release.publishedAt !== undefined) {
|
||||||
|
metadata.publishedAt = release.publishedAt;
|
||||||
|
}
|
||||||
|
return metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
LATEST_RELEASE_URL,
|
||||||
|
RELEASES_PAGE_URL,
|
||||||
|
REQUEST_TIMEOUT_MS,
|
||||||
|
MAX_BODY_BYTES,
|
||||||
|
UpdatePolicyError,
|
||||||
|
checkForUpdate,
|
||||||
|
compareSemver,
|
||||||
|
defaultRequest,
|
||||||
|
};
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const test = require('node:test');
|
||||||
|
|
||||||
|
const {
|
||||||
|
LATEST_RELEASE_URL,
|
||||||
|
RELEASES_PAGE_URL,
|
||||||
|
MAX_BODY_BYTES,
|
||||||
|
checkForUpdate,
|
||||||
|
compareSemver,
|
||||||
|
} = require('../src/update-policy');
|
||||||
|
|
||||||
|
function response(body, overrides = {}) {
|
||||||
|
return {
|
||||||
|
statusCode: 200,
|
||||||
|
headers: { 'content-type': 'application/json; charset=utf-8' },
|
||||||
|
body: Buffer.from(body),
|
||||||
|
url: LATEST_RELEASE_URL,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rejectsWithCode(promise, code) {
|
||||||
|
await assert.rejects(promise, (error) => {
|
||||||
|
assert.equal(error.code, code);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('valid update returns only sanitized, check-only metadata', async () => {
|
||||||
|
let requestOptions;
|
||||||
|
const request = async (options) => {
|
||||||
|
requestOptions = options;
|
||||||
|
return response(JSON.stringify({
|
||||||
|
id: 42,
|
||||||
|
tag_name: '1.2.3',
|
||||||
|
name: ' Security <Release> ',
|
||||||
|
published_at: '2026-08-19T12:34:56Z',
|
||||||
|
body: 'untrusted release notes',
|
||||||
|
html_url: 'https://evil.example/download',
|
||||||
|
assets: [{ browser_download_url: 'https://evil.example/payload.exe' }],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await checkForUpdate({
|
||||||
|
currentVersion: '1.2.2',
|
||||||
|
request,
|
||||||
|
platform: 'win32',
|
||||||
|
arch: 'x64',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(requestOptions, {
|
||||||
|
url: LATEST_RELEASE_URL,
|
||||||
|
timeoutMs: 5000,
|
||||||
|
maxBodyBytes: MAX_BODY_BYTES,
|
||||||
|
redirects: 'error',
|
||||||
|
});
|
||||||
|
assert.deepEqual(result, {
|
||||||
|
status: 'update-available',
|
||||||
|
currentVersion: '1.2.2',
|
||||||
|
latestVersion: '1.2.3',
|
||||||
|
releaseName: 'Security <Release>',
|
||||||
|
publishedAt: '2026-08-19T12:34:56.000Z',
|
||||||
|
releasesPageUrl: RELEASES_PAGE_URL,
|
||||||
|
platform: 'win32',
|
||||||
|
arch: 'x64',
|
||||||
|
});
|
||||||
|
assert.equal(JSON.stringify(result).includes('payload.exe'), false);
|
||||||
|
assert.equal(JSON.stringify(result).includes('release notes'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('equal or older release is reported as up to date', async () => {
|
||||||
|
const request = async () => response(JSON.stringify({
|
||||||
|
tag_name: '2.0.0',
|
||||||
|
name: 'Current',
|
||||||
|
published_at: '2026-08-19T12:34:56Z',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await checkForUpdate({ currentVersion: '2.0.0', request });
|
||||||
|
assert.equal(result.status, 'up-to-date');
|
||||||
|
assert.equal(result.releasesPageUrl, RELEASES_PAGE_URL);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('404 means there is no published release', async () => {
|
||||||
|
const request = async () => response('', { statusCode: 404 });
|
||||||
|
const result = await checkForUpdate({ currentVersion: '1.0.0', request });
|
||||||
|
|
||||||
|
assert.deepEqual(result, {
|
||||||
|
status: 'no-release',
|
||||||
|
currentVersion: '1.0.0',
|
||||||
|
releasesPageUrl: RELEASES_PAGE_URL,
|
||||||
|
platform: process.platform,
|
||||||
|
arch: process.arch,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('malformed JSON and invalid response schema fail closed', async () => {
|
||||||
|
await rejectsWithCode(
|
||||||
|
checkForUpdate({ currentVersion: '1.0.0', request: async () => response('{') }),
|
||||||
|
'INVALID_RESPONSE',
|
||||||
|
);
|
||||||
|
await rejectsWithCode(
|
||||||
|
checkForUpdate({ currentVersion: '1.0.0', request: async () => response('[]') }),
|
||||||
|
'INVALID_RESPONSE',
|
||||||
|
);
|
||||||
|
await rejectsWithCode(
|
||||||
|
checkForUpdate({
|
||||||
|
currentVersion: '1.0.0',
|
||||||
|
request: async () => response(JSON.stringify({ tag_name: '1.1.0', name: 7 })),
|
||||||
|
}),
|
||||||
|
'INVALID_RESPONSE',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('oversized bodies fail before parsing', async () => {
|
||||||
|
const oversized = Buffer.alloc(MAX_BODY_BYTES + 1, 0x20);
|
||||||
|
await rejectsWithCode(
|
||||||
|
checkForUpdate({
|
||||||
|
currentVersion: '1.0.0',
|
||||||
|
request: async () => response('', { body: oversized }),
|
||||||
|
}),
|
||||||
|
'RESPONSE_TOO_LARGE',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('redirects and response host drift are rejected', async () => {
|
||||||
|
await rejectsWithCode(
|
||||||
|
checkForUpdate({
|
||||||
|
currentVersion: '1.0.0',
|
||||||
|
request: async () => response('', {
|
||||||
|
statusCode: 302,
|
||||||
|
headers: { location: 'https://evil.example/latest' },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
'REDIRECT_REJECTED',
|
||||||
|
);
|
||||||
|
|
||||||
|
await rejectsWithCode(
|
||||||
|
checkForUpdate({
|
||||||
|
currentVersion: '1.0.0',
|
||||||
|
request: async () => response('{}', { url: 'https://evil.example/latest' }),
|
||||||
|
}),
|
||||||
|
'UNTRUSTED_RESPONSE_URL',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalid or non-strict semver fails closed', async () => {
|
||||||
|
const invalidVersions = ['v1.2.3', '1.2', '01.2.3', '1.2.3.4', 'latest'];
|
||||||
|
|
||||||
|
for (const tag_name of invalidVersions) {
|
||||||
|
await rejectsWithCode(
|
||||||
|
checkForUpdate({
|
||||||
|
currentVersion: '1.0.0',
|
||||||
|
request: async () => response(JSON.stringify({ tag_name })),
|
||||||
|
}),
|
||||||
|
'INVALID_RELEASE_VERSION',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await rejectsWithCode(
|
||||||
|
checkForUpdate({ currentVersion: 'v1.0.0', request: async () => response('{}') }),
|
||||||
|
'INVALID_CURRENT_VERSION',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('strict semver comparison handles prerelease precedence', () => {
|
||||||
|
assert.equal(compareSemver('1.0.0', '1.0.0'), 0);
|
||||||
|
assert.equal(compareSemver('1.0.1', '1.0.0'), 1);
|
||||||
|
assert.equal(compareSemver('1.0.0-alpha.2', '1.0.0-alpha.10'), -1);
|
||||||
|
assert.equal(compareSemver('1.0.0-rc.1', '1.0.0'), -1);
|
||||||
|
assert.equal(compareSemver('2.0.0+build.1', '2.0.0+build.2'), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unexpected HTTP and content types fail closed', async () => {
|
||||||
|
await rejectsWithCode(
|
||||||
|
checkForUpdate({
|
||||||
|
currentVersion: '1.0.0',
|
||||||
|
request: async () => response('server error', { statusCode: 500 }),
|
||||||
|
}),
|
||||||
|
'HTTP_ERROR',
|
||||||
|
);
|
||||||
|
await rejectsWithCode(
|
||||||
|
checkForUpdate({
|
||||||
|
currentVersion: '1.0.0',
|
||||||
|
request: async () => response('{}', { headers: { 'content-type': 'text/html' } }),
|
||||||
|
}),
|
||||||
|
'INVALID_CONTENT_TYPE',
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user