[release] v0.3.0-unstable 2FA + geoblock

This commit is contained in:
Yann Stepienik 2023-04-30 13:03:14 +01:00
parent ea48586705
commit 3811e3131e
47 changed files with 1732 additions and 1816 deletions

View File

@ -44,6 +44,12 @@ jobs:
name: Install dependencies
command: npm install
- run:
name: Download GeoLite2-Country database
command: |
curl -s -L "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-Country&license_key=$MAX_TOKEN&suffix=tar.gz" -o GeoLite2-Country.tar.gz
tar -xzf GeoLite2-Country.tar.gz --strip-components 1 --wildcards "*.mmdb"
- run:
name: Build UI
command: npm run client-build

3
.gitignore vendored
View File

@ -11,4 +11,5 @@ tests
todo.txt
LICENCE
tokens.json
.vscode
.vscode
GeoLite2-Country.mmdb

BIN
Logo.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 354 KiB

After

Width:  |  Height:  |  Size: 348 KiB

View File

@ -4,6 +4,7 @@ if [ $? -ne 0 ]; then
exit 1
fi
cp -r static build/
cp -r GeoLite2-Country.mmdb build/
mkdir build/images
cp client/src/assets/images/icons/cosmos_gray.png build/cosmos_gray.png
cp client/src/assets/images/icons/cosmos_gray.png cosmos_gray.png

View File

@ -67,6 +67,39 @@ function deleteUser(nickname) {
}))
}
function new2FA(nickname) {
return wrap(fetch('/cosmos/api/mfa', {
method: 'GET',
headers: {
'Content-Type': 'application/json'
},
}))
}
function check2FA(values) {
return wrap(fetch('/cosmos/api/mfa', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
token: values
}),
}))
}
function reset2FA(values) {
return wrap(fetch('/cosmos/api/mfa', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
nickname: values
}),
}))
}
export {
list,
create,
@ -75,4 +108,7 @@ export {
edit,
get,
deleteUser,
new2FA,
check2FA,
reset2FA
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 354 KiB

After

Width:  |  Height:  |  Size: 348 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 141 KiB

View File

@ -55,3 +55,10 @@
.code {
background-color: rgba(0.2,0.2,0.2,0.2);
}
@media (prefers-color-scheme: dark) {
.MuiPopper-root > * {
color:white;
background-color: rgba(0,0,0,0.8);
}
}

View File

@ -10,6 +10,10 @@ const IsLoggedIn = () => useEffect(() => {
window.location.href = '/ui/newInstall';
} else if (data.status == 'error' && data.code == "HTTP004") {
window.location.href = '/ui/login';
} else if (data.status == 'error' && data.code == "HTTP006") {
window.location.href = '/ui/loginmfa';
} else if (data.status == 'error' && data.code == "HTTP007") {
window.location.href = '/ui/newmfa';
}
}
});

View File

@ -9,7 +9,6 @@ import {
FormControlLabel,
FormHelperText,
Grid,
Link,
IconButton,
InputAdornment,
InputLabel,

View File

@ -72,8 +72,8 @@ const AuthRegister = ({nickname, isRegister, isInviteLink, regkey}) => {
.max(255)
.required('Password is required')
.matches(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*])(?=.{8,})/,
'Must Contain 8 Characters, One Uppercase, One Lowercase, One Number and one special case Character'
/^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*])(?=.{9,})/,
'Must Contain 9 Characters, One Uppercase, One Lowercase, One Number and one special case Character'
),
})}
onSubmit={async (values, { setErrors, setStatus, setSubmitting }) => {

View File

@ -0,0 +1,197 @@
import { Link } from 'react-router-dom';
// material-ui
import {
Button,
Checkbox,
Divider,
FormControlLabel,
FormHelperText,
Grid,
IconButton,
InputAdornment,
InputLabel,
OutlinedInput,
Stack,
Typography,
Alert,
TextField,
Tooltip
} from '@mui/material';
// project import
import AuthWrapper from './AuthWrapper';
import { useEffect, useState, useRef } from 'react';
import * as Yup from 'yup';
import * as API from '../../api';
import QRCode from 'qrcode';
import { useTheme } from '@mui/material/styles';
import { Formik } from 'formik';
import { LoadingButton } from '@mui/lab';
import { CosmosCollapse } from '../config/users/formShortcuts';
const MFALoginForm = () => {
const urlSearchParams = new URLSearchParams(window.location.search);
const redirectTo = urlSearchParams.get('redirect') ? urlSearchParams.get('redirect') : '/ui';
useEffect(() => {
API.auth.me().then((data) => {
if(data.status == 'OK') {
window.location.href = redirectTo;
} else if(data.status == 'NEW_INSTALL') {
window.location.href = '/ui/newInstall';
}
});
});
return <Formik
initialValues={{
token: '',
}}
validationSchema={Yup.object().shape({
token: Yup.string().required('Token is required').min(6, 'Token must be at least 6 characters').max(6, 'Token must be at most 6 characters'),
})}
onSubmit={(values, { setSubmitting, setStatus, setErrors }) => {
API.users.check2FA(values.token).then((data) => {
window.location.href = redirectTo;
}).catch((error) => {
console.log(error)
setStatus({ success: false });
setErrors({ submit: "Wrong OTP. Try again" });
setSubmitting(false);
});
}}
>
{(formik) => (
<form autoComplete="off" noValidate onSubmit={formik.handleSubmit}>
<Stack spacing={3}>
<TextField
fullWidth
autoComplete="off"
type="text"
label="Token"
{...formik.getFieldProps('token')}
error={formik.touched.token && formik.errors.token && true}
helperText={formik.touched.token && formik.errors.token && formik.errors.token}
/>
{formik.errors.submit && (
<Grid item xs={12}>
<FormHelperText error>{formik.errors.submit}</FormHelperText>
</Grid>
)}
<LoadingButton
fullWidth
size="large"
type="submit"
variant="contained"
loading={formik.isSubmitting}
>
Login
</LoadingButton>
</Stack>
</form>
)}
</Formik>;
}
const MFASetup = () => {
const [mfaCode, setMfaCode] = useState('');
const canvasRef = useRef(null);
const theme = useTheme();
const isDark = theme.palette.mode === 'dark';
const getCode = () => {
return API.users.new2FA().then(({data}) => {
if (data) {
setMfaCode(data.key);
QRCode.toCanvas(canvasRef.current, data.key, {
width: 300,
color: {
dark: theme.palette.secondary.main,
light: '#ffffff'
}
}, function (error) {
if (error) console.error(error)
})
}
});
};
useEffect(() => {
getCode();
}, []);
return (
<Grid container spacing={3}>
<Grid item xs={12}>
<Typography variant="h5">This server requires 2FA. Scan this QR code with your <Tooltip title="For example FreeOTP(+) or Google/Microsoft authenticator"><span style={{cursor: 'pointer', textDecoration:"underline dotted"}}>authenticator app</span></Tooltip> to proceed</Typography>
</Grid>
<Grid item xs={12} textAlign={'center'}>
<canvas style={{borderRadius: '15px'}} ref={canvasRef} />
</Grid>
<Grid item xs={12}>
<Typography variant="h5">...Or enter this code manually in it</Typography>
</Grid>
<Grid item xs={12}>
<CosmosCollapse title="Show manual code" defaultExpanded={false}>
<div style={{padding: '20px', fontSize: '90%', borderRadius: '15px', background: 'rgba(0,0,0,0.2)'}}>
{mfaCode && <span>{mfaCode.split('?')[1].split('&').map(a => <div>{decodeURI(a).replace('=', ': ')}</div>)}</span>}
</div>
</CosmosCollapse>
</Grid>
<Grid item xs={12}>
<Typography variant="h5">Once you have scanned the QR code or entered the code manually, enter the token from your authenticator app below</Typography>
</Grid>
<Grid item xs={12}>
<MFALoginForm />
</Grid>
<Grid item xs={12}>
<Link to="/ui/logout">
<Typography variant="h5">Logout</Typography>
</Link>
</Grid>
</Grid>
);
}
const NewMFA = () => (
<AuthWrapper>
<Grid container spacing={3}>
<Grid item xs={12}>
<Stack direction="row" justifyContent="space-between" alignItems="baseline" sx={{ mb: { xs: -0.5, sm: 0.5 } }}>
<Typography variant="h3">New MFA Setup</Typography>
</Stack>
</Grid>
<Grid item xs={12}>
<MFASetup />
</Grid>
</Grid>
</AuthWrapper>
);
const MFALogin = () => (
<AuthWrapper>
<Grid container spacing={3}>
<Grid item xs={12}>
<Stack direction="row" justifyContent="space-between" alignItems="baseline" sx={{ mb: { xs: -0.5, sm: 0.5 } }}>
<Typography variant="h3">Enter your OTP</Typography>
</Stack>
</Grid>
<Grid item xs={12}>
<MFALoginForm />
</Grid>
</Grid>
</AuthWrapper>
);
export default NewMFA;
export {
MFASetup,
NewMFA,
MFALogin,
MFALoginForm
};

View File

@ -51,6 +51,7 @@ const NewRouteCreate = ({ openNewModal, setOpenNewModal, config }) => {
AuthEnabled: false,
Timeout: 14400000,
ThrottlePerMinute: 10000,
BlockCommonBots: true,
SmartShield: {
Enabled: true,
}

View File

@ -5,12 +5,13 @@ import { Formik } from 'formik';
import {
Alert,
Button,
Divider,
Grid,
Stack,
} from '@mui/material';
import RestartModal from '../users/restart';
import { CosmosCheckbox, CosmosInputText } from '../users/formShortcuts';
import { CosmosCheckbox, CosmosFormDivider, CosmosInputText, CosmosSelect } from '../users/formShortcuts';
import { snackit } from '../../../api/wrap';
const RouteSecurity = ({ routeConfig }) => {
@ -27,7 +28,16 @@ const RouteSecurity = ({ routeConfig }) => {
ThrottlePerMinute: routeConfig.ThrottlePerMinute,
CORSOrigin: routeConfig.CORSOrigin,
MaxBandwith: routeConfig.MaxBandwith,
BlockAPIAbuse: routeConfig.BlockAPIAbuse,
BlockCommonBots: routeConfig.BlockCommonBots,
_SmartShield_Enabled: (routeConfig.SmartShield ? routeConfig.SmartShield.Enabled : false),
_SmartShield_PolicyStrictness: (routeConfig.SmartShield ? routeConfig.SmartShield.PolicyStrictness : 0),
_SmartShield_PerUserTimeBudget: (routeConfig.SmartShield ? routeConfig.SmartShield.PerUserTimeBudget : 0),
_SmartShield_PerUserRequestLimit: (routeConfig.SmartShield ? routeConfig.SmartShield.PerUserRequestLimit : 0),
_SmartShield_PerUserByteLimit: (routeConfig.SmartShield ? routeConfig.SmartShield.PerUserByteLimit : 0),
_SmartShield_PerUserSimultaneous: (routeConfig.SmartShield ? routeConfig.SmartShield.PerUserSimultaneous : 0),
_SmartShield_MaxGlobalSimultaneous: (routeConfig.SmartShield ? routeConfig.SmartShield.MaxGlobalSimultaneous : 0),
_SmartShield_PrivilegedGroups: (routeConfig.SmartShield ? routeConfig.SmartShield.PrivilegedGroups : []),
}}
onSubmit={async (values, { setErrors, setStatus, setSubmitting }) => {
const fullValues = {
@ -38,8 +48,23 @@ const RouteSecurity = ({ routeConfig }) => {
if(!fullValues.SmartShield) {
fullValues.SmartShield = {};
}
fullValues.SmartShield.Enabled = values._SmartShield_Enabled;
delete fullValues._SmartShield_Enabled;
fullValues.SmartShield.PolicyStrictness = values._SmartShield_PolicyStrictness;
delete fullValues._SmartShield_PolicyStrictness;
fullValues.SmartShield.PerUserTimeBudget = values._SmartShield_PerUserTimeBudget;
delete fullValues._SmartShield_PerUserTimeBudget;
fullValues.SmartShield.PerUserRequestLimit = values._SmartShield_PerUserRequestLimit;
delete fullValues._SmartShield_PerUserRequestLimit;
fullValues.SmartShield.PerUserByteLimit = values._SmartShield_PerUserByteLimit;
delete fullValues._SmartShield_PerUserByteLimit;
fullValues.SmartShield.PerUserSimultaneous = values._SmartShield_PerUserSimultaneous;
delete fullValues._SmartShield_PerUserSimultaneous;
fullValues.SmartShield.MaxGlobalSimultaneous = values._SmartShield_MaxGlobalSimultaneous;
delete fullValues._SmartShield_MaxGlobalSimultaneous;
fullValues.SmartShield.PrivilegedGroups = values._SmartShield_PrivilegedGroups;
delete fullValues._SmartShield_PrivilegedGroups;
API.config.replaceRoute(routeConfig.Name, fullValues).then((res) => {
if (res.status == "OK") {
@ -64,18 +89,88 @@ const RouteSecurity = ({ routeConfig }) => {
<Alert color='info'>Additional security settings. MFA and Captcha are not yet implemented.</Alert>
</Grid>
<CosmosFormDivider title={'Authentication'} />
<CosmosCheckbox
name="AuthEnabled"
label="Authentication Required"
formik={formik}
/>
<CosmosFormDivider title={'Smart Shield'} />
<CosmosCheckbox
name="_SmartShield_Enabled"
label="Smart Shield Protection"
formik={formik}
/>
<CosmosSelect
name="_SmartShield_PolicyStrictness"
label="Policy Strictness"
placeholder="Policy Strictness"
options={[
[0, 'Default'],
[1, 'Strict'],
[2, 'Normal'],
[3, 'Lenient'],
]}
formik={formik}
/>
<CosmosInputText
name="_SmartShield_PerUserTimeBudget"
label="Per User Time Budget in milliseconds (0 for default)"
placeholder="Per User Time Budget"
type="number"
formik={formik}
/>
<CosmosInputText
name="_SmartShield_PerUserRequestLimit"
label="Per User Request Limit (0 for default)"
placeholder="Per User Request Limit"
type="number"
formik={formik}
/>
<CosmosInputText
name="_SmartShield_PerUserByteLimit"
label="Per User Byte Limit (0 for default)"
placeholder="Per User Byte Limit"
type="number"
formik={formik}
/>
<CosmosInputText
name="_SmartShield_PerUserSimultaneous"
label="Per User Simultaneous Connections Limit (0 for default)"
placeholder="Per User Simultaneous Connections Limit"
type="number"
formik={formik}
/>
<CosmosInputText
name="_SmartShield_MaxGlobalSimultaneous"
label="Max Global Simultaneous Connections Limit (0 for default)"
placeholder="Max Global Simultaneous Connections Limit"
type="number"
formik={formik}
/>
<CosmosSelect
name="_SmartShield_PrivilegedGroups"
label="Privileged Groups (comma separated)"
placeholder="Privileged Groups"
options={[
[0, 'Default'],
[1, 'Users'],
[2, 'Admin'],
]}
formik={formik}
/>
<CosmosFormDivider title={'Limits'} />
<CosmosInputText
name="Timeout"
label="Timeout in milliseconds (0 for no timeout, at least 30000 or less recommended)"
@ -106,6 +201,18 @@ const RouteSecurity = ({ routeConfig }) => {
placeholder="CORS Origin"
formik={formik}
/>
<CosmosCheckbox
name="BlockCommonBots"
label="Block Common Bots (Recommended)"
formik={formik}
/>
<CosmosCheckbox
name="BlockAPIAbuse"
label="Block requests without Referer header"
formik={formik}
/>
</Grid>
</MainCard>
<MainCard ><Button

View File

@ -28,7 +28,7 @@ import { EyeOutlined, EyeInvisibleOutlined } from '@ant-design/icons';
import AnimateButton from '../../../components/@extended/AnimateButton';
import RestartModal from './restart';
import { WarningOutlined, PlusCircleOutlined, CopyOutlined, ExclamationCircleOutlined , SyncOutlined, UserOutlined, KeyOutlined } from '@ant-design/icons';
import { CosmosInputText, CosmosSelect } from './formShortcuts';
import { CosmosCheckbox, CosmosInputText, CosmosSelect } from './formShortcuts';
const ConfigManagement = () => {
@ -56,6 +56,7 @@ const ConfigManagement = () => {
initialValues={{
MongoDB: config.MongoDB,
LoggingLevel: config.LoggingLevel,
RequireMFA: config.RequireMFA,
Hostname: config.HTTPConfig.Hostname,
GenerateMissingTLSCert: config.HTTPConfig.GenerateMissingTLSCert,
@ -64,6 +65,7 @@ const ConfigManagement = () => {
HTTPSPort: config.HTTPConfig.HTTPSPort,
SSLEmail: config.HTTPConfig.SSLEmail,
HTTPSCertificateMode: config.HTTPConfig.HTTPSCertificateMode,
DNSChallengeProvider: config.HTTPConfig.DNSChallengeProvider,
}}
validationSchema={Yup.object().shape({
Hostname: Yup.string().max(255).required('Hostname is required'),
@ -76,6 +78,7 @@ const ConfigManagement = () => {
...config,
MongoDB: values.MongoDB,
LoggingLevel: values.LoggingLevel,
RequireMFA: values.RequireMFA,
HTTPConfig: {
...config.HTTPConfig,
Hostname: values.Hostname,
@ -84,6 +87,7 @@ const ConfigManagement = () => {
HTTPSPort: values.HTTPSPort,
SSLEmail: values.SSLEmail,
HTTPSCertificateMode: values.HTTPSCertificateMode,
DNSChallengeProvider: values.DNSChallengeProvider,
}
}
@ -117,6 +121,14 @@ const ConfigManagement = () => {
<Grid item xs={12}>
<Alert severity="info">This page allow you to edit the configuration file. Any Environment Variable overwritting configuration won't appear here.</Alert>
</Grid>
<CosmosCheckbox
label="Force Multi-Factor Authentication"
name="RequireMFA"
formik={formik}
helperText="Require MFA for all users"
/>
<Grid item xs={12}>
<Stack spacing={1}>
<InputLabel htmlFor="MongoDB-login">MongoDB connection string. It is advised to use Environment variable to store this securely instead. (Optional)</InputLabel>
@ -266,7 +278,7 @@ const ConfigManagement = () => {
]}
/>
{
{
formik.values.HTTPSCertificateMode === "LETSENCRYPT" && (
<CosmosInputText
name="SSLEmail"
@ -275,6 +287,16 @@ const ConfigManagement = () => {
/>
)
}
{
formik.values.HTTPSCertificateMode === "LETSENCRYPT" && (
<CosmosInputText
name="DNSChallengeProvider"
label="DNS provider (if you are using a DNS Challenge)"
formik={formik}
/>
)
}
<Grid item xs={12}>
<Stack direction="row" justifyContent="space-between" alignItems="center" spacing={2}>

View File

@ -138,7 +138,7 @@ const UserManagement = () => {
}).then(() => {
setOpenCreateForm(false);
refresh();
sendlink(document.getElementById('c-nickname').value, 'create');
sendlink(document.getElementById('c-nickname').value, 2);
});
}}>Create</Button>
</DialogActions>
@ -232,7 +232,14 @@ const UserManagement = () => {
setToAction(r.nickname);
setOpenDeleteForm(true);
}
}>Delete</Button></>
}>Delete</Button>
&nbsp;&nbsp;<Button variant="contained" color="error" onClick={
() => {
API.users.reset2FA(r.nickname).then(() => {
refresh();
});
}
}>Reset 2FA</Button></>
}
},
]}

View File

@ -325,8 +325,10 @@ const NewInstall = () => {
email: '',
}}
validationSchema={Yup.object().shape({
nickname: Yup.string().required('Nickname is required').min(3).max(32),
password: Yup.string().required('Password is required').min(8).max(128).matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*])(?=.{8,})/, 'Password must contain at least 1 lowercase, 1 uppercase, 1 number, and 1 special character'),
// nickname cant be admin or root
nickname: Yup.string().required('Nickname is required').min(3).max(32)
.matches(/^(?!admin|root).*$/, 'Nickname cannot be admin or root'),
password: Yup.string().required('Password is required').min(8).max(128).matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*])(?=.{9,})/, 'Password must contain 9 characters: at least 1 lowercase, 1 uppercase, 1 number, and 1 special character'),
email: Yup.string().email('Must be a valid email').max(255),
confirmPassword: Yup.string().oneOf([Yup.ref('password'), null], 'Passwords must match'),
})}

View File

@ -157,6 +157,7 @@ const ServeApps = () => {
AuthEnabled: false,
Timeout: 14400000,
ThrottlePerMinute: 10000,
BlockCommonBots: true,
SmartShield: {
Enabled: true,
}

View File

@ -7,6 +7,8 @@ import MinimalLayout from '../layout/MinimalLayout';
import Logout from '../pages/authentication/Logoff';
import NewInstall from '../pages/newInstall/newInstall';
import {NewMFA, MFALogin} from '../pages/authentication/newMFA';
// render - login
const AuthLogin = Loadable(lazy(() => import('../pages/authentication/Login')));
const AuthRegister = Loadable(lazy(() => import('../pages/authentication/Register')));
@ -31,9 +33,16 @@ const LoginRoutes = {
},
{
path: '/ui/newInstall',
// redirect to /ui
element: <NewInstall />
},
{
path: '/ui/newmfa',
element: <NewMFA />
},
{
path: '/ui/loginmfa',
element: <MFALogin />
},
]
};

View File

@ -36,6 +36,6 @@ export default function ComponentsOverrides(theme) {
Tab(theme),
TableCell(theme),
Tabs(),
Typography()
Typography(),
);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 141 KiB

View File

@ -12,6 +12,7 @@ WORKDIR /app
COPY build/cosmos .
COPY build/cosmos_gray.png .
COPY build/GeoLite2-Country.mmdb .
COPY build/meta.json .
COPY static ./static

View File

@ -12,6 +12,7 @@ WORKDIR /app
COPY build/cosmos .
COPY build/cosmos_gray.png .
COPY build/GeoLite2-Country.mmdb .
COPY build/meta.json .
COPY static ./static

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

4
go.mod
View File

@ -104,12 +104,14 @@ require (
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.0.2 // indirect
github.com/oracle/oci-go-sdk v24.3.0+incompatible // indirect
github.com/oschwald/geoip2-golang v1.8.0 // indirect
github.com/oschwald/maxminddb-golang v1.10.0 // indirect
github.com/ovh/go-ovh v1.1.0 // indirect
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/pquerna/otp v1.3.0 // indirect
github.com/pquerna/otp v1.4.0 // indirect
github.com/sacloud/libsacloud v1.36.2 // indirect
github.com/sirupsen/logrus v1.7.0 // indirect
github.com/stretchr/objx v0.5.0 // indirect

6
go.sum
View File

@ -465,6 +465,10 @@ github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJ
github.com/oracle/oci-go-sdk v24.2.0+incompatible/go.mod h1:VQb79nF8Z2cwLkLS35ukwStZIg5F66tcBccjip/j888=
github.com/oracle/oci-go-sdk v24.3.0+incompatible h1:x4mcfb4agelf1O4/1/auGlZ1lr97jXRSSN5MxTgG/zU=
github.com/oracle/oci-go-sdk v24.3.0+incompatible/go.mod h1:VQb79nF8Z2cwLkLS35ukwStZIg5F66tcBccjip/j888=
github.com/oschwald/geoip2-golang v1.8.0 h1:KfjYB8ojCEn/QLqsDU0AzrJ3R5Qa9vFlx3z6SLNcKTs=
github.com/oschwald/geoip2-golang v1.8.0/go.mod h1:R7bRvYjOeaoenAp9sKRS8GX5bJWcZ0laWO5+DauEktw=
github.com/oschwald/maxminddb-golang v1.10.0 h1:Xp1u0ZhqkSuopaKmk1WwHtjF0H9Hd9181uj2MQ5Vndg=
github.com/oschwald/maxminddb-golang v1.10.0/go.mod h1:Y2ELenReaLAZ0b400URyGwvYxHV1dLIxBuyOsyYjHK0=
github.com/ovh/go-ovh v1.1.0 h1:bHXZmw8nTgZin4Nv7JuaLs0KG5x54EQR7migYTd1zrk=
github.com/ovh/go-ovh v1.1.0/go.mod h1:AxitLZ5HBRPyUd+Zl60Ajaag+rNTdVXWIkzfrVuTXWA=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
@ -482,6 +486,8 @@ github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:Om
github.com/pquerna/otp v1.2.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/pquerna/otp v1.3.0 h1:oJV/SkzR33anKXwQU3Of42rL4wbrffP4uvUf1SvS5Xs=
github.com/pquerna/otp v1.3.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=

2138
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "cosmos-server",
"version": "0.2.0",
"version": "0.3.0-unstable",
"description": "",
"main": "test-server.js",
"bugs": {
@ -27,6 +27,7 @@
"history": "^5.3.0",
"lodash": "^4.17.21",
"prop-types": "^15.8.1",
"qrcode": "^1.5.3",
"react": "^18.2.0",
"react-apexcharts": "^1.4.0",
"react-copy-to-clipboard": "^5.1.0",

View File

@ -23,12 +23,13 @@ Whether you have a **server**, a **NAS**, or a **Raspberry Pi** with application
* **Easy to use** 🚀👍 to install and use, with a simple web UI to manage your applications
* **User-friendly** 🧑‍🎨 For both new and experienced users: easily integrates into your existing home server (even with NGinx, Traefik, Portainer, etc...), the already existing applications you have, and the new ones you want to install
* **SmartShield technology** 🧠🛡 Automatically secure your applications without manual adjustments (see below for more details)
* **Secure Authentication** 👦👩 Connect to all your applications with the same account, including **strong security** and **multi-factor authentication**
* **Latest Encryption Methods** 🔒🔑 To encrypt your data and protect your privacy. Security by design, and not as an afterthought
* **Reverse Proxy** 🔄🔗 Reverse Proxy included, with a UI to easily manage your applications and their settings
* **Automatic HTTPS** 🔑📜 certificates provisioning with Certbot / Let's Encrypt
* **Anti-Bot** 🤖❌ protections such as Captcha and IP rate limiting
* **Anti-DDOS** 🔥⛔️ protections such as variable timeouts/throttling, IP rate limiting and IP blacklisting
* **Anti-Bot** 🤖❌ Collection of tools to prevent bots from accessing your applications, such as common bot detection, IP based detection, and more
* **Anti-DDOS** 🔥⛔️ Additional protections such as variable timeouts/throttling, IP rate limiting and geo-blacklisting
* **Proper User Management** 🪪 ❎ to invite your friends and family to your applications without awkardly sharing credentials. Let them request a password change with an email rather than having you unlock their account manually!
* **Container Management** 🐋🔧 to easily manage your containers and their settings, keep them up to date as well as audit their security.
* **Modular** 🧩📦 to easily add new features and integrations, but also run only the features you need (for example No docker, no Databases, or no HTTPS)
@ -38,8 +39,21 @@ And a **lot more planned features** are coming!
![schema](./schema.png)
# What is the SmartShield?
# Why use it?
SmartShield is a modern API protection package designed to secure your API by implementing advanced rate-limiting and user restrictions. This helps efficiently allocate and protect your resources without manual adjustment of limits and policies.
Key Features:
* **Dynamic Rate Limiting** ✨ SmartShield calculates rate limits based on user behavior, providing a flexible approach to maintain API health without negatively impacting user experience.
* **Adaptive Actions** 📈 SmartShield automatically throttles users who exceed their rate limits, preventing them from consuming more resources than they are allowed without abruptly terminating their requests.
* **User Bans & Strikes** 🚫 Implement temporary or permanent bans and issue strikes automatically to prevent API abuse from malicious or resource-intensive users.
* **Global Request Control** 🌐 Monitor and limit the total number of simultaneous requests on your server, ensuring optimal performance and stability.
* **User-based Metrics** 📊 SmartShield tracks user consumption in terms of requests, data usage, and simultaneous connections, allowing for detailed control.
* **Privileged Access** 🔑 Assign privileged access to specific user groups, granting them exemption from certain restrictions and ensuring uninterrupted service even durin attacks.
* **Customizable Policies** ⚙️ Modify SmartShield's default policies to suit your specific needs, such as request limits, time budgets, and more.
# Why use Cosmos?
If you have your own self-hosted data, such as a Plex server, or may be your own photo server, **you expose your data to being hacked, or your server to being highjacked** (even on your **local network**!).
@ -67,6 +81,10 @@ If you have any further questions, feel free to join our [Discord](https://disco
Disclaimer: Cosmos is still in early Alpha stage, please be careful when you use it. It is not (yet, at least ;p) a replacement for proper control and mindfulness of your own security.
```
# Let's Encrypt
Cosmos Server can automatically generate and renews HTTPS certificates for your applications using Let's Encrypt. It is compatible with wildcard certificates, using the DNS challenge. In order to do it, you need to add `DNSChallengeProvider` to the `HTTPConfig` in your config (or in the UI). And then add the proper API token via environment variables. To know what providers are supported and what environment variable they need, please refer to [this page](https://go-acme.github.io/lego/dns/#dns-providers).
# As A Developer
**If you're a self-hosted application developer**, integrate your application with Cosmos and enjoy **secure authentication**, **robust HTTP layer protection**, **HTTPS support**, **user management**, **encryption**, **logging**, **backup**, and more - all with **minimal effort**. And if your users prefer **not to install** Cosmos, your application will **still work seamlessly**.
@ -87,7 +105,9 @@ make sure you expose the right ports (by default 80 / 443). It is best to keep t
You also need to keep the docker socket mounted, as Cosmos needs to be able to manage your containers.
you can use `latest-arm64` for arm architecture (ex: NAS or Raspberry)
you can use `latest-arm64` for arm architecture (ex: NAS or Raspberry).
Finally, if you are using Cosmos from one of the countries considered "high risk," you can prevent Cosmos from blocking your IP by adding the following environment variable to your Docker run command: `-e COSMOS_SERVER_COUNTRY=IN`. Replace "IN" with your country code. The following countries are blocked by default: China (CN), Russia (RU), Turkey (TR), Brazil (BR), Bangladesh (BD), India (IN), Nepal (NP), Pakistan (PK), Sri Lanka (LK), Vietnam (VN), Indonesia (ID), Iran (IR), Iraq (IQ), Egypt (EG), Afghanistan (AF), and Romania (RO). Please note that this choice is neither political nor personal; it is solely based on past attack statistics. If you are from one of these countries and want to use Cosmos, we sincerely apologize for the inconvenience. If you are having issues with this, please contact us on Discord!
You can tweak the config file accordingly. Some settings can be changed before end with env var. [see here](https://github.com/azukaar/Cosmos-Server/wiki/Configuration).

View File

@ -44,6 +44,11 @@ func startHTTPSServer(router *mux.Router, tlsCert string, tlsKey string) {
cfg.SSLEmail = config.HTTPConfig.SSLEmail
cfg.HTTPAddress = serverHostname+":"+serverPortHTTP
cfg.TLSAddress = serverHostname+":"+serverPortHTTPS
if config.HTTPConfig.DNSChallengeProvider != "" {
cfg.DNSProvider = config.HTTPConfig.DNSChallengeProvider
}
cfg.FailedToRenewCertificate = func(err error) {
utils.Error("Failed to renew certificate", err)
}
@ -119,8 +124,9 @@ func startHTTPSServer(router *mux.Router, tlsCert string, tlsKey string) {
func tokenMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
//Header.Del
r.Header.Set("x-cosmos-user", "")
r.Header.Set("x-cosmos-role", "")
r.Header.Del("x-cosmos-user")
r.Header.Del("x-cosmos-role")
r.Header.Del("x-cosmos-mfa")
u, err := user.RefreshUserToken(w, r)
@ -130,6 +136,7 @@ func tokenMiddleware(next http.Handler) http.Handler {
r.Header.Set("x-cosmos-user", u.Nickname)
r.Header.Set("x-cosmos-role", strconv.Itoa((int)(u.Role)))
r.Header.Set("x-cosmos-mfa", strconv.Itoa((int)(u.MFAState)))
next.ServeHTTP(w, r)
})
@ -137,19 +144,27 @@ func tokenMiddleware(next http.Handler) http.Handler {
func StartServer() {
baseMainConfig := utils.GetBaseMainConfig()
config := utils.GetMainConfig().HTTPConfig
serverPortHTTP = config.HTTPPort
serverPortHTTPS = config.HTTPSPort
config := utils.GetMainConfig()
HTTPConfig := config.HTTPConfig
serverPortHTTP = HTTPConfig.HTTPPort
serverPortHTTPS = HTTPConfig.HTTPSPort
var tlsCert = config.TLSCert
var tlsKey= config.TLSKey
var tlsCert = HTTPConfig.TLSCert
var tlsKey= HTTPConfig.TLSKey
if((tlsCert == "" || tlsKey == "") && config.HTTPSCertificateMode == utils.HTTPSCertModeList["SELFSIGNED"]) {
domains := utils.GetAllHostnames()
oldDomains := baseMainConfig.HTTPConfig.TLSKeyHostsCached
NeedsRefresh := (tlsCert == "" || tlsKey == "") || !utils.StringArrayEquals(domains, oldDomains)
if(NeedsRefresh && HTTPConfig.HTTPSCertificateMode == utils.HTTPSCertModeList["SELFSIGNED"]) {
utils.Log("Generating new TLS certificate")
pub, priv := utils.GenerateRSAWebCertificates()
pub, priv := utils.GenerateRSAWebCertificates(domains)
baseMainConfig.HTTPConfig.TLSCert = pub
baseMainConfig.HTTPConfig.TLSKey = priv
baseMainConfig.HTTPConfig.TLSKeyHostsCached = domains
utils.SetBaseMainConfig(baseMainConfig)
utils.Log("Saved new TLS certificate")
@ -158,7 +173,7 @@ func StartServer() {
tlsKey = priv
}
if ((config.AuthPublicKey == "" || config.AuthPrivateKey == "") && config.GenerateMissingAuthCert) {
if ((HTTPConfig.AuthPublicKey == "" || HTTPConfig.AuthPrivateKey == "") && HTTPConfig.GenerateMissingAuthCert) {
utils.Log("Generating new Auth ED25519 certificate")
pub, priv := utils.GenerateEd25519Certificates()
@ -176,6 +191,10 @@ func StartServer() {
// router.Use(middleware.Recoverer)
router.Use(middleware.Logger)
router.Use(utils.SetSecurityHeaders)
if config.BlockedCountries != nil && len(config.BlockedCountries) > 0 {
router.Use(utils.BlockByCountryMiddleware(config.BlockedCountries))
}
srapi := router.PathPrefix("/cosmos").Subrouter()
@ -188,6 +207,7 @@ func StartServer() {
srapi.HandleFunc("/api/register", user.UserRegister)
srapi.HandleFunc("/api/invite", user.UserResendInviteLink)
srapi.HandleFunc("/api/me", user.Me)
srapi.HandleFunc("/api/mfa", user.API2FA)
srapi.HandleFunc("/api/config", configapi.ConfigRoute)
srapi.HandleFunc("/api/restart", configapi.ConfigApiRestart)
@ -201,9 +221,15 @@ func StartServer() {
srapi.Use(proxy.SmartShieldMiddleware(
utils.SmartShieldPolicy{
Enabled: true,
PerUserSimultaneous: 3,
MaxGlobalSimultaneous: 12,
PolicyStrictness: 1,
PerUserRequestLimit: 5000,
},
))
srapi.Use(utils.MiddlewareTimeout(20 * time.Second))
srapi.Use(utils.BlockPostWithoutReferer)
srapi.Use(proxy.BotDetectionMiddleware)
srapi.Use(httprate.Limit(60, 1*time.Minute,
httprate.WithKeyFuncs(httprate.KeyByIP),
httprate.WithLimitHandler(func(w http.ResponseWriter, r *http.Request) {
@ -223,14 +249,14 @@ func StartServer() {
fs := spa.SpaHandler(pwd + "/static", "index.html")
router.PathPrefix("/ui").Handler(http.StripPrefix("/ui", fs))
router = proxy.BuildFromConfig(router, config.ProxyConfig)
router = proxy.BuildFromConfig(router, HTTPConfig.ProxyConfig)
router.HandleFunc("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/ui", http.StatusMovedPermanently)
}))
if ((config.HTTPSCertificateMode == utils.HTTPSCertModeList["SELFSIGNED"] || config.HTTPSCertificateMode == utils.HTTPSCertModeList["PROVIDED"]) &&
tlsCert != "" && tlsKey != "") || (config.HTTPSCertificateMode == utils.HTTPSCertModeList["LETSENCRYPT"]) {
if ((HTTPConfig.HTTPSCertificateMode == utils.HTTPSCertModeList["SELFSIGNED"] || HTTPConfig.HTTPSCertificateMode == utils.HTTPSCertModeList["PROVIDED"]) &&
tlsCert != "" && tlsKey != "") || (HTTPConfig.HTTPSCertificateMode == utils.HTTPSCertModeList["LETSENCRYPT"]) {
utils.Log("TLS certificate exist, starting HTTPS servers and redirecting HTTP to HTTPS")
startHTTPSServer(router, tlsCert, tlsKey)
} else {

View File

@ -36,7 +36,7 @@ type NewInstallJSON struct {
type AdminJSON struct {
Nickname string `validate:"required,min=3,max=32,alphanum"`
Password string `validate:"required,min=8,max=128,containsany=!@#$%^&*()_+,containsany=ABCDEFGHIJKLMNOPQRSTUVWXYZ,containsany=abcdefghijklmnopqrstuvwxyz,containsany=0123456789"`
Password string `validate:"required,min=9,max=128,containsany=!@#$%^&*()_+,containsany=ABCDEFGHIJKLMNOPQRSTUVWXYZ,containsany=abcdefghijklmnopqrstuvwxyz,containsany=0123456789"`
}
func NewInstallRoute(w http.ResponseWriter, req *http.Request) {

View File

@ -23,6 +23,8 @@ type SmartResponseWriterWrapper struct {
shield smartShieldState
policy utils.SmartShieldPolicy
isOver bool
hasBeenInterrupted bool
isPrivileged bool
}
func (w *SmartResponseWriterWrapper) IsOver() bool {
@ -49,26 +51,47 @@ func (w *SmartResponseWriterWrapper) WriteHeader(status int) {
if w.Status >= 400 {
w.RequestCost *= 30
}
w.ResponseWriter.WriteHeader(status)
if !w.IsOver() {
w.ResponseWriter.WriteHeader(status)
}
}
func (w *SmartResponseWriterWrapper) Write(p []byte) (int, error) {
userConsumed := shield.GetUserUsedBudgets(w.ClientID)
if !shield.isAllowedToReqest(w.policy, userConsumed) {
utils.Log(fmt.Sprintf("SmartShield: %s is banned", w.ClientID))
if !w.isPrivileged && !shield.isAllowedToReqest(w.policy, userConsumed) {
utils.Log(fmt.Sprintf("SmartShield: %s has been blocked due to abuse", w.ClientID))
w.isOver = true
w.TimeEnded = time.Now()
w.hasBeenInterrupted = true
w.ResponseWriter.WriteHeader(http.StatusServiceUnavailable)
w.ResponseWriter.(http.Flusher).Flush()
return 0, errors.New("Pending request cancelled due to SmartShield")
}
thro := shield.computeThrottle(w.policy, userConsumed)
thro := 0
if !w.isPrivileged {
shield.computeThrottle(w.policy, userConsumed)
}
// initial throttle
if w.ThrottleNext > 0 {
time.Sleep(time.Duration(w.ThrottleNext) * time.Millisecond)
}
w.ThrottleNext = 0
// ongoing throttle
if thro > 0 {
time.Sleep(time.Duration(thro) * time.Millisecond)
}
n, err := w.ResponseWriter.Write(p)
if err != nil {
w.isOver = true
w.TimeEnded = time.Now()
w.hasBeenInterrupted = true
}
w.Bytes += int64(n)
return n, err
}

45
src/proxy/botblock.go Normal file
View File

@ -0,0 +1,45 @@
package proxy
import (
"net/http"
)
var botUserAgents = []string{
"360Spider", "acapbot", "acoonbot", "ahrefs", "alexibot", "asterias", "attackbot", "backdorbot", "becomebot", "binlar",
"blackwidow", "blekkobot", "blexbot", "blowfish", "bullseye", "bunnys", "butterfly", "careerbot", "casper", "checkpriv",
"cheesebot", "cherrypick", "chinaclaw", "choppy", "clshttp", "cmsworld", "copernic", "copyrightcheck", "cosmos", "crescent",
"cy_cho", "datacha", "demon", "diavol", "discobot", "dittospyder", "dotbot", "dotnetdotcom", "dumbot", "emailcollector",
"emailsiphon", "emailwolf", "exabot", "extract", "eyenetie", "feedfinder", "flaming", "flashget", "flicky", "foobot",
"g00g1e", "getright", "gigabot", "go-ahead-got", "gozilla", "grabnet", "grafula", "harvest", "heritrix", "httrack",
"icarus6j", "jetbot", "jetcar", "jikespider", "kmccrew", "leechftp", "libweb", "linkextractor", "linkscan", "linkwalker",
"loader", "masscan", "miner", "majestic", "mechanize", "mj12bot", "morfeus", "moveoverbot", "netmechanic", "netspider",
"nicerspro", "nikto", "ninja", "nutch", "octopus", "pagegrabber", "planetwork", "postrank", "proximic", "purebot",
"pycurl", "python", "queryn", "queryseeker", "radian6", "radiation", "realdownload", "rogerbot", "scooter", "seekerspider",
"semalt", "siclab", "sindice", "sistrix", "sitebot", "siteexplorer", "sitesnagger", "skygrid", "smartdownload", "snoopy",
"sosospider", "spankbot", "spbot", "sqlmap", "stackrambler", "stripper", "sucker", "surftbot", "sux0r", "suzukacz",
"suzuran", "takeout", "teleport", "telesoft", "true_robots", "turingos", "turnit", "vampire", "vikspider", "voideye",
"webleacher", "webreaper", "webstripper", "webvac", "webviewer", "webwhacker", "winhttp", "wwwoffle", "woxbot",
"xaldon", "xxxyy", "yamanalab", "yioopbot", "youda", "zeus", "zmeu", "zune", "zyborg",
}
// botDetectionMiddleware checks if the User-Agent is a known bot
func BotDetectionMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userAgent := r.UserAgent()
if userAgent == "" {
http.Error(w, "Access denied: Bots are not allowed.", http.StatusForbidden)
return
}
for _, botUserAgent := range botUserAgents {
if userAgent == botUserAgent {
http.Error(w, "Access denied: Bots are not allowed.", http.StatusForbidden)
return
}
}
// If no bot user agent is detected, pass the request to the next handler
next.ServeHTTP(w, r)
})
}

View File

@ -15,8 +15,9 @@ import (
func tokenMiddleware(enabled bool) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.Header.Set("x-cosmos-user", "")
r.Header.Set("x-cosmos-role", "")
r.Header.Del("x-cosmos-user")
r.Header.Del("x-cosmos-role")
r.Header.Del("x-cosmos-mfa")
u, err := user.RefreshUserToken(w, r)
@ -26,6 +27,7 @@ func tokenMiddleware(enabled bool) func(next http.Handler) http.Handler {
r.Header.Set("x-cosmos-user", u.Nickname)
r.Header.Set("x-cosmos-role", strconv.Itoa((int)(u.Role)))
r.Header.Set("x-cosmos-mfa", strconv.Itoa((int)(u.MFAState)))
ogcookies := r.Header.Get("Cookie")
cookieRemoveRegex := regexp.MustCompile(`jwttoken=[^;]*;`)
@ -106,7 +108,17 @@ func RouterGen(route utils.ProxyRouteConfig, router *mux.Router, destination htt
destination = utils.BandwithLimiterMiddleware(route.MaxBandwith)(destination)
}
origin.Handler(tokenMiddleware(route.AuthEnabled)(utils.CORSHeader(originCORS)((destination))))
if route.BlockCommonBots {
destination = BotDetectionMiddleware(destination)
}
if route.BlockAPIAbuse {
destination = utils.BlockPostWithoutReferer(destination)
}
destination = tokenMiddleware(route.AuthEnabled)(utils.CORSHeader(originCORS)((destination)))
origin.Handler(destination)
utils.Log("Added route: [" + (string)(route.Mode) + "] " + route.Host + route.PathPrefix + " to " + route.Target + "")

View File

@ -38,10 +38,29 @@ type userUsedBudget struct {
Time float64
Requests int
Bytes int64
Simultaneous int
}
var shield smartShieldState
func (shield *smartShieldState) GetServerNbReq() int {
shield.Lock()
defer shield.Unlock()
nbRequests := 0
for i := len(shield.requests) - 1; i >= 0; i-- {
request := shield.requests[i]
if(request.IsOld()) {
return nbRequests
}
if(!request.IsOver()) {
nbRequests++
}
}
return nbRequests
}
func (shield *smartShieldState) GetUserUsedBudgets(ClientID string) userUsedBudget {
shield.Lock()
defer shield.Unlock()
@ -51,6 +70,7 @@ func (shield *smartShieldState) GetUserUsedBudgets(ClientID string) userUsedBudg
Time: 0,
Requests: 0,
Bytes: 0,
Simultaneous: 0,
}
// Check for recent requests
@ -64,6 +84,7 @@ func (shield *smartShieldState) GetUserUsedBudgets(ClientID string) userUsedBudg
userConsumed.Time += request.TimeEnded.Sub(request.TimeStarted).Seconds()
} else {
userConsumed.Time += time.Now().Sub(request.TimeStarted).Seconds()
userConsumed.Simultaneous++
}
userConsumed.Requests += request.RequestCost
userConsumed.Bytes += request.Bytes
@ -125,7 +146,8 @@ func (shield *smartShieldState) isAllowedToReqest(policy utils.SmartShieldPolicy
// Check for new strikes
if (userConsumed.Time > (policy.PerUserTimeBudget * float64(policy.PolicyStrictness))) ||
(userConsumed.Requests > (policy.PerUserRequestLimit * policy.PolicyStrictness)) ||
(userConsumed.Bytes > (policy.PerUserByteLimit * int64(policy.PolicyStrictness))) {
(userConsumed.Bytes > (policy.PerUserByteLimit * int64(policy.PolicyStrictness))) ||
(userConsumed.Simultaneous > (policy.PerUserSimultaneous * policy.PolicyStrictness)) {
shield.bans = append(shield.bans, &userBan{
ClientID: ClientID,
banType: STRIKE,
@ -155,7 +177,16 @@ func (shield *smartShieldState) computeThrottle(policy utils.SmartShieldPolicy,
overByte := policy.PerUserByteLimit - userConsumed.Bytes
overByteRatio := float64(overByte) / float64(policy.PerUserByteLimit)
if overByte < 0 {
newThrottle := int(float64(150) * -overByteRatio)
newThrottle := int(float64(40) * -overByteRatio)
if newThrottle > throttle {
throttle = newThrottle
}
}
overSim := policy.PerUserSimultaneous - userConsumed.Simultaneous
overSimRatio := float64(overSim) / float64(policy.PerUserSimultaneous)
if overSim < 0 {
newThrottle := int(float64(20) * -overSimRatio)
if newThrottle > throttle {
throttle = newThrottle
}
@ -185,6 +216,11 @@ func GetClientID(r *http.Request) string {
return ip
}
func isPrivileged(req *http.Request, policy utils.SmartShieldPolicy) bool {
role, _ := strconv.Atoi(req.Header.Get("x-cosmos-role"))
return role >= policy.PrivilegedGroups
}
func SmartShieldMiddleware(policy utils.SmartShieldPolicy) func(http.Handler) http.Handler {
if policy.Enabled == false {
return func(next http.Handler) http.Handler {
@ -198,25 +234,46 @@ func SmartShieldMiddleware(policy utils.SmartShieldPolicy) func(http.Handler) ht
policy.PerUserRequestLimit = 6000 // 100 requests per minute
}
if(policy.PerUserByteLimit == 0) {
policy.PerUserByteLimit = 3 * 60 * 1024 * 1024 * 1024 // 180GB
policy.PerUserByteLimit = 150 * 1024 * 1024 * 1024 // 150GB
}
if(policy.PolicyStrictness == 0) {
policy.PolicyStrictness = 2 // NORMAL
}
if(policy.PerUserSimultaneous == 0) {
policy.PerUserSimultaneous = 2
}
if(policy.MaxGlobalSimultaneous == 0) {
policy.MaxGlobalSimultaneous = 50
}
if(policy.PrivilegedGroups == 0) {
policy.PrivilegedGroups = utils.ADMIN
}
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
utils.Log("SmartShield: Request received")
currentGlobalRequests := shield.GetServerNbReq() + 1
utils.Debug(fmt.Sprintf("SmartShield: Current global requests: %d", currentGlobalRequests))
if currentGlobalRequests > policy.MaxGlobalSimultaneous && !isPrivileged(r, policy) {
utils.Log("SmartShield: Too many users on the server")
http.Error(w, "Too many requests", http.StatusTooManyRequests)
return
}
clientID := GetClientID(r)
userConsumed := shield.GetUserUsedBudgets(clientID)
if !shield.isAllowedToReqest(policy, userConsumed) {
utils.Log("SmartShield: User is banned")
if !isPrivileged(r, policy) && !shield.isAllowedToReqest(policy, userConsumed) {
utils.Log("SmartShield: User is blocked due to abuse")
http.Error(w, "Too many requests", http.StatusTooManyRequests)
return
} else {
utils.Debug("SmartShield: Creating request")
throttle := shield.computeThrottle(policy, userConsumed)
throttle := 0
if(!isPrivileged(r, policy)) {
throttle = shield.computeThrottle(policy, userConsumed)
}
wrapper := &SmartResponseWriterWrapper {
ResponseWriter: w,
ThrottleNext: throttle,
@ -226,6 +283,7 @@ func SmartShieldMiddleware(policy utils.SmartShieldPolicy) func(http.Handler) ht
Method: r.Method,
shield: shield,
policy: policy,
isPrivileged: isPrivileged(r, policy),
}
// add rate limite headers
@ -234,19 +292,26 @@ func SmartShieldMiddleware(policy utils.SmartShieldPolicy) func(http.Handler) ht
w.Header().Set("X-RateLimit-Limit", strconv.FormatInt(int64(policy.PerUserRequestLimit), 10))
w.Header().Set("X-RateLimit-Reset", In20Minutes)
utils.Debug("SmartShield: Adding request")
shield.Lock()
shield.requests = append(shield.requests, wrapper)
shield.Unlock()
utils.Debug("SmartShield: Processing request")
next.ServeHTTP(wrapper, r)
ctx := r.Context()
done := make(chan struct{})
shield.Lock()
wrapper.TimeEnded = time.Now()
wrapper.isOver = true
shield.Unlock()
utils.Debug("SmartShield: Request finished")
go (func() {
select {
case <-ctx.Done():
case <-done:
}
shield.Lock()
wrapper.TimeEnded = time.Now()
wrapper.isOver = true
shield.Unlock()
})()
next.ServeHTTP(wrapper, r)
close(done)
}
})
}

88
src/user/2fa_check.go Normal file
View File

@ -0,0 +1,88 @@
package user
import (
"encoding/json"
"net/http"
"github.com/azukaar/cosmos-server/src/utils"
"github.com/pquerna/otp/totp"
)
type User2FACheckRequest struct {
Token string
}
func Check2FA(w http.ResponseWriter, req *http.Request) {
if utils.LoggedInWeakOnly(w, req) != nil {
return
}
nickname := req.Header.Get("x-cosmos-user")
var request User2FACheckRequest
errD := json.NewDecoder(req.Body).Decode(&request)
if errD != nil {
utils.Error("2FA Error: Invalid User Request", errD)
utils.HTTPError(w, "2FA Error", http.StatusInternalServerError, "2FA001")
return
}
c, errCo := utils.GetCollection(utils.GetRootAppId(), "users")
if errCo != nil {
utils.Error("Database Connect", errCo)
utils.HTTPError(w, "Database", http.StatusInternalServerError, "DB001")
return
}
userInBase := utils.User{}
err := c.FindOne(nil, map[string]interface{}{
"Nickname": nickname,
}).Decode(&userInBase)
if err != nil {
utils.Error("UserGet: Error while getting user", err)
utils.HTTPError(w, "User Get Error", http.StatusInternalServerError, "2FA002")
return
}
if(userInBase.MFAKey == "") {
utils.Error("2FA: User " + nickname + " has no key", nil)
utils.HTTPError(w, "2FA Error", http.StatusInternalServerError, "2FA003")
return
}
valid := totp.Validate(request.Token, userInBase.MFAKey)
if valid {
utils.Log("2FA: User " + nickname + " has valid token")
if(!userInBase.Was2FAVerified) {
toSet := map[string]interface{}{
"Was2FAVerified": true,
}
_, err = c.UpdateOne(nil, map[string]interface{}{
"Nickname": nickname,
}, map[string]interface{}{
"$set": toSet,
})
if err != nil {
utils.Error("2FA: Cannot update user", err)
utils.HTTPError(w, "2FA Error", http.StatusInternalServerError, "2FA004")
return
}
}
SendUserToken(w, userInBase, true)
json.NewEncoder(w).Encode(map[string]interface{}{
"status": "OK",
})
} else {
utils.Error("2FA: User " + nickname + " has invalid token", nil)
utils.HTTPError(w, "2FA Error", http.StatusInternalServerError, "2FA005")
return
}
}

84
src/user/2fa_new.go Normal file
View File

@ -0,0 +1,84 @@
package user
import (
"encoding/json"
"net/http"
"time"
"math/rand"
"github.com/azukaar/cosmos-server/src/utils"
"github.com/pquerna/otp/totp"
)
func New2FA(w http.ResponseWriter, req *http.Request) {
if utils.LoggedInWeakOnly(w, req) != nil {
return
}
time.Sleep(time.Duration(rand.Float64()*2)*time.Second)
nickname := req.Header.Get("x-cosmos-user")
key, err := totp.Generate(totp.GenerateOpts{
Issuer: "Cosmos " + utils.GetMainConfig().HTTPConfig.Hostname,
AccountName: nickname,
})
if err != nil {
utils.Error("2FA: Cannot generate key", err)
utils.HTTPError(w, "2FA Error", http.StatusInternalServerError, "2FA001")
return
}
utils.Log("2FA: New key generated for " + nickname)
toSet := map[string]interface{}{
"MFAKey": key.Secret(),
"Was2FAVerified": false,
}
c, errCo := utils.GetCollection(utils.GetRootAppId(), "users")
if errCo != nil {
utils.Error("Database Connect", errCo)
utils.HTTPError(w, "Database", http.StatusInternalServerError, "DB001")
return
}
userInBase := utils.User{}
err = c.FindOne(nil, map[string]interface{}{
"Nickname": nickname,
}).Decode(&userInBase)
if err != nil {
utils.Error("UserGet: Error while getting user", err)
utils.HTTPError(w, "User Get Error", http.StatusInternalServerError, "UD001")
return
}
if(userInBase.MFAKey != "" && userInBase.Was2FAVerified) {
if utils.LoggedInOnly(w, req) != nil {
return
}
}
_, err = c.UpdateOne(nil, map[string]interface{}{
"Nickname": nickname,
}, map[string]interface{}{
"$set": toSet,
})
if err != nil {
utils.Error("2FA: Cannot update user", err)
utils.HTTPError(w, "2FA Error", http.StatusInternalServerError, "2FA002")
return
}
utils.Log("2FA: User " + nickname + " updated")
json.NewEncoder(w).Encode(map[string]interface{}{
"status": "OK",
"data": map[string]interface{}{
"key": key.URL(),
},
})
}

69
src/user/2fa_reset.go Normal file
View File

@ -0,0 +1,69 @@
package user
import (
"encoding/json"
"net/http"
"github.com/azukaar/cosmos-server/src/utils"
)
type User2FAResetRequest struct {
Nickname string
}
func Delete2FA(w http.ResponseWriter, req *http.Request) {
if utils.AdminOnly(w, req) != nil {
return
}
var request User2FAResetRequest
errD := json.NewDecoder(req.Body).Decode(&request)
if errD != nil {
utils.Error("2FA Error: Invalid User Request", errD)
utils.HTTPError(w, "2FA Error", http.StatusInternalServerError, "2FA001")
return
}
nickname := request.Nickname
c, errCo := utils.GetCollection(utils.GetRootAppId(), "users")
if errCo != nil {
utils.Error("Database Connect", errCo)
utils.HTTPError(w, "Database", http.StatusInternalServerError, "DB001")
return
}
userInBase := utils.User{}
err := c.FindOne(nil, map[string]interface{}{
"Nickname": nickname,
}).Decode(&userInBase)
if err != nil {
utils.Error("UserGet: Error while getting user", err)
utils.HTTPError(w, "User Get Error", http.StatusInternalServerError, "2FA002")
return
}
toSet := map[string]interface{}{
"Was2FAVerified": false,
"MFAKey": "",
"PasswordCycle": userInBase.PasswordCycle + 1,
}
_, err = c.UpdateOne(nil, map[string]interface{}{
"Nickname": nickname,
}, map[string]interface{}{
"$set": toSet,
})
if err != nil {
utils.Error("UserGet: Error while getting user", err)
utils.HTTPError(w, "User Get Error", http.StatusInternalServerError, "2FA002")
return
}
json.NewEncoder(w).Encode(map[string]interface{}{
"status": "OK",
})
}

View File

@ -7,6 +7,7 @@ import (
"go.mongodb.org/mongo-driver/mongo"
"golang.org/x/crypto/bcrypt"
"time"
"github.com/azukaar/cosmos-server/src/utils"
)
@ -69,7 +70,7 @@ func UserLogin(w http.ResponseWriter, req *http.Request) {
return
}
SendUserToken(w, user)
SendUserToken(w, user, false)
json.NewEncoder(w).Encode(map[string]interface{}{
"status": "OK",

View File

@ -13,7 +13,7 @@ import (
type RegisterRequestJSON struct {
Nickname string `validate:"required,min=3,max=32,alphanum"`
Password string `validate:"required,min=8,max=128,containsany=!@#$%^&*()_+,containsany=ABCDEFGHIJKLMNOPQRSTUVWXYZ,containsany=abcdefghijklmnopqrstuvwxyz,containsany=0123456789"`
Password string `validate:"required,min=9,max=128,containsany=!@#$%^&*()_+,containsany=ABCDEFGHIJKLMNOPQRSTUVWXYZ,containsany=abcdefghijklmnopqrstuvwxyz,containsany=0123456789"`
RegisterKey string `validate:"required,min=1,max=512,alphanum"`
}

View File

@ -10,16 +10,18 @@ import (
"encoding/json"
)
func RefreshUserToken(w http.ResponseWriter, req *http.Request) (utils.User, error) {
// if(utils.DB != nil) {
// return utils.User{
// Nickname: "noname",
// Role: utils.ADMIN,
// }, nil
// }
func quickLoggout(w http.ResponseWriter, req *http.Request, err error) (utils.User, error) {
utils.Error("UserToken: Token likely falsified", err)
logOutUser(w)
redirectToReLogin(w, req)
return utils.User{}, errors.New("Token likely falsified")
}
func RefreshUserToken(w http.ResponseWriter, req *http.Request) (utils.User, error) {
config := utils.GetMainConfig()
// if new install
if utils.GetMainConfig().NewInstall {
if config.NewInstall {
// check route
if req.URL.Path != "/cosmos/api/status" && req.URL.Path != "/cosmos/api/newInstall" {
json.NewEncoder(w).Encode(map[string]interface{}{
@ -56,10 +58,9 @@ func RefreshUserToken(w http.ResponseWriter, req *http.Request) (utils.User, err
errT := jwt.SigningMethodEdDSA.Verify(strings.Join(parts[0:2], "."), parts[2], ed25519Key)
if errT != nil {
utils.Error("UserToken: Token likely falsified", errT)
logOutUser(w)
redirectToReLogin(w, req)
return utils.User{}, errors.New("Token likely falsified")
if _, e := quickLoggout(w, req, errT); e != nil {
return utils.User{}, errT
}
}
type claimsType struct {
@ -79,8 +80,32 @@ func RefreshUserToken(w http.ResponseWriter, req *http.Request) (utils.User, err
return utils.User{}, errors.New("Token not valid")
}
nickname := claims["nickname"].(string)
passwordCycle := int(claims["passwordCycle"].(float64))
var (
nickname string
passwordCycle int
mfaDone bool
ok bool
)
if nickname, ok = claims["nickname"].(string); !ok {
if _, e := quickLoggout(w, req, nil); e != nil {
return utils.User{}, e
}
}
if passwordCycleFloat, ok := claims["passwordCycle"].(float64); ok {
passwordCycle = int(passwordCycleFloat)
} else {
if _, e := quickLoggout(w, req, nil); e != nil {
return utils.User{}, e
}
}
if mfaDone, ok = claims["mfaDone"].(bool); !ok {
if _, e := quickLoggout(w, req, nil); e != nil {
return utils.User{}, e
}
}
userInBase := utils.User{}
@ -109,6 +134,23 @@ func RefreshUserToken(w http.ResponseWriter, req *http.Request) (utils.User, err
return utils.User{}, errors.New("Password cycle changed, token is too old")
}
requestURL := req.URL.Path
isSettingMFA := strings.HasPrefix(requestURL, "/ui/loginmfa") || strings.HasPrefix(requestURL, "/ui/newmfa") || strings.HasPrefix(requestURL, "/api/mfa")
userInBase.MFAState = 0
if !isSettingMFA && (userInBase.MFAKey != "" && userInBase.Was2FAVerified && !mfaDone) {
utils.Warn("UserToken: MFA required")
userInBase.MFAState = 1
} else if !isSettingMFA && (config.RequireMFA && !mfaDone) {
utils.Warn("UserToken: MFA not set")
userInBase.MFAState = 2
}
if time.Now().Unix() - int64(claims["iat"].(float64)) > 3600 {
SendUserToken(w, userInBase, mfaDone)
}
return userInBase, nil
}
@ -140,7 +182,15 @@ func redirectToReLogin(w http.ResponseWriter, req *http.Request) {
http.Redirect(w, req, "/ui/login?invalid=1&redirect=" + req.URL.Path + "&" + req.URL.RawQuery, http.StatusTemporaryRedirect)
}
func SendUserToken(w http.ResponseWriter, user utils.User) {
func redirectToLoginMFA(w http.ResponseWriter, req *http.Request) {
http.Redirect(w, req, "/ui/loginmfa?invalid=1&redirect=" + req.URL.Path + "&" + req.URL.RawQuery, http.StatusTemporaryRedirect)
}
func redirectToNewMFA(w http.ResponseWriter, req *http.Request) {
http.Redirect(w, req, "/ui/newmfa?invalid=1&redirect=" + req.URL.Path + "&" + req.URL.RawQuery, http.StatusTemporaryRedirect)
}
func SendUserToken(w http.ResponseWriter, user utils.User, mfaDone bool) {
expiration := time.Now().Add(3 * 24 * time.Hour)
token := jwt.New(jwt.SigningMethodEdDSA)
@ -151,6 +201,7 @@ func SendUserToken(w http.ResponseWriter, user utils.User) {
claims["passwordCycle"] = user.PasswordCycle
claims["iat"] = time.Now().Unix()
claims["nbf"] = time.Now().Unix()
claims["mfaDone"] = mfaDone
key, err5 := jwt.ParseEdPrivateKeyFromPEM([]byte(utils.GetPrivateAuthKey()))

View File

@ -29,4 +29,18 @@ func UsersRoute(w http.ResponseWriter, req *http.Request) {
utils.HTTPError(w, "Method not allowed", http.StatusMethodNotAllowed, "HTTP001")
return
}
}
func API2FA(w http.ResponseWriter, req *http.Request) {
if(req.Method == "POST") {
Check2FA(w, req)
} else if (req.Method == "GET") {
New2FA(w, req)
} else if (req.Method == "DELETE") {
Delete2FA(w, req)
} else {
utils.Error("API2FARoute: Method not allowed" + req.Method, nil)
utils.HTTPError(w, "Method not allowed", http.StatusMethodNotAllowed, "HTTP001")
return
}
}

View File

@ -12,7 +12,7 @@ import (
"encoding/asn1"
)
func GenerateRSAWebCertificates() (string, string) {
func GenerateRSAWebCertificates(domains []string) (string, string) {
// generate self signed certificate
Log("Generating RSA Web Certificates for " + GetMainConfig().HTTPConfig.Hostname)
@ -57,7 +57,7 @@ func GenerateRSAWebCertificates() (string, string) {
PermittedDNSDomainsCritical: false,
PermittedDNSDomains: []string{GetMainConfig().HTTPConfig.Hostname},
PermittedDNSDomains: domains,
// PermittedIPRanges: ,

128
src/utils/loggedIn.go Normal file
View File

@ -0,0 +1,128 @@
package utils
import (
"errors"
"net/http"
"strconv"
)
func LoggedInOnlyWithRedirect(w http.ResponseWriter, req *http.Request) error {
userNickname := req.Header.Get("x-cosmos-user")
role, _ := strconv.Atoi(req.Header.Get("x-cosmos-role"))
mfa, _ := strconv.Atoi(req.Header.Get("x-cosmos-mfa"))
isUserLoggedIn := role > 0
if !isUserLoggedIn || userNickname == "" {
Error("LoggedInOnlyWithRedirect: User is not logged in", nil)
http.Redirect(w, req, "/ui/login?notlogged=1&redirect="+req.URL.Path, http.StatusFound)
}
if(mfa == 1) {
http.Redirect(w, req, "/ui/loginmfa?invalid=1&redirect=" + req.URL.Path + "&" + req.URL.RawQuery, http.StatusTemporaryRedirect)
return errors.New("User requires MFA")
} else if(mfa == 2) {
http.Redirect(w, req, "/ui/newmfa?invalid=1&redirect=" + req.URL.Path + "&" + req.URL.RawQuery, http.StatusTemporaryRedirect)
return errors.New("User requires MFA Setup")
}
return nil
}
func LoggedInWeakOnly(w http.ResponseWriter, req *http.Request) error {
userNickname := req.Header.Get("x-cosmos-user")
role, _ := strconv.Atoi(req.Header.Get("x-cosmos-role"))
isUserLoggedIn := role > 0
if !isUserLoggedIn || userNickname == "" {
Error("LoggedInOnly: User is not logged in", nil)
HTTPError(w, "User not logged in", http.StatusUnauthorized, "HTTP004")
return errors.New("User not logged in")
}
return nil
}
func LoggedInOnly(w http.ResponseWriter, req *http.Request) error {
userNickname := req.Header.Get("x-cosmos-user")
role, _ := strconv.Atoi(req.Header.Get("x-cosmos-role"))
mfa, _ := strconv.Atoi(req.Header.Get("x-cosmos-mfa"))
isUserLoggedIn := role > 0
if !isUserLoggedIn || userNickname == "" {
Error("LoggedInOnly: User is not logged in", nil)
HTTPError(w, "User not logged in", http.StatusUnauthorized, "HTTP004")
return errors.New("User not logged in")
}
if(mfa == 1) {
HTTPError(w, "User not logged in (MFA)", http.StatusUnauthorized, "HTTP006")
return errors.New("User requires MFA")
} else if(mfa == 2) {
HTTPError(w, "User requires MFA Setup", http.StatusUnauthorized, "HTTP007")
return errors.New("User requires MFA Setup")
}
return nil
}
func AdminOnly(w http.ResponseWriter, req *http.Request) error {
userNickname := req.Header.Get("x-cosmos-user")
role, _ := strconv.Atoi(req.Header.Get("x-cosmos-role"))
mfa, _ := strconv.Atoi(req.Header.Get("x-cosmos-mfa"))
isUserLoggedIn := role > 0
isUserAdmin := role > 1
if !isUserLoggedIn || userNickname == "" {
Error("AdminOnly: User is not logged in", nil)
//http.Redirect(w, req, "/login?notlogged=1&redirect=" + req.URL.Path, http.StatusFound)
HTTPError(w, "User not logged in", http.StatusUnauthorized, "HTTP004")
return errors.New("User not logged in")
}
if isUserLoggedIn && !isUserAdmin {
Error("AdminOnly: User is not admin", nil)
HTTPError(w, "User unauthorized", http.StatusUnauthorized, "HTTP005")
return errors.New("User not Admin")
}
if(mfa == 1) {
HTTPError(w, "User not logged in (MFA)", http.StatusUnauthorized, "HTTP006")
return errors.New("User requires MFA")
} else if(mfa == 2) {
HTTPError(w, "User requires MFA Setup", http.StatusUnauthorized, "HTTP007")
return errors.New("User requires MFA Setup")
}
return nil
}
func AdminOrItselfOnly(w http.ResponseWriter, req *http.Request, nickname string) error {
userNickname := req.Header.Get("x-cosmos-user")
role, _ := strconv.Atoi(req.Header.Get("x-cosmos-role"))
mfa, _ := strconv.Atoi(req.Header.Get("x-cosmos-mfa"))
isUserLoggedIn := role > 0
isUserAdmin := role > 1
if !isUserLoggedIn || userNickname == "" {
Error("AdminOrItselfOnly: User is not logged in", nil)
HTTPError(w, "User not logged in", http.StatusUnauthorized, "HTTP004")
return errors.New("User not logged in")
}
if nickname != userNickname && !isUserAdmin {
Error("AdminOrItselfOnly: User is not admin", nil)
HTTPError(w, "User unauthorized", http.StatusUnauthorized, "HTTP005")
return errors.New("User not Admin")
}
if(mfa == 1) {
HTTPError(w, "User not logged in (MFA)", http.StatusUnauthorized, "HTTP006")
return errors.New("User requires MFA")
} else if(mfa == 2) {
HTTPError(w, "User requires MFA Setup", http.StatusUnauthorized, "HTTP007")
return errors.New("User requires MFA Setup")
}
return nil
}

View File

@ -4,7 +4,10 @@ import (
"context"
"net/http"
"time"
"net"
"github.com/mxk/go-flowrate/flowrate"
"github.com/oschwald/geoip2-golang"
)
// https://github.com/go-chi/chi/blob/master/middleware/timeout.go
@ -95,3 +98,72 @@ func AcceptHeader(accept string) func(next http.Handler) http.Handler {
})
}
}
// GetIPLocation returns the ISO country code for a given IP address.
func GetIPLocation(ip string) (string, error) {
geoDB, err := geoip2.Open("GeoLite2-Country.mmdb")
if err != nil {
return "", err
}
defer geoDB.Close()
parsedIP := net.ParseIP(ip)
record, err := geoDB.Country(parsedIP)
if err != nil {
return "", err
}
return record.Country.IsoCode, nil
}
// BlockByCountryMiddleware returns a middleware function that blocks requests from specified countries.
func BlockByCountryMiddleware(blockedCountries []string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
countryCode, err := GetIPLocation(ip)
if err == nil {
if countryCode == "" {
Debug("Country code is empty")
} else {
Debug("Country code: " + countryCode)
}
config := GetMainConfig()
for _, blockedCountry := range blockedCountries {
if config.ServerCountry != countryCode && countryCode == blockedCountry {
http.Error(w, "Access denied", http.StatusForbidden)
return
}
}
} else {
Warn("Missing geolocation information to block IPs")
}
next.ServeHTTP(w, r)
})
}
}
// blockPostWithoutReferer blocks POST requests without a Referer header
func BlockPostWithoutReferer(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" || r.Method == "PUT" || r.Method == "PATCH" || r.Method == "DELETE" {
referer := r.Header.Get("Referer")
if referer == "" {
http.Error(w, "Bad Request: Invalid request.", http.StatusBadRequest)
return
}
}
// If it's not a POST request or the POST request has a Referer header, pass the request to the next handler
next.ServeHTTP(w, r)
})
}

View File

@ -68,6 +68,9 @@ type User struct {
LastPasswordChangedAt time.Time `json:"lastPasswordChangedAt"`
CreatedAt time.Time `json:"createdAt"`
LastLogin time.Time `json:"lastLogin"`
MFAKey string `json:"-"`
Was2FAVerified bool `json:"-"`
MFAState int `json:"-"` // 0 = done, 1 = needed, 2 = not set
}
type Config struct {
@ -77,15 +80,20 @@ type Config struct {
NewInstall bool `validate:"boolean"`
HTTPConfig HTTPConfig `validate:"required,dive,required"`
DockerConfig DockerConfig
BlockedCountries []string
ServerCountry string
RequireMFA bool
}
type HTTPConfig struct {
TLSCert string `validate:"omitempty,contains=\n`
TLSKey string
TLSKeyHostsCached []string
AuthPrivateKey string
AuthPublicKey string
GenerateMissingAuthCert bool
HTTPSCertificateMode string
DNSChallengeProvider string
HTTPPort string `validate:"required,containsany=0123456789,min=1,max=6"`
HTTPSPort string `validate:"required,containsany=0123456789,min=1,max=6"`
ProxyConfig ProxyConfig
@ -104,6 +112,9 @@ type SmartShieldPolicy struct {
PerUserTimeBudget float64
PerUserRequestLimit int
PerUserByteLimit int64
PerUserSimultaneous int
MaxGlobalSimultaneous int
PrivilegedGroups int
}
type DockerConfig struct {
@ -130,4 +141,6 @@ type ProxyRouteConfig struct {
Target string `validate:"required"`
SmartShield SmartShieldPolicy
Mode ProxyMode
BlockCommonBots bool
BlockAPIAbuse bool
}

View File

@ -2,7 +2,6 @@ package utils
import (
"encoding/json"
"errors"
"math/rand"
"regexp"
"net/http"
@ -22,6 +21,42 @@ var NeedsRestart = false
var DefaultConfig = Config{
LoggingLevel: "INFO",
NewInstall: true,
// By default we block all countries that have a high amount of attacks
// Note that Cosmos wont block the country of origin of the server even if it is in this list
BlockedCountries: []string{
// china
"CN",
// Russia
"RU",
// turkey
"TR",
// Brazil
"BR",
// Bangladesh
"BD",
// India
"IN",
// Nepal
"NP",
// Pakistan
"PK",
// Sri Lanka
"LK",
// Vietnam
"VN",
// Indonesia
"ID",
// Iran
"IR",
// Iraq
"IQ",
// Egypt
"EG",
// Afghanistan
"AF",
// Romania
"RO",
},
HTTPConfig: HTTPConfig{
HTTPSCertificateMode: "DISABLED",
GenerateMissingAuthCert: true,
@ -162,6 +197,9 @@ func LoadBaseMainConfig(config Config){
if os.Getenv("COSMOS_MONGODB") != "" {
MainConfig.MongoDB = os.Getenv("COSMOS_MONGODB")
}
if os.Getenv("COSMOS_SERVER_COUNTRY") != "" {
MainConfig.ServerCountry = os.Getenv("COSMOS_SERVER_COUNTRY")
}
}
func GetMainConfig() Config {
@ -240,77 +278,6 @@ func RestartServer() {
os.Exit(0)
}
func LoggedInOnlyWithRedirect(w http.ResponseWriter, req *http.Request) error {
userNickname := req.Header.Get("x-cosmos-user")
role, _ := strconv.Atoi(req.Header.Get("x-cosmos-role"))
isUserLoggedIn := role > 0
if !isUserLoggedIn || userNickname == "" {
Error("LoggedInOnlyWithRedirect: User is not logged in", nil)
http.Redirect(w, req, "/ui/login?notlogged=1&redirect="+req.URL.Path, http.StatusFound)
}
return nil
}
func LoggedInOnly(w http.ResponseWriter, req *http.Request) error {
userNickname := req.Header.Get("x-cosmos-user")
role, _ := strconv.Atoi(req.Header.Get("x-cosmos-role"))
isUserLoggedIn := role > 0
if !isUserLoggedIn || userNickname == "" {
Error("LoggedInOnly: User is not logged in", nil)
//http.Redirect(w, req, "/login?notlogged=1&redirect=" + req.URL.Path, http.StatusFound)
HTTPError(w, "User not logged in", http.StatusUnauthorized, "HTTP004")
return errors.New("User not logged in")
}
return nil
}
func AdminOnly(w http.ResponseWriter, req *http.Request) error {
userNickname := req.Header.Get("x-cosmos-user")
role, _ := strconv.Atoi(req.Header.Get("x-cosmos-role"))
isUserLoggedIn := role > 0
isUserAdmin := role > 1
if !isUserLoggedIn || userNickname == "" {
Error("AdminOnly: User is not logged in", nil)
//http.Redirect(w, req, "/login?notlogged=1&redirect=" + req.URL.Path, http.StatusFound)
HTTPError(w, "User not logged in", http.StatusUnauthorized, "HTTP004")
return errors.New("User not logged in")
}
if isUserLoggedIn && !isUserAdmin {
Error("AdminOnly: User is not admin", nil)
HTTPError(w, "User unauthorized", http.StatusUnauthorized, "HTTP005")
return errors.New("User not Admin")
}
return nil
}
func AdminOrItselfOnly(w http.ResponseWriter, req *http.Request, nickname string) error {
userNickname := req.Header.Get("x-cosmos-user")
role, _ := strconv.Atoi(req.Header.Get("x-cosmos-role"))
isUserLoggedIn := role > 0
isUserAdmin := role > 1
if !isUserLoggedIn || userNickname == "" {
Error("AdminOrItselfOnly: User is not logged in", nil)
HTTPError(w, "User not logged in", http.StatusUnauthorized, "HTTP004")
return errors.New("User not logged in")
}
if nickname != userNickname && !isUserAdmin {
Error("AdminOrItselfOnly: User is not admin", nil)
HTTPError(w, "User unauthorized", http.StatusUnauthorized, "HTTP005")
return errors.New("User not Admin")
}
return nil
}
func GetAllHostnames() []string {
hostnames := []string{
GetMainConfig().HTTPConfig.Hostname,
@ -342,4 +309,33 @@ func GetAvailableRAM() uint64 {
// Use total available memory as an approximation
return vmStat.Available
}
func StringArrayEquals(a []string, b []string) bool {
if len(a) != len(b) {
return false
}
for _, value := range a {
if !StringArrayContains(b, value) {
return false
}
}
for _, value := range b {
if !StringArrayContains(a, value) {
return false
}
}
return true
}
func StringArrayContains(a []string, b string) bool {
for _, value := range a {
if value == b {
return true
}
}
return false
}