Merge "web: Pass auth header to log endpoint if serving from the same origin"

This commit is contained in:
Zuul 2025-05-12 18:37:15 +00:00 committed by Gerrit Code Review
commit bc3d7cc057
7 changed files with 115 additions and 8 deletions

View File

@ -997,6 +997,15 @@ sections of ``zuul.conf`` are used by the web server:
If this is used the finger gateways should be configured accordingly.
.. attr:: auth_log_file_requests
:default: false
If set to true, the JavaScript web client will pass an Authorization
header with HTTP requests for log files if the origin of a log file is
the same as the Zuul API. This is useful when build logs are served
through the same authenticated endpoint as the API (e.g. a reverse
proxy).
.. attr:: keystore
.. attr:: password

View File

@ -2132,6 +2132,7 @@ class TestInfo(BaseTestWeb):
"realms": {},
"default_realm": None,
"read_protected": False,
"auth_log_file_requests": False,
}
},
"stats": {
@ -2192,6 +2193,7 @@ class TestWebCapabilitiesInfo(TestInfo):
},
'default_realm': 'myOIDC1',
'read_protected': False,
'auth_log_file_requests': False,
}
if niz is not None:
info['info']['capabilities']['auth']['niz'] = niz

View File

@ -12,8 +12,6 @@
// License for the specific language governing permissions and limitations
// under the License.
import Axios from 'axios'
import * as API from '../api'
import { fetchLogfile } from './logfile'
@ -324,7 +322,8 @@ function fetchBuildOutput(buildId, state) {
const url = build.log_url.substr(0, build.log_url.lastIndexOf('/') + 1)
dispatch(requestBuildOutput())
try {
const response = await Axios.get(url + 'job-output.json.gz')
const auth_log_request = state.info.capabilities?.auth?.auth_log_file_requests === true
const response = await API.getLogFile(url + 'job-output.json.gz', auth_log_request)
dispatch(receiveBuildOutput(buildId, response.data))
} catch (error) {
if (!error.request) {
@ -335,7 +334,8 @@ function fetchBuildOutput(buildId, state) {
}
try {
// Try without compression
const response = await Axios.get(url + 'job-output.json')
const auth_log_request = state.info.capabilities?.auth?.auth_log_file_requests === true
const response = await API.getLogFile(url + 'job-output.json', auth_log_request)
dispatch(receiveBuildOutput(buildId, response.data))
} catch (error) {
dispatch(failedBuildOutput(buildId, error, url))
@ -367,7 +367,8 @@ export function fetchBuildManifest(buildId, state) {
artifact.metadata.type === 'zuul_manifest'
) {
try {
const response = await Axios.get(artifact.url)
const auth_log_request = state.info.capabilities?.auth?.auth_log_file_requests === true
const response = await API.getLogFile(artifact.url, auth_log_request)
return dispatch(receiveBuildManifest(buildId, response.data))
} catch(error) {
// Show the error since we expected a manifest but did not

View File

@ -12,7 +12,7 @@
// License for the specific language governing permissions and limitations
// under the License.
import Axios from 'axios'
import * as API from '../api'
export const LOGFILE_FETCH_REQUEST = 'LOGFILE_FETCH_REQUEST'
export const LOGFILE_FETCH_SUCCESS = 'LOGFILE_FETCH_SUCCESS'
@ -109,7 +109,8 @@ export function fetchLogfile(buildId, file, state) {
const url = build.log_url + file
dispatch(requestLogfile())
try {
const response = await Axios.get(url, { transformResponse: [] })
const auth_log_request = state.info.capabilities?.auth?.auth_log_file_requests === true
const response = await API.getLogFile(url, auth_log_request, { transformResponse: [] })
dispatch(receiveLogfile(buildId, file, response.data))
} catch(error) {
dispatch(failedLogfile(error, url))

View File

@ -127,6 +127,22 @@ function makeRequest(url, method, data) {
return res
}
function getLogFile(url, authenticate, requestConfig = {}) {
const apiOrigin = new URL(getZuulUrl()).origin
const urlOrigin = new URL(url).origin
let headers = {}
// If we serve the log files from the same origin as the api, pass auth
// headers to the log endpoint.
// This is optional and can be enabled explicitly by setting the authenticate
// parameter to true
if (authenticate && authToken && urlOrigin === apiOrigin) {
headers['Authorization'] = `Bearer ${authToken}`
}
const config = {headers: headers, ...requestConfig}
return Axios.get(url, config)
}
// Direct APIs
function fetchInfo() {
return makeRequest('info')
@ -408,6 +424,7 @@ export {
fetchUserAuthorizations,
getAuthToken,
getHomepageUrl,
getLogFile,
getStreamUrl,
promote,
}

View File

@ -1,4 +1,10 @@
import { getHomepageUrl } from './api'
import { getHomepageUrl, getLogFile, setAuthToken } from './api'
import axios from 'axios'
jest.mock('axios')
afterEach(() => {
jest.clearAllMocks()
})
it('should should return the homepage url', () => {
const homepage = 'https://my-zuul.com/'
@ -61,3 +67,64 @@ it('should return the subdir homepage url', () => {
expect(getHomepageUrl()).toEqual(homepage)
}
})
it('should not request logs with auth header per default', () => {
Object.defineProperty(process.env, 'REACT_APP_ZUUL_API', {
value: 'https://example.com/api/'
})
// same origin but we're not expecting auth headers, because
// we have not explicitly enabled that
const logFileUrl = 'https://example.com/logs/build-output.txt'
setAuthToken('foobar')
getLogFile(logFileUrl, false)
expect(axios.get).toHaveBeenCalledTimes(1)
expect(axios.get).toHaveBeenCalledWith(logFileUrl, { headers: {} })
})
it('should request logs with auth header if enabled', () => {
Object.defineProperty(process.env, 'REACT_APP_ZUUL_API', {
value: 'https://example.com/api/'
})
const logFileUrl = 'https://example.com/logs/build-output.txt'
setAuthToken('foobar')
getLogFile(logFileUrl, true)
expect(axios.get).toHaveBeenCalledTimes(1)
expect(axios.get).toHaveBeenCalledWith(logFileUrl, {
headers: { Authorization: 'Bearer foobar' }
})
})
it('should request logs without auth header if origins don\'t match', () => {
Object.defineProperty(process.env, 'REACT_APP_ZUUL_API', {
value: 'https://example.com/api/'
})
// api and log endpoint have different origins, so we must not pass auth
// headers
const logFileUrl = 'https://example.org/logs/build-output.txt'
setAuthToken('foobar')
getLogFile(logFileUrl, true)
expect(axios.get).toHaveBeenCalledTimes(1)
expect(axios.get).toHaveBeenCalledWith(logFileUrl, { headers: {} })
})
it('should pass additional request configs to axios', () => {
Object.defineProperty(process.env, 'REACT_APP_ZUUL_API', {
value: 'https://example.com/api/'
})
const logFileUrl = 'https://example.com/logs/build-output.txt'
setAuthToken('foobar')
getLogFile(logFileUrl, true, { transformResponse: [] })
expect(axios.get).toHaveBeenCalledTimes(1)
expect(axios.get).toHaveBeenCalledWith(logFileUrl, {
headers: { Authorization: 'Bearer foobar' },
transformResponse: [],
})
})

View File

@ -1501,6 +1501,8 @@ class ZuulWebAPI(object):
auth_info['default_realm'] = root_realm
read_protected = bool(self.zuulweb.abide.api_root.access_rules)
auth_info['read_protected'] = read_protected
auth_info['auth_log_file_requests'] =\
self.zuulweb.auth_log_file_requests
return self._handleInfo(info.toDict())
@cherrypy.expose
@ -1523,6 +1525,8 @@ class ZuulWebAPI(object):
auth_info['read_protected'] = read_protected
# TODO: remove this after NIZ transition is complete
auth_info['niz'] = bool(self.zuulweb.tenant_providers[tenant_name])
auth_info['auth_log_file_requests'] =\
self.zuulweb.auth_log_file_requests
return self._handleInfo(info)
def _handleInfo(self, info):
@ -3048,6 +3052,12 @@ class ZuulWeb(object):
self.finger_tls_verify_hostnames = get_default(
self.config, 'fingergw', 'tls_verify_hostnames', default=True)
# NOTE: taking this from the zuul.conf for now, but we might want
# to make this configurable per-tenant in the future
self.auth_log_file_requests = get_default(
self.config, "web", "auth_log_file_requests", default=False
)
api = ZuulWebAPI(self)
self.api = api
route_map = self.generateRouteMap(