server/api: improve input sanitization, fix docs

This commit is contained in:
rr- 2016-04-19 11:50:47 +02:00
parent 7263849fac
commit 83784c5e76
9 changed files with 207 additions and 150 deletions

11
API.md
View File

@ -14,7 +14,7 @@
2. [API reference](#api-reference)
- Tag categories
- [Listing tag categories](#listing-tags-category)
- [Listing tag categories](#listing-tags-categories)
- [Creating tag category](#creating-tag-category)
- [Updating tag category](#updating-tag-category)
- [Getting tag category](#getting-tag-category)
@ -245,7 +245,8 @@ Not implemented yet.
are optional. If specified implied tags or suggested tags do not exist yet,
they will be automatically created. Tags created automatically have no
implications, no suggestions, one name and their category is set to the
first item of `tag_categories` from server's configuration.
first tag category found. If there are no tag categories established yet,
an error will be thrown.
## Updating tag
@ -275,10 +276,10 @@ Not implemented yet.
- **Errors**
- the tag does not exist
- any name is used by an existing tag (names are case insensitive)
- any name, implication or suggestion has invalid name
- any name, implication or suggestion name is invalid
- category is invalid
- no name was specified
- implications or suggestions contain any item from names (e.g. there's a
shallow cyclic dependency)
- privileges are too low
@ -338,7 +339,7 @@ Not implemented yet.
- **Description**
Deletes existing tag.
Deletes existing tag. The tag to be deleted must have no usages.
## Listing users

View File

@ -70,6 +70,34 @@ def test_creating_simple_tags(test_ctx, fake_datetime):
assert_relations(tag.implications, [])
assert os.path.exists(os.path.join(config.config['data_dir'], 'tags.json'))
@pytest.mark.parametrize('input,expected_exception', [
({'names': None}, tags.InvalidTagNameError),
({'names': []}, tags.InvalidTagNameError),
({'names': [None]}, tags.InvalidTagNameError),
({'names': ['']}, tags.InvalidTagNameError),
({'names': ['!bad']}, tags.InvalidTagNameError),
({'names': ['x' * 65]}, tags.InvalidTagNameError),
({'category': None}, tags.InvalidTagCategoryError),
({'category': ''}, tags.InvalidTagCategoryError),
({'category': 'invalid'}, tags.InvalidTagCategoryError),
({'suggestions': ['good', '!bad']}, tags.InvalidTagNameError),
({'implications': ['good', '!bad']}, tags.InvalidTagNameError),
])
def test_invalid_inputs(test_ctx, input, expected_exception):
real_input={
'names': ['tag1', 'tag2'],
'category': 'meta',
'suggestions': [],
'implications': [],
}
for key, value in input.items():
real_input[key] = value
with pytest.raises(expected_exception):
test_ctx.api.post(
test_ctx.context_factory(
input=real_input,
user=test_ctx.user_factory()))
@pytest.mark.parametrize('field', ['names', 'category'])
def test_missing_mandatory_field(test_ctx, field):
input = {
@ -94,10 +122,11 @@ def test_missing_optional_field(test_ctx, tmpdir, field):
'implications': [],
}
del input[field]
test_ctx.api.post(
result = test_ctx.api.post(
test_ctx.context_factory(
input=input,
user=test_ctx.user_factory(rank='regular_user')))
assert result is not None
def test_duplicating_names(test_ctx):
result = test_ctx.api.post(
@ -114,33 +143,6 @@ def test_duplicating_names(test_ctx):
tag = get_tag(test_ctx.session, 'tag1')
assert [tag_name.name for tag_name in tag.names] == ['tag1']
def test_trying_to_create_tag_without_names(test_ctx):
with pytest.raises(tags.InvalidTagNameError):
test_ctx.api.post(
test_ctx.context_factory(
input={
'names': [],
'category': 'meta',
'suggestions': [],
'implications': [],
},
user=test_ctx.user_factory(rank='regular_user')))
@pytest.mark.parametrize('names', [['!'], ['x' * 65]])
def test_trying_to_create_tag_with_invalid_name(test_ctx, names):
with pytest.raises(tags.InvalidTagNameError):
test_ctx.api.post(
test_ctx.context_factory(
input={
'names': names,
'category': 'meta',
'suggestions': [],
'implications': [],
},
user=test_ctx.user_factory(rank='regular_user')))
assert get_tag(test_ctx.session, 'ok') is None
assert get_tag(test_ctx.session, '!') is None
def test_trying_to_use_existing_name(test_ctx):
test_ctx.session.add_all([
test_ctx.tag_factory(names=['used1'], category_name='meta'),
@ -169,19 +171,6 @@ def test_trying_to_use_existing_name(test_ctx):
user=test_ctx.user_factory(rank='regular_user')))
assert get_tag(test_ctx.session, 'unused') is None
def test_trying_to_create_tag_with_invalid_category(test_ctx):
with pytest.raises(tags.InvalidTagCategoryError):
test_ctx.api.post(
test_ctx.context_factory(
input={
'names': ['ok'],
'category': 'invalid',
'suggestions': [],
'implications': [],
},
user=test_ctx.user_factory(rank='regular_user')))
assert get_tag(test_ctx.session, 'ok') is None
@pytest.mark.parametrize('input,expected_suggestions,expected_implications', [
# new relations
({
@ -285,7 +274,7 @@ def test_trying_to_create_tag_with_invalid_relation(test_ctx, input):
}
])
def test_tag_trying_to_relate_to_itself(test_ctx, input):
with pytest.raises(tags.RelationError):
with pytest.raises(tags.InvalidTagRelationError):
test_ctx.api.post(
test_ctx.context_factory(
input=input,

View File

@ -27,7 +27,7 @@ def test_ctx(
ret.api = api.TagDetailApi()
return ret
def test_deleting_tags(test_ctx):
def test_deleting(test_ctx):
test_ctx.session.add(test_ctx.tag_factory(names=['tag']))
test_ctx.session.commit()
result = test_ctx.api.delete(
@ -38,7 +38,7 @@ def test_deleting_tags(test_ctx):
assert test_ctx.session.query(db.Tag).count() == 0
assert os.path.exists(os.path.join(config.config['data_dir'], 'tags.json'))
def test_deleting_tags_without_privileges(test_ctx):
def test_deleting_without_privileges(test_ctx):
test_ctx.session.add(test_ctx.tag_factory(names=['tag']))
test_ctx.session.commit()
with pytest.raises(errors.AuthError):
@ -48,7 +48,7 @@ def test_deleting_tags_without_privileges(test_ctx):
'tag')
assert test_ctx.session.query(db.Tag).count() == 1
def test_deleting_tags_with_usages(test_ctx, post_factory):
def test_deleting_with_usages(test_ctx, post_factory):
tag = test_ctx.tag_factory(names=['tag'])
post = post_factory()
post.tags.append(tag)

View File

@ -20,9 +20,31 @@ def test_ctx(
ret.context_factory = context_factory
ret.user_factory = user_factory
ret.tag_factory = tag_factory
ret.list_api = api.TagListApi()
ret.detail_api = api.TagDetailApi()
return ret
def test_retrieving_multiple(test_ctx):
tag1 = test_ctx.tag_factory(names=['t1'])
tag2 = test_ctx.tag_factory(names=['t2'])
test_ctx.session.add_all([tag1, tag2])
result = test_ctx.list_api.get(
test_ctx.context_factory(
input={'query': '', 'page': 1},
user=test_ctx.user_factory(rank='regular_user')))
assert result['query'] == ''
assert result['page'] == 1
assert result['pageSize'] == 100
assert result['total'] == 2
assert [t['names'] for t in result['tags']] == [['t1'], ['t2']]
def test_retrieving_multiple_without_privileges(test_ctx):
with pytest.raises(errors.AuthError):
test_ctx.list_api.get(
test_ctx.context_factory(
input={'query': '', 'page': 1},
user=test_ctx.user_factory(rank='anonymous')))
def test_retrieving_single(test_ctx):
test_ctx.session.add(test_ctx.tag_factory(names=['tag']))
result = test_ctx.detail_api.get(

View File

@ -78,6 +78,50 @@ def test_simple_updating(test_ctx, fake_datetime):
assert tag.implications == []
assert os.path.exists(os.path.join(config.config['data_dir'], 'tags.json'))
@pytest.mark.parametrize('input,expected_exception', [
({'names': None}, tags.InvalidTagNameError),
({'names': []}, tags.InvalidTagNameError),
({'names': [None]}, tags.InvalidTagNameError),
({'names': ['']}, tags.InvalidTagNameError),
({'names': ['!bad']}, tags.InvalidTagNameError),
({'names': ['x' * 65]}, tags.InvalidTagNameError),
({'category': None}, tags.InvalidTagCategoryError),
({'category': ''}, tags.InvalidTagCategoryError),
({'category': 'invalid'}, tags.InvalidTagCategoryError),
({'suggestions': ['good', '!bad']}, tags.InvalidTagNameError),
({'implications': ['good', '!bad']}, tags.InvalidTagNameError),
])
def test_invalid_inputs(test_ctx, input, expected_exception):
test_ctx.session.add(
test_ctx.tag_factory(names=['tag1'], category_name='meta'))
test_ctx.session.commit()
with pytest.raises(expected_exception):
test_ctx.api.put(
test_ctx.context_factory(
input=input,
user=test_ctx.user_factory(rank='regular_user')),
'tag1')
@pytest.mark.parametrize(
'field', ['names', 'category', 'implications', 'suggestions'])
def test_missing_optional_field(test_ctx, tmpdir, field):
test_ctx.session.add(
test_ctx.tag_factory(names=['tag'], category_name='meta'))
test_ctx.session.commit()
input = {
'names': ['tag1', 'tag2'],
'category': 'meta',
'suggestions': [],
'implications': [],
}
del input[field]
result = test_ctx.api.put(
test_ctx.context_factory(
input=input,
user=test_ctx.user_factory(rank='regular_user')),
'tag')
assert result is not None
def test_trying_to_update_non_existing_tag(test_ctx):
with pytest.raises(tags.TagNotFoundError):
test_ctx.api.put(
@ -118,22 +162,6 @@ def test_duplicating_names(test_ctx):
assert tag is not None
assert [tag_name.name for tag_name in tag.names] == ['tag3']
@pytest.mark.parametrize('input', [
{'names': []},
{'names': ['!']},
{'names': ['x' * 65]},
])
def test_trying_to_set_invalid_name(test_ctx, input):
test_ctx.session.add(
test_ctx.tag_factory(names=['tag1'], category_name='meta'))
test_ctx.session.commit()
with pytest.raises(tags.InvalidTagNameError):
test_ctx.api.put(
test_ctx.context_factory(
input=input,
user=test_ctx.user_factory(rank='regular_user')),
'tag1')
@pytest.mark.parametrize('dup_name', ['tag1', 'TAG1', 'tag2', 'TAG2'])
def test_trying_to_use_existing_name(test_ctx, dup_name):
test_ctx.session.add_all([
@ -147,22 +175,6 @@ def test_trying_to_use_existing_name(test_ctx, dup_name):
user=test_ctx.user_factory(rank='regular_user')),
'tag3')
def test_trying_to_update_tag_with_invalid_category(test_ctx):
test_ctx.session.add(
test_ctx.tag_factory(names=['tag1'], category_name='meta'))
test_ctx.session.commit()
with pytest.raises(tags.InvalidTagCategoryError):
test_ctx.api.put(
test_ctx.context_factory(
input={
'names': ['ok'],
'category': 'invalid',
'suggestions': [],
'implications': [],
},
user=test_ctx.user_factory(rank='regular_user')),
'tag1')
@pytest.mark.parametrize('input,expected_suggestions,expected_implications', [
# new relations
({
@ -226,25 +238,6 @@ def test_reusing_suggestions_and_implications(test_ctx):
assert_relations(tag.suggestions, ['tag1'])
assert_relations(tag.implications, ['tag1'])
@pytest.mark.parametrize('input', [
{'names': ['ok'], 'suggestions': ['ok2', '!']},
{'names': ['ok'], 'implications': ['ok2', '!']},
])
def test_trying_to_update_tag_with_invalid_relation(test_ctx, input):
test_ctx.session.add(
test_ctx.tag_factory(names=['tag'], category_name='meta'))
test_ctx.session.commit()
with pytest.raises(tags.InvalidTagNameError):
test_ctx.api.put(
test_ctx.context_factory(
input=input, user=test_ctx.user_factory(rank='regular_user')),
'tag')
test_ctx.session.rollback()
assert get_tag(test_ctx.session, 'tag') is not None
assert get_tag(test_ctx.session, '!') is None
assert get_tag(test_ctx.session, 'ok') is None
assert get_tag(test_ctx.session, 'ok2') is None
@pytest.mark.parametrize('input', [
{
'names': ['tag1'],
@ -263,7 +256,7 @@ def test_tag_trying_to_relate_to_itself(test_ctx, input):
test_ctx.session.add(
test_ctx.tag_factory(names=['tag1'], category_name='meta'))
test_ctx.session.commit()
with pytest.raises(tags.RelationError):
with pytest.raises(tags.InvalidTagRelationError):
test_ctx.api.put(
test_ctx.context_factory(
input=input, user=test_ctx.user_factory(rank='regular_user')),

View File

@ -16,8 +16,8 @@ def test_ctx(
session, config_injector, context_factory, user_factory):
config_injector({
'secret': '',
'user_name_regex': '.{3,}',
'password_regex': '.{3,}',
'user_name_regex': '[^!]{3,}',
'password_regex': '[^!]{3,}',
'default_rank': 'regular_user',
'thumbnails': {'avatar_width': 200, 'avatar_height': 200},
'ranks': ['anonymous', 'regular_user', 'mod', 'admin'],
@ -150,34 +150,44 @@ def test_missing_optional_field(test_ctx, tmpdir, field):
'avatarStyle': 'manual',
}
del input[field]
test_ctx.api.post(
result = test_ctx.api.post(
test_ctx.context_factory(
input=input,
files={'avatar': EMPTY_PIXEL},
user=test_ctx.user_factory(rank='mod')))
assert result is not None
@pytest.mark.parametrize('input', [
{'name': '.'},
{'name': 'x' * 51},
{'password': '.'},
{'rank': '.'},
{'email': '.'},
{'email': 'x' * 65},
{'avatarStyle': 'manual'},
@pytest.mark.parametrize('input,expected_exception', [
({'name': None}, users.InvalidUserNameError),
({'name': ''}, users.InvalidUserNameError),
({'name': '!bad'}, users.InvalidUserNameError),
({'name': 'x' * 51}, users.InvalidUserNameError),
({'password': None}, users.InvalidPasswordError),
({'password': ''}, users.InvalidPasswordError),
({'password': '!bad'}, users.InvalidPasswordError),
({'rank': None}, users.InvalidRankError),
({'rank': ''}, users.InvalidRankError),
({'rank': 'bad'}, users.InvalidRankError),
({'email': 'bad'}, users.InvalidEmailError),
({'email': 'x@' * 65 + '.com'}, users.InvalidEmailError),
({'avatarStyle': None}, users.InvalidAvatarError),
({'avatarStyle': ''}, users.InvalidAvatarError),
({'avatarStyle': 'invalid'}, users.InvalidAvatarError),
({'avatarStyle': 'manual'}, users.InvalidAvatarError), # missing file
])
def test_invalid_inputs(test_ctx, input):
user = test_ctx.user_factory(name='u1', rank='admin')
test_ctx.session.add(user)
with pytest.raises(errors.ValidationError):
real_input={
'name': 'chewie',
'email': 'asd@asd.asd',
'password': 'oks',
}
for key, value in input.items():
real_input[key] = value
def test_invalid_inputs(test_ctx, input, expected_exception):
real_input={
'name': 'chewie',
'email': 'asd@asd.asd',
'password': 'oks',
}
for key, value in input.items():
real_input[key] = value
with pytest.raises(expected_exception):
test_ctx.api.post(
test_ctx.context_factory(input=real_input, user=user))
test_ctx.context_factory(
input=real_input,
user=test_ctx.user_factory(name='u1', rank='admin')))
def test_mods_trying_to_become_admin(test_ctx):
user1 = test_ctx.user_factory(name='u1', rank='mod')

View File

@ -3,6 +3,11 @@ import pytest
from szurubooru import api, config, db, errors
from szurubooru.util import auth, misc, users
EMPTY_PIXEL = \
b'\x47\x49\x46\x38\x39\x61\x01\x00\x01\x00\x80\x01\x00\x00\x00\x00' \
b'\xff\xff\xff\x21\xf9\x04\x01\x00\x00\x01\x00\x2c\x00\x00\x00\x00' \
b'\x01\x00\x01\x00\x00\x02\x02\x4c\x01\x00\x3b'
def get_user(session, name):
return session.query(db.User).filter_by(name=name).first()
@ -11,8 +16,8 @@ def test_ctx(
session, config_injector, context_factory, user_factory):
config_injector({
'secret': '',
'user_name_regex': '.{3,}',
'password_regex': '.{3,}',
'user_name_regex': '^[^!]{3,}$',
'password_regex': '^[^!]{3,}$',
'thumbnails': {'avatar_width': 200, 'avatar_height': 200},
'ranks': ['anonymous', 'regular_user', 'mod', 'admin'],
'rank_names': {},
@ -71,6 +76,29 @@ def test_updating_user(test_ctx):
assert auth.is_valid_password(user, 'oks') is True
assert auth.is_valid_password(user, 'invalid') is False
@pytest.mark.parametrize(
'field', ['name', 'email', 'password', 'rank', 'avatarStyle'])
def test_missing_optional_field(test_ctx, tmpdir, field):
config.config['data_dir'] = str(tmpdir.mkdir('data'))
config.config['data_url'] = 'http://example.com/data/'
user = test_ctx.user_factory(name='u1', rank='admin')
test_ctx.session.add(user)
input = {
'name': 'chewie',
'email': 'asd@asd.asd',
'password': 'oks',
'rank': 'mod',
'avatarStyle': 'gravatar',
}
del input[field]
result = test_ctx.api.put(
test_ctx.context_factory(
input=input,
files={'avatar': EMPTY_PIXEL},
user=user),
'u1')
assert result is not None
def test_update_changing_nothing(test_ctx):
user = test_ctx.user_factory(name='u1', rank='admin')
test_ctx.session.add(user)
@ -93,19 +121,28 @@ def test_removing_email(test_ctx):
test_ctx.context_factory(input={'email': ''}, user=user), 'u1')
assert get_user(test_ctx.session, 'u1').email is None
@pytest.mark.parametrize('input', [
{'name': '.'},
{'name': 'x' * 51},
{'password': '.'},
{'rank': '.'},
{'email': '.'},
{'email': 'x' * 65},
{'avatarStyle': 'manual'},
@pytest.mark.parametrize('input,expected_exception', [
({'name': None}, users.InvalidUserNameError),
({'name': ''}, users.InvalidUserNameError),
({'name': '!bad'}, users.InvalidUserNameError),
({'name': 'x' * 51}, users.InvalidUserNameError),
({'password': None}, users.InvalidPasswordError),
({'password': ''}, users.InvalidPasswordError),
({'password': '!bad'}, users.InvalidPasswordError),
({'rank': None}, users.InvalidRankError),
({'rank': ''}, users.InvalidRankError),
({'rank': 'bad'}, users.InvalidRankError),
({'email': 'bad'}, users.InvalidEmailError),
({'email': 'x@' * 65 + '.com'}, users.InvalidEmailError),
({'avatarStyle': None}, users.InvalidAvatarError),
({'avatarStyle': ''}, users.InvalidAvatarError),
({'avatarStyle': 'invalid'}, users.InvalidAvatarError),
({'avatarStyle': 'manual'}, users.InvalidAvatarError), # missing file
])
def test_invalid_inputs(test_ctx, input):
def test_invalid_inputs(test_ctx, input, expected_exception):
user = test_ctx.user_factory(name='u1', rank='admin')
test_ctx.session.add(user)
with pytest.raises(errors.ValidationError):
with pytest.raises(expected_exception):
test_ctx.api.put(
test_ctx.context_factory(input=input, user=user), 'u1')
@ -151,14 +188,10 @@ def test_uploading_avatar(test_ctx, tmpdir):
config.config['data_url'] = 'http://example.com/data/'
user = test_ctx.user_factory(name='u1', rank='mod')
test_ctx.session.add(user)
empty_pixel = \
b'\x47\x49\x46\x38\x39\x61\x01\x00\x01\x00\x80\x01\x00\x00\x00\x00' \
b'\xff\xff\xff\x21\xf9\x04\x01\x00\x00\x01\x00\x2c\x00\x00\x00\x00' \
b'\x01\x00\x01\x00\x00\x02\x02\x4c\x01\x00\x3b'
response = test_ctx.api.put(
test_ctx.context_factory(
input={'avatarStyle': 'manual'},
files={'avatar': empty_pixel},
files={'avatar': EMPTY_PIXEL},
user=user),
'u1')
user = get_user(test_ctx.session, 'u1')

View File

@ -11,7 +11,7 @@ class TagAlreadyExistsError(errors.ValidationError): pass
class TagIsInUseError(errors.ValidationError): pass
class InvalidTagNameError(errors.ValidationError): pass
class InvalidTagCategoryError(errors.ValidationError): pass
class RelationError(errors.ValidationError): pass
class InvalidTagRelationError(errors.ValidationError): pass
def _verify_name_validity(name):
name_regex = config.config['tag_name_regex']
@ -111,7 +111,7 @@ def update_category_name(tag, category_name):
tag.category = category
def update_names(tag, names):
names = misc.icase_unique(names)
names = misc.icase_unique([name for name in names if name])
if not len(names):
raise InvalidTagNameError('At least one name must be specified.')
for name in names:
@ -139,14 +139,14 @@ def update_names(tag, names):
def update_implications(tag, relations):
if _check_name_intersection(_get_plain_names(tag), relations):
raise RelationError('Tag cannot imply itself.')
raise InvalidTagRelationError('Tag cannot imply itself.')
related_tags, new_tags = get_or_create_tags_by_names(relations)
db.session().flush()
tag.implications = related_tags + new_tags
def update_suggestions(tag, relations):
if _check_name_intersection(_get_plain_names(tag), relations):
raise RelationError('Tag cannot suggest itself.')
raise InvalidTagRelationError('Tag cannot suggest itself.')
related_tags, new_tags = get_or_create_tags_by_names(relations)
db.session().flush()
tag.suggestions = related_tags + new_tags

View File

@ -6,7 +6,7 @@ from szurubooru.util import auth, misc, files, images
class UserNotFoundError(errors.NotFoundError): pass
class UserAlreadyExistsError(errors.ValidationError): pass
class InvalidNameError(errors.ValidationError): pass
class InvalidUserNameError(errors.ValidationError): pass
class InvalidEmailError(errors.ValidationError): pass
class InvalidPasswordError(errors.ValidationError): pass
class InvalidRankError(errors.ValidationError): pass
@ -41,19 +41,23 @@ def create_user(name, password, email, auth_user):
return user
def update_name(user, name, auth_user):
if not name:
raise InvalidUserNameError('Name cannot be empty.')
if misc.value_exceeds_column_size(name, db.User.name):
raise InvalidNameError('User name is too long.')
raise InvalidUserNameError('User name is too long.')
other_user = get_user_by_name(name)
if other_user and other_user.user_id != auth_user.user_id:
raise UserAlreadyExistsError('User %r already exists.' % name)
name = name.strip()
name_regex = config.config['user_name_regex']
if not re.match(name_regex, name):
raise InvalidNameError(
raise InvalidUserNameError(
'User name %r must satisfy regex %r.' % (name, name_regex))
user.name = name
def update_password(user, password):
if not password:
raise InvalidPasswordError('Password cannot be empty.')
password_regex = config.config['password_regex']
if not re.match(password_regex, password):
raise InvalidPasswordError(
@ -62,7 +66,10 @@ def update_password(user, password):
user.password_hash = auth.get_password_hash(user.password_salt, password)
def update_email(user, email):
email = email.strip() or None
if email:
email = email.strip()
if not email:
email = None
if email and misc.value_exceeds_column_size(email, db.User.email):
raise InvalidEmailError('Email is too long.')
if not misc.is_valid_email(email):
@ -70,6 +77,8 @@ def update_email(user, email):
user.email = email
def update_rank(user, rank, authenticated_user):
if not rank:
raise InvalidRankError('Rank cannot be empty.')
rank = rank.strip()
available_ranks = config.config['ranks']
if not rank in available_ranks: