Commit f9dc8a61 authored by Chad Woolley's avatar Chad Woolley Committed by GitLab Release Tools Bot

Warn when snippet contains unretrievable files

Merge branch 'security-snippets-warn-unretrievable-files' into 'master'

See merge request gitlab-org/security/gitlab!2187

Changelog: security
parent ef3d007a
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
import eventHub from '~/blob/components/eventhub';
import {
SNIPPET_MARK_VIEW_APP_START,
......@@ -23,6 +23,7 @@ export default {
EmbedDropdown,
SnippetHeader,
SnippetTitle,
GlAlert,
GlLoadingIcon,
SnippetBlob,
CloneDropdownButton,
......@@ -35,6 +36,9 @@ export default {
canBeCloned() {
return Boolean(this.snippet.sshUrlToRepo || this.snippet.httpUrlToRepo);
},
hasUnretrievableBlobs() {
return this.snippet.hasUnretrievableBlobs;
},
},
beforeCreate() {
performanceMarkAndMeasure({ mark: SNIPPET_MARK_VIEW_APP_START });
......@@ -66,6 +70,13 @@ export default {
data-qa-selector="clone_button"
/>
</div>
<gl-alert v-if="hasUnretrievableBlobs" variant="danger" class="gl-mb-3" :dismissible="false">
{{
__(
'WARNING: This snippet contains hidden files which might be used to mask malicious behavior. Exercise caution if cloning and executing code from this snippet.',
)
}}
</gl-alert>
<snippet-blob
v-for="blob in blobs"
:key="blob.path"
......
......@@ -17,6 +17,7 @@ export const getSnippetMixin = {
// Set `snippet.blobs` since some child components are coupled to this.
if (!isEmpty(res)) {
res.hasUnretrievableBlobs = res.blobs?.hasUnretrievableBlobs || false;
// It's possible for us to not get any blobs in a response.
// In this case, we should default to current blobs.
res.blobs = res.blobs ? res.blobs.nodes : blobsDefault;
......
......@@ -15,6 +15,7 @@ query GetSnippetQuery($ids: [SnippetID!]) {
sshUrlToRepo
blobs {
__typename
hasUnretrievableBlobs
nodes {
__typename
binary
......
......@@ -12,6 +12,7 @@ query SnippetBlobContent($ids: [ID!], $rich: Boolean!, $paths: [String!]) {
richData @include(if: $rich)
plainData @skip(if: $rich)
}
hasUnretrievableBlobs
}
}
}
......
......@@ -19,18 +19,18 @@ module Resolvers
def resolve(paths: [])
return [snippet.blob] if snippet.empty_repo?
if paths.empty?
snippet.blobs
else
snippet.repository.blobs_at(transformed_blob_paths(paths))
end
end
private
def transformed_blob_paths(paths)
ref = snippet.default_branch
paths.map { |path| [ref, path] }
paths = snippet.all_files if paths.empty?
blobs = snippet.blobs(paths)
# TODO: Some blobs, e.g. those with non-utf8 filenames, are returned as nil from the
# repository. We need to provide a flag to notify the user of this until we come up with a
# way to retrieve and display these blobs. We will be exploring a more holistic solution for
# this general problem of making all blobs retrievable as part
# of https://gitlab.com/gitlab-org/gitlab/-/issues/323082, at which point this attribute may
# be removed.
context[:unretrievable_blobs?] = blobs.size < paths.size
blobs
end
end
end
......
# frozen_string_literal: true
module Types
module Snippets
# rubocop: disable Graphql/AuthorizeTypes
class BlobConnectionType < GraphQL::Types::Relay::BaseConnection
field :has_unretrievable_blobs, GraphQL::Types::Boolean, null: false,
description: 'Indicates if the snippet has unretrievable blobs.',
resolver_method: :unretrievable_blobs?
def unretrievable_blobs?
!!context[:unretrievable_blobs?]
end
end
end
end
......@@ -8,6 +8,8 @@ module Types
description 'Represents the snippet blob'
present_using SnippetBlobPresenter
connection_type_class(Types::Snippets::BlobConnectionType)
field :rich_data, GraphQL::Types::String,
description: 'Blob highlighted data.',
null: true
......
......@@ -237,15 +237,19 @@ class Snippet < ApplicationRecord
end
end
def all_files
list_files(default_branch)
end
def blob
@blob ||= Blob.decorate(SnippetBlob.new(self), self)
end
def blobs
def blobs(paths = [])
return [] unless repository_exists?
files = list_files(default_branch)
items = files.map { |file| [default_branch, file] }
paths = all_files if paths.empty?
items = paths.map { |path| [default_branch, path] }
repository.blobs_at(items).compact
end
......
......@@ -7841,6 +7841,7 @@ The connection type for [`SnippetBlob`](#snippetblob).
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="snippetblobconnectionedges"></a>`edges` | [`[SnippetBlobEdge]`](#snippetblobedge) | A list of edges. |
| <a id="snippetblobconnectionhasunretrievableblobs"></a>`hasUnretrievableBlobs` | [`Boolean!`](#boolean) | Indicates if the snippet has unretrievable blobs. |
| <a id="snippetblobconnectionnodes"></a>`nodes` | [`[SnippetBlob]`](#snippetblob) | A list of nodes. |
| <a id="snippetblobconnectionpageinfo"></a>`pageInfo` | [`PageInfo!`](#pageinfo) | Information to aid in pagination. |
......@@ -40768,6 +40768,9 @@ msgstr ""
msgid "WARNING:"
msgstr ""
msgid "WARNING: This snippet contains hidden files which might be used to mask malicious behavior. Exercise caution if cloning and executing code from this snippet."
msgstr ""
msgid "Wait for the file to load to copy its contents"
msgstr ""
......
import { GlLoadingIcon } from '@gitlab/ui';
import { GlLoadingIcon, GlAlert } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { Blob, BinaryBlob } from 'jest/blob/components/mock_data';
import EmbedDropdown from '~/snippets/components/embed_dropdown.vue';
......@@ -106,6 +106,23 @@ describe('Snippet view app', () => {
});
});
describe('hasUnretrievableBlobs alert rendering', () => {
it.each`
hasUnretrievableBlobs | condition | isRendered
${false} | ${'not render'} | ${false}
${true} | ${'render'} | ${true}
`('does $condition gl-alert by default', ({ hasUnretrievableBlobs, isRendered }) => {
createComponent({
data: {
snippet: {
hasUnretrievableBlobs,
},
},
});
expect(wrapper.findComponent(GlAlert).exists()).toBe(isRendered);
});
});
describe('Clone button rendering', () => {
it.each`
httpUrlToRepo | sshUrlToRepo | shouldRender | isRendered
......
......@@ -13,11 +13,14 @@ RSpec.describe Resolvers::Snippets::BlobsResolver do
let_it_be(:current_user) { create(:user) }
let_it_be(:snippet) { create(:personal_snippet, :private, :repository, author: current_user) }
let(:query_context) { {} }
context 'when user is not authorized' do
let(:other_user) { create(:user) }
it 'redacts the field' do
expect(resolve_blobs(snippet, user: other_user)).to be_nil
expect(query_context[:unretrievable_blobs?]).to eq(false)
end
end
......@@ -28,6 +31,7 @@ RSpec.describe Resolvers::Snippets::BlobsResolver do
expect(result).to match_array(snippet.list_files.map do |file|
have_attributes(path: file)
end)
expect(query_context[:unretrievable_blobs?]).to eq(false)
end
end
......@@ -37,12 +41,14 @@ RSpec.describe Resolvers::Snippets::BlobsResolver do
path = 'CHANGELOG'
expect(resolve_blobs(snippet, paths: [path])).to contain_exactly(have_attributes(path: path))
expect(query_context[:unretrievable_blobs?]).to eq(false)
end
end
context 'the argument does not match anything' do
it 'returns an empty result' do
expect(resolve_blobs(snippet, paths: ['does not exist'])).to be_empty
expect(query_context[:unretrievable_blobs?]).to eq(true)
end
end
......@@ -53,12 +59,15 @@ RSpec.describe Resolvers::Snippets::BlobsResolver do
expect(resolve_blobs(snippet, paths: paths)).to match_array(paths.map do |file|
have_attributes(path: file)
end)
expect(query_context[:unretrievable_blobs?]).to eq(false)
end
end
end
end
def resolve_blobs(snippet, user: current_user, paths: [], args: { paths: paths })
resolve(described_class, args: args, ctx: { current_user: user }, obj: snippet)
def resolve_blobs(snippet, user: current_user, paths: [], args: { paths: paths }, has_unretrievable_blobs: false)
query_context[:current_user] = user
query_context[:unretrievable_blobs?] = has_unretrievable_blobs
resolve(described_class, args: args, ctx: query_context, obj: snippet)
end
end
......@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe Snippet do
include FakeBlobHelpers
describe 'modules' do
subject { described_class }
......@@ -526,6 +528,21 @@ RSpec.describe Snippet do
end
end
describe '#all_files' do
let(:snippet) { create(:snippet, :repository) }
let(:files) { double(:files) }
subject(:all_files) { snippet.all_files }
before do
allow(snippet.repository).to receive(:ls_files).with(snippet.default_branch).and_return(files)
end
it 'lists files from the repository with the default branch' do
expect(all_files).to eq(files)
end
end
describe '#blobs' do
context 'when repository does not exist' do
let(:snippet) { create(:snippet) }
......@@ -552,6 +569,23 @@ RSpec.describe Snippet do
end
end
end
context 'when some blobs are not retrievable from repository' do
let(:snippet) { create(:snippet, :repository) }
let(:container) { double(:container) }
let(:retrievable_filename) { 'retrievable_file'}
let(:unretrievable_filename) { 'unretrievable_file'}
before do
allow(snippet).to receive(:list_files).and_return([retrievable_filename, unretrievable_filename])
blob = fake_blob(path: retrievable_filename, container: container)
allow(snippet.repository).to receive(:blobs_at).and_return([blob, nil])
end
it 'does not include unretrievable blobs' do
expect(snippet.blobs.map(&:name)).to contain_exactly(retrievable_filename)
end
end
end
describe '#to_json' do
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment