Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 27d7fd3a93 | |||
| 220f6e7369 | |||
| e709956d0b | |||
| 93d89ba662 | |||
| 646497de68 | |||
| d4d34aa029 | |||
| 5e351b85f0 | |||
| ab2a6c8aef | |||
| 6b7f3f6924 | |||
| 4c032a3b53 | |||
| 64c9d551ba | |||
| db7b7e66bf | |||
| 0232343126 | |||
| b26514714f |
@@ -1,17 +1,37 @@
|
||||
# This composite action is designed for Gitea Actions runners.
|
||||
# Gitea Actions supports GitHub Actions syntax including $GITHUB_OUTPUT,
|
||||
# actions/cache, and actions/checkout.
|
||||
# This composite action supports both Gitea Actions and GitHub Actions runners.
|
||||
# It detects the VCS host type by checking whether github.api_url is set
|
||||
# (present on GitHub.com and GHES runners, absent on Gitea runners) and uses
|
||||
# the appropriate releases API for version resolution and binary download
|
||||
# (REST API on GitHub, direct URLs on Gitea).
|
||||
#
|
||||
# Security notes:
|
||||
# - On GitHub/GHES (VCS_TYPE=github), inputs.gitea-url is IGNORED to prevent
|
||||
# token exfiltration. API calls use github.api_url; downloads use
|
||||
# github.server_url. Tokens are never sent to user-supplied URLs.
|
||||
# - On Gitea (VCS_TYPE=gitea), inputs.gitea-url is validated (https scheme,
|
||||
# no whitespace/newlines) before use.
|
||||
# - action-repo is validated against owner/repo pattern.
|
||||
# - Tokens are passed via masked environment variables, not step outputs.
|
||||
#
|
||||
# Requirements: python3, sha256sum, curl (all present on ubuntu-* runners).
|
||||
name: 'AI Code Review'
|
||||
description: 'Run AI-powered code review on a pull request using review-bot'
|
||||
|
||||
inputs:
|
||||
gitea-url:
|
||||
description: 'Gitea instance URL (defaults to server_url)'
|
||||
description: 'Gitea instance URL (only used on Gitea runners; ignored on GitHub/GHES). Defaults to server_url.'
|
||||
required: false
|
||||
default: ''
|
||||
repo:
|
||||
description: 'Repository (owner/name, defaults to current)'
|
||||
description: 'Repository to review (owner/name, defaults to current)'
|
||||
required: false
|
||||
default: ''
|
||||
action-repo:
|
||||
description: 'Repository hosting review-bot releases (owner/name). Defaults to github.action_repository or rodin/review-bot.'
|
||||
required: false
|
||||
default: ''
|
||||
action-repo-token:
|
||||
description: 'Token for downloading release assets from action-repo (defaults to github.token on GitHub, reviewer-token on Gitea). Required for private repos.'
|
||||
required: false
|
||||
default: ''
|
||||
pr-number:
|
||||
@@ -19,7 +39,7 @@ inputs:
|
||||
required: false
|
||||
default: ''
|
||||
reviewer-token:
|
||||
description: 'Gitea token for posting the review'
|
||||
description: 'Token for posting the review'
|
||||
required: true
|
||||
reviewer-name:
|
||||
description: 'Display name for the reviewer'
|
||||
@@ -112,19 +132,131 @@ runs:
|
||||
id: version
|
||||
shell: bash
|
||||
run: |
|
||||
GITEA_URL="${{ inputs.gitea-url || github.server_url }}"
|
||||
REPO="${{ inputs.repo || 'rodin/review-bot' }}"
|
||||
set -euo pipefail
|
||||
|
||||
# --- Input Validation ---
|
||||
|
||||
# Determine the repo hosting review-bot releases (not the repo being reviewed)
|
||||
ACTION_REPO="${{ inputs.action-repo }}"
|
||||
if [ -z "$ACTION_REPO" ]; then
|
||||
# github.action_repository is the repo containing the running action
|
||||
ACTION_REPO="${{ github.action_repository }}"
|
||||
fi
|
||||
if [ -z "$ACTION_REPO" ]; then
|
||||
# Final fallback for Gitea (which may not set action_repository)
|
||||
ACTION_REPO="rodin/review-bot"
|
||||
echo "::notice::action-repo not specified and github.action_repository is empty; falling back to rodin/review-bot"
|
||||
fi
|
||||
|
||||
# Validate ACTION_REPO matches owner/repo pattern (prevent path traversal)
|
||||
if ! printf '%s' "$ACTION_REPO" | grep -qE '^[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+$'; then
|
||||
echo "Error: action-repo '${ACTION_REPO}' does not match expected owner/repo format" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Detect VCS host type using github.api_url context.
|
||||
# github.api_url is set on GitHub.com (https://api.github.com) and GHES
|
||||
# (https://<host>/api/v3). It is empty/unset on Gitea Actions runners.
|
||||
GITHUB_API_URL="${{ github.api_url }}"
|
||||
if [ -n "$GITHUB_API_URL" ]; then
|
||||
VCS_TYPE="github"
|
||||
else
|
||||
VCS_TYPE="gitea"
|
||||
fi
|
||||
|
||||
# Determine SERVER_URL based on VCS type.
|
||||
# SECURITY: On GitHub/GHES, ALWAYS use github.server_url — never trust
|
||||
# inputs.gitea-url to prevent token exfiltration to attacker-controlled hosts.
|
||||
if [ "$VCS_TYPE" = "github" ]; then
|
||||
SERVER_URL="${{ github.server_url }}"
|
||||
if [ -n "${{ inputs.gitea-url }}" ]; then
|
||||
echo "::warning::inputs.gitea-url is ignored on GitHub/GHES runners (VCS_TYPE=github). Using github.server_url instead."
|
||||
fi
|
||||
else
|
||||
SERVER_URL="${{ inputs.gitea-url || github.server_url }}"
|
||||
fi
|
||||
# Strip trailing slash if present
|
||||
SERVER_URL="${SERVER_URL%/}"
|
||||
|
||||
# Validate SERVER_URL for Gitea path: must be https, no whitespace/newlines.
|
||||
# The [^[:space:]] class already rejects newlines, so no separate newline check needed.
|
||||
if [ "$VCS_TYPE" = "gitea" ]; then
|
||||
if ! printf '%s' "$SERVER_URL" | grep -qE '^https://[^[:space:]]+$'; then
|
||||
echo "Error: SERVER_URL '${SERVER_URL}' must be an https:// URL with no whitespace" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Determine auth token for release API requests
|
||||
ACTION_TOKEN="${{ inputs.action-repo-token }}"
|
||||
if [ -z "$ACTION_TOKEN" ]; then
|
||||
if [ "$VCS_TYPE" = "github" ]; then
|
||||
ACTION_TOKEN="${{ github.token }}"
|
||||
else
|
||||
ACTION_TOKEN="${{ inputs.reviewer-token }}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Validate token contains no control characters (defense-in-depth against header injection)
|
||||
if [ -n "$ACTION_TOKEN" ]; then
|
||||
if printf '%s' "$ACTION_TOKEN" | LC_ALL=C grep -q '[^[:print:]]'; then
|
||||
echo "Error: ACTION_TOKEN contains control characters" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "${{ inputs.version }}" = "latest" ]; then
|
||||
VERSION=$(curl -sSf "${GITEA_URL}/api/v1/repos/${REPO}/releases?limit=1" \
|
||||
| python3 -c "import sys, json; releases = json.load(sys.stdin); print(releases[0]['tag_name'] if releases else '')")
|
||||
if [ "$VCS_TYPE" = "github" ]; then
|
||||
# SECURITY: Use github.api_url which is a trusted platform-provided value.
|
||||
# Never construct API URLs from user-supplied inputs on GitHub.
|
||||
API_URL="${GITHUB_API_URL}/repos/${ACTION_REPO}/releases?per_page=1"
|
||||
else
|
||||
# Gitea API — SERVER_URL was validated above
|
||||
API_URL="${SERVER_URL}/api/v1/repos/${ACTION_REPO}/releases?limit=1"
|
||||
fi
|
||||
|
||||
# Fetch latest version with inline auth header (no intermediate variable)
|
||||
if [ -n "$ACTION_TOKEN" ]; then
|
||||
if [ "$VCS_TYPE" = "github" ]; then
|
||||
VERSION=$(curl -sSf --connect-timeout 10 --max-time 30 \
|
||||
-H "Authorization: Bearer ${ACTION_TOKEN}" "$API_URL" \
|
||||
| python3 -c "import sys, json; releases = json.load(sys.stdin); print(releases[0]['tag_name'] if releases else '')")
|
||||
else
|
||||
VERSION=$(curl -sSf --connect-timeout 10 --max-time 30 \
|
||||
-H "Authorization: token ${ACTION_TOKEN}" "$API_URL" \
|
||||
| python3 -c "import sys, json; releases = json.load(sys.stdin); print(releases[0]['tag_name'] if releases else '')")
|
||||
fi
|
||||
else
|
||||
VERSION=$(curl -sSf --connect-timeout 10 --max-time 30 "$API_URL" \
|
||||
| python3 -c "import sys, json; releases = json.load(sys.stdin); print(releases[0]['tag_name'] if releases else '')")
|
||||
fi
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "Failed to determine latest version" >&2
|
||||
echo "Failed to determine latest version from ${API_URL}" >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
VERSION="${{ inputs.version }}"
|
||||
fi
|
||||
|
||||
# Validate VERSION: no slashes or whitespace (prevent path traversal).
|
||||
# [:space:] includes newlines and carriage returns in POSIX.
|
||||
if printf '%s' "$VERSION" | grep -qE '[/[:space:]]'; then
|
||||
echo "Error: VERSION '${VERSION}' contains invalid characters (newline, slash, or whitespace)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "action_repo=${ACTION_REPO}" >> "$GITHUB_OUTPUT"
|
||||
echo "server_url=${SERVER_URL}" >> "$GITHUB_OUTPUT"
|
||||
echo "vcs_type=${VCS_TYPE}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# SECURITY: Pass token via masked environment variable instead of step output.
|
||||
# Step outputs can leak in debug logs; GITHUB_ENV with masking is safer.
|
||||
if [ -n "$ACTION_TOKEN" ]; then
|
||||
echo "::add-mask::${ACTION_TOKEN}"
|
||||
echo "ACTION_TOKEN=${ACTION_TOKEN}" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Cache review-bot binary
|
||||
id: cache
|
||||
@@ -137,19 +269,111 @@ runs:
|
||||
if: steps.cache.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
GITEA_URL="${{ inputs.gitea-url || github.server_url }}"
|
||||
REPO="${{ inputs.repo || 'rodin/review-bot' }}"
|
||||
set -euo pipefail
|
||||
|
||||
SERVER_URL="${{ steps.version.outputs.server_url }}"
|
||||
ACTION_REPO="${{ steps.version.outputs.action_repo }}"
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
VCS_TYPE="${{ steps.version.outputs.vcs_type }}"
|
||||
# Read token from masked environment variable (set in Determine version step)
|
||||
# Falls back to empty if not set (public repos don't need auth)
|
||||
ACTION_TOKEN="${ACTION_TOKEN:-}"
|
||||
BINARY="review-bot-linux-amd64"
|
||||
|
||||
curl -sSfL "${GITEA_URL}/${REPO}/releases/download/${VERSION}/${BINARY}" \
|
||||
-o "${{ runner.temp }}/review-bot"
|
||||
curl -sSfL "${GITEA_URL}/${REPO}/releases/download/${VERSION}/checksums.txt" \
|
||||
-o "${{ runner.temp }}/checksums.txt"
|
||||
if [ "$VCS_TYPE" = "github" ]; then
|
||||
# GitHub/GHES: Use REST API for release asset downloads.
|
||||
# Web release URLs ({server}/.../releases/download/{tag}/{asset}) redirect
|
||||
# to S3 and don't reliably support Authorization headers for private repos.
|
||||
# The REST API endpoint with Accept: application/octet-stream is required.
|
||||
# GITHUB_API_URL: trusted platform value, same as detected in "Determine version" step.
|
||||
GITHUB_API_URL="${{ github.api_url }}"
|
||||
|
||||
if [ -n "$ACTION_TOKEN" ]; then
|
||||
RELEASE_JSON=$(curl -sSf --connect-timeout 10 --max-time 30 \
|
||||
-H "Authorization: Bearer ${ACTION_TOKEN}" \
|
||||
"${GITHUB_API_URL}/repos/${ACTION_REPO}/releases/tags/${VERSION}")
|
||||
else
|
||||
RELEASE_JSON=$(curl -sSf --connect-timeout 10 --max-time 30 \
|
||||
"${GITHUB_API_URL}/repos/${ACTION_REPO}/releases/tags/${VERSION}")
|
||||
fi
|
||||
|
||||
# Extract asset IDs for binary and checksums
|
||||
BINARY_ASSET_ID=$(printf '%s' "$RELEASE_JSON" | python3 -c "
|
||||
import sys, json
|
||||
assets = json.load(sys.stdin).get('assets', [])
|
||||
for a in assets:
|
||||
if a['name'] == '${BINARY}':
|
||||
print(a['id'])
|
||||
break
|
||||
")
|
||||
if [ -z "$BINARY_ASSET_ID" ]; then
|
||||
echo "Error: could not find asset '${BINARY}' in release ${VERSION}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CHECKSUMS_ASSET_ID=$(printf '%s' "$RELEASE_JSON" | python3 -c "
|
||||
import sys, json
|
||||
assets = json.load(sys.stdin).get('assets', [])
|
||||
for a in assets:
|
||||
if a['name'] == 'checksums.txt':
|
||||
print(a['id'])
|
||||
break
|
||||
")
|
||||
if [ -z "$CHECKSUMS_ASSET_ID" ]; then
|
||||
echo "Error: could not find asset 'checksums.txt' in release ${VERSION}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Download assets via REST API with Accept: application/octet-stream
|
||||
if [ -n "$ACTION_TOKEN" ]; then
|
||||
curl -sSfL --connect-timeout 10 --max-time 120 \
|
||||
-H "Authorization: Bearer ${ACTION_TOKEN}" \
|
||||
-H "Accept: application/octet-stream" \
|
||||
"${GITHUB_API_URL}/repos/${ACTION_REPO}/releases/assets/${BINARY_ASSET_ID}" \
|
||||
-o "${{ runner.temp }}/review-bot"
|
||||
curl -sSfL --connect-timeout 10 --max-time 30 \
|
||||
-H "Authorization: Bearer ${ACTION_TOKEN}" \
|
||||
-H "Accept: application/octet-stream" \
|
||||
"${GITHUB_API_URL}/repos/${ACTION_REPO}/releases/assets/${CHECKSUMS_ASSET_ID}" \
|
||||
-o "${{ runner.temp }}/checksums.txt"
|
||||
else
|
||||
curl -sSfL --connect-timeout 10 --max-time 120 \
|
||||
-H "Accept: application/octet-stream" \
|
||||
"${GITHUB_API_URL}/repos/${ACTION_REPO}/releases/assets/${BINARY_ASSET_ID}" \
|
||||
-o "${{ runner.temp }}/review-bot"
|
||||
curl -sSfL --connect-timeout 10 --max-time 30 \
|
||||
-H "Accept: application/octet-stream" \
|
||||
"${GITHUB_API_URL}/repos/${ACTION_REPO}/releases/assets/${CHECKSUMS_ASSET_ID}" \
|
||||
-o "${{ runner.temp }}/checksums.txt"
|
||||
fi
|
||||
else
|
||||
# Gitea: Direct download via web release URLs (Gitea serves assets
|
||||
# directly without redirects — no -L needed).
|
||||
# SECURITY: Omitting -L prevents forwarding Authorization header to
|
||||
# unexpected hosts if Gitea ever introduces CDN redirects.
|
||||
DOWNLOAD_URL="${SERVER_URL}/${ACTION_REPO}/releases/download/${VERSION}"
|
||||
|
||||
if [ -n "$ACTION_TOKEN" ]; then
|
||||
curl -sSf --connect-timeout 10 --max-time 120 \
|
||||
-H "Authorization: token ${ACTION_TOKEN}" \
|
||||
"${DOWNLOAD_URL}/${BINARY}" -o "${{ runner.temp }}/review-bot"
|
||||
curl -sSf --connect-timeout 10 --max-time 30 \
|
||||
-H "Authorization: token ${ACTION_TOKEN}" \
|
||||
"${DOWNLOAD_URL}/checksums.txt" -o "${{ runner.temp }}/checksums.txt"
|
||||
else
|
||||
curl -sSf --connect-timeout 10 --max-time 120 \
|
||||
"${DOWNLOAD_URL}/${BINARY}" -o "${{ runner.temp }}/review-bot"
|
||||
curl -sSf --connect-timeout 10 --max-time 30 \
|
||||
"${DOWNLOAD_URL}/checksums.txt" -o "${{ runner.temp }}/checksums.txt"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Verify SHA-256 checksum
|
||||
# NOTE: This verifies integrity (download wasn't corrupted) but not
|
||||
# authenticity — both binary and checksums come from the same server.
|
||||
# For stronger guarantees, consider GPG signature verification.
|
||||
cd "${{ runner.temp }}"
|
||||
EXPECTED=$(grep "${BINARY}" checksums.txt | awk '{print $1}')
|
||||
EXPECTED=$(grep -E "^[0-9a-f]+[[:space:]]+\*?${BINARY}$" checksums.txt | awk '{print $1}')
|
||||
ACTUAL=$(sha256sum review-bot | awk '{print $1}')
|
||||
|
||||
if [ -z "$EXPECTED" ]; then
|
||||
@@ -169,7 +393,7 @@ runs:
|
||||
- name: Run review
|
||||
shell: bash
|
||||
env:
|
||||
GITEA_URL: ${{ inputs.gitea-url || github.server_url }}
|
||||
GITEA_URL: ${{ steps.version.outputs.server_url }}
|
||||
GITEA_REPO: ${{ inputs.repo || github.repository }}
|
||||
PR_NUMBER: ${{ inputs.pr-number || github.event.pull_request.number }}
|
||||
REVIEWER_TOKEN: ${{ inputs.reviewer-token }}
|
||||
|
||||
+79
-6
@@ -8,7 +8,10 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -93,10 +96,14 @@ type Client struct {
|
||||
// Higher-level exported methods (GetPullRequest, etc.) will use it to
|
||||
// construct request URLs; remove this field if those methods end up
|
||||
// accepting full URLs instead.
|
||||
baseURL string
|
||||
token string
|
||||
baseURL string
|
||||
token string
|
||||
httpClient *http.Client
|
||||
|
||||
// allowInsecureHTTP permits requests to HTTP (non-TLS) endpoints.
|
||||
// When false, doRequest rejects URLs with an http:// scheme.
|
||||
allowInsecureHTTP bool
|
||||
|
||||
// retryBackoff defines the delays between retry attempts for 429 responses.
|
||||
// retryBackoff[i] is the delay before attempt i+1 (after attempt i fails).
|
||||
// If nil, defaults to {1s, 2s}.
|
||||
@@ -135,16 +142,53 @@ func defaultCheckRedirect(req *http.Request, via []*http.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClientOption configures optional behavior of a Client.
|
||||
type ClientOption func(*clientConfig)
|
||||
|
||||
type clientConfig struct {
|
||||
allowInsecureHTTP bool
|
||||
insecureIsTestBypass bool
|
||||
}
|
||||
|
||||
// AllowInsecureHTTP permits sending credentials over plaintext HTTP connections.
|
||||
// In production, this option is gated by the REVIEW_BOT_ALLOW_INSECURE=1
|
||||
// environment variable. Without the env var set, the option is ignored
|
||||
// and a warning is logged.
|
||||
//
|
||||
// For tests, use AllowInsecureHTTPForTest (defined in a _test.go file in the same package) which bypasses the env gate.
|
||||
func AllowInsecureHTTP() ClientOption {
|
||||
return func(cfg *clientConfig) {
|
||||
cfg.allowInsecureHTTP = true
|
||||
}
|
||||
}
|
||||
|
||||
// NewClient creates a new GitHub API client.
|
||||
// If baseURL is empty, it defaults to https://api.github.com.
|
||||
// For GitHub Enterprise, pass the API base URL (e.g. https://github.concur.com/api/v3).
|
||||
func NewClient(token, baseURL string) *Client {
|
||||
func NewClient(token, baseURL string, opts ...ClientOption) *Client {
|
||||
if baseURL == "" {
|
||||
baseURL = defaultBaseURL
|
||||
}
|
||||
|
||||
var cfg clientConfig
|
||||
for _, opt := range opts {
|
||||
opt(&cfg)
|
||||
}
|
||||
|
||||
if cfg.allowInsecureHTTP && !cfg.insecureIsTestBypass {
|
||||
if os.Getenv("REVIEW_BOT_ALLOW_INSECURE") != "1" {
|
||||
slog.Warn("AllowInsecureHTTP ignored: set REVIEW_BOT_ALLOW_INSECURE=1 to enable")
|
||||
cfg.allowInsecureHTTP = false
|
||||
} else {
|
||||
slog.Warn("AllowInsecureHTTP enabled — credentials may be sent over plaintext",
|
||||
"env", "REVIEW_BOT_ALLOW_INSECURE=1")
|
||||
}
|
||||
}
|
||||
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
token: token,
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
token: token,
|
||||
allowInsecureHTTP: cfg.allowInsecureHTTP,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
CheckRedirect: defaultCheckRedirect,
|
||||
@@ -215,10 +259,39 @@ func (c *Client) parseRetryAfter(value string) (time.Duration, bool) {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// redactURL redacts sensitive components from a URL for safe inclusion in error
|
||||
// messages and log output. It removes userinfo (e.g., user:pass@) and replaces
|
||||
// query parameters with a placeholder.
|
||||
func redactURL(rawURL string) string {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return "<unparseable URL>"
|
||||
}
|
||||
u.User = nil
|
||||
|
||||
if u.RawQuery != "" {
|
||||
u.RawQuery = "<redacted>"
|
||||
}
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// doRequest performs an HTTP request with retry on 429 rate limit responses.
|
||||
// It respects the Retry-After header when present, supporting both integer
|
||||
// seconds and HTTP-date formats (capped at maxRetryAfter).
|
||||
func (c *Client) doRequest(ctx context.Context, method, reqURL string, accept string) ([]byte, error) {
|
||||
// NOTE: This parses reqURL a second time (http.NewRequestWithContext parses it
|
||||
// again internally). Acceptable cost: URL parsing is cheap and threading the
|
||||
// parsed *url.URL through would complicate the interface for negligible gain.
|
||||
if !c.allowInsecureHTTP {
|
||||
parsed, err := url.Parse(reqURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse request URL: %w", err)
|
||||
}
|
||||
if strings.EqualFold(parsed.Scheme, "http") {
|
||||
return nil, fmt.Errorf("refusing HTTP request to %s: use HTTPS or set AllowInsecureHTTP option", redactURL(reqURL))
|
||||
}
|
||||
}
|
||||
|
||||
var backoff []time.Duration
|
||||
if c.retryBackoff != nil {
|
||||
backoff = append([]time.Duration(nil), c.retryBackoff...)
|
||||
@@ -237,7 +310,7 @@ func (c *Client) doRequest(ctx context.Context, method, reqURL string, accept st
|
||||
timer := time.NewTimer(delay)
|
||||
select {
|
||||
case <-timer.C:
|
||||
timer.Stop()
|
||||
timer.Stop() // no-op after fire; kept for symmetry with the ctx.Done case
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return nil, ctx.Err()
|
||||
|
||||
+154
-9
@@ -35,7 +35,7 @@ func TestDoRequest_Success(t *testing.T) {
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient("test-token", srv.URL)
|
||||
c := NewClient("test-token", srv.URL, AllowInsecureHTTPForTest())
|
||||
body, err := c.doGet(context.Background(), srv.URL+"/test")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -60,7 +60,7 @@ func TestDoRequest_429_RetryAfter_IntegerSeconds(t *testing.T) {
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient("tok", srv.URL)
|
||||
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
|
||||
c.SetRetryBackoff([]time.Duration{0, 0})
|
||||
|
||||
body, err := c.doGet(context.Background(), srv.URL+"/test")
|
||||
@@ -94,7 +94,7 @@ func TestDoRequest_429_RetryAfter_HTTPDate(t *testing.T) {
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient("tok", srv.URL)
|
||||
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
|
||||
c.now = func() time.Time { return fixedNow }
|
||||
// Initial backoff is 0; the HTTP-date parser will compute 1s and override.
|
||||
c.SetRetryBackoff([]time.Duration{0, 0})
|
||||
@@ -130,7 +130,7 @@ func TestDoRequest_429_RetryAfter_HTTPDate_InPast(t *testing.T) {
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient("tok", srv.URL)
|
||||
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
|
||||
c.now = func() time.Time { return fixedNow }
|
||||
c.SetRetryBackoff([]time.Duration{0, 0})
|
||||
|
||||
@@ -157,7 +157,7 @@ func TestDoRequest_429_NoRetryAfter_UsesDefaultBackoff(t *testing.T) {
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient("tok", srv.URL)
|
||||
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
|
||||
c.SetRetryBackoff([]time.Duration{0, 0})
|
||||
|
||||
body, err := c.doGet(context.Background(), srv.URL+"/test")
|
||||
@@ -187,7 +187,7 @@ func TestDoRequest_429_InvalidRetryAfter_UsesDefaultBackoff(t *testing.T) {
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient("tok", srv.URL)
|
||||
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
|
||||
c.SetRetryBackoff([]time.Duration{0, 0})
|
||||
|
||||
body, err := c.doGet(context.Background(), srv.URL+"/test")
|
||||
@@ -208,7 +208,7 @@ func TestDoRequest_404_NoRetry(t *testing.T) {
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient("tok", srv.URL)
|
||||
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
|
||||
_, err := c.doGet(context.Background(), srv.URL+"/test")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
@@ -230,7 +230,7 @@ func TestDoRequest_401_NoRetry(t *testing.T) {
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient("tok", srv.URL)
|
||||
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
|
||||
_, err := c.doGet(context.Background(), srv.URL+"/test")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
@@ -260,7 +260,7 @@ func TestDoRequest_ContextCanceled(t *testing.T) {
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient("tok", srv.URL)
|
||||
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
|
||||
c.SetRetryBackoff([]time.Duration{10 * time.Second, 10 * time.Second})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
@@ -511,3 +511,148 @@ func TestSetHTTPClient_NilRestoresDefault(t *testing.T) {
|
||||
t.Fatal("expected CheckRedirect policy after SetHTTPClient(nil)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowInsecureHTTPForTest_PermitsHTTP(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("ok"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
|
||||
body, err := c.doGet(context.Background(), srv.URL+"/test")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if string(body) != "ok" {
|
||||
t.Errorf("body = %q, want %q", body, "ok")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoInsecureOption_RejectsHTTP(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("request should not have been sent")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient("tok", srv.URL)
|
||||
_, err := c.doGet(context.Background(), srv.URL+"/test")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for HTTP request without AllowInsecureHTTP")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "refusing HTTP request") {
|
||||
t.Errorf("unexpected error message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoInsecureOption_RejectsUppercaseHTTP(t *testing.T) {
|
||||
// Verify case-insensitive scheme check (RFC 3986).
|
||||
c := NewClient("tok", "HTTP://127.0.0.1:1")
|
||||
_, err := c.doGet(context.Background(), "HTTP://127.0.0.1:1/test")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for uppercase HTTP scheme")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "refusing HTTP request") {
|
||||
t.Errorf("unexpected error message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoInsecureOption_RejectsMixedCaseHTTP(t *testing.T) {
|
||||
// Verify mixed case like "Http://" is also rejected.
|
||||
c := NewClient("tok", "Http://127.0.0.1:1")
|
||||
_, err := c.doGet(context.Background(), "Http://127.0.0.1:1/test")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for mixed-case HTTP scheme")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "refusing HTTP request") {
|
||||
t.Errorf("unexpected error message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowInsecureHTTP_WithoutEnvVar_Rejected(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("request should not have been sent")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
t.Setenv("REVIEW_BOT_ALLOW_INSECURE", "")
|
||||
|
||||
c := NewClient("tok", srv.URL, AllowInsecureHTTP())
|
||||
_, err := c.doGet(context.Background(), srv.URL+"/test")
|
||||
if err == nil {
|
||||
t.Fatal("expected error: AllowInsecureHTTP without env var should be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "refusing HTTP request") {
|
||||
t.Errorf("unexpected error message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowInsecureHTTP_WithEnvVar_Permitted(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("insecure-ok"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
t.Setenv("REVIEW_BOT_ALLOW_INSECURE", "1")
|
||||
|
||||
c := NewClient("tok", srv.URL, AllowInsecureHTTP())
|
||||
body, err := c.doGet(context.Background(), srv.URL+"/test")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if string(body) != "insecure-ok" {
|
||||
t.Errorf("body = %q, want %q", body, "insecure-ok")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowInsecureHTTP_EnvVarNotOne_Rejected(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("request should not have been sent")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// "true" is not "1" — strict check
|
||||
t.Setenv("REVIEW_BOT_ALLOW_INSECURE", "true")
|
||||
|
||||
c := NewClient("tok", srv.URL, AllowInsecureHTTP())
|
||||
_, err := c.doGet(context.Background(), srv.URL+"/test")
|
||||
if err == nil {
|
||||
t.Fatal("expected error: env var 'true' is not '1'")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "refusing HTTP request") {
|
||||
t.Errorf("unexpected error message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactURL_WithQuery(t *testing.T) {
|
||||
got := redactURL("http://localhost:1234/path?secret=token&foo=bar")
|
||||
want := "http://localhost:1234/path?<redacted>"
|
||||
if got != want {
|
||||
t.Errorf("redactURL = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactURL_NoQuery(t *testing.T) {
|
||||
got := redactURL("http://localhost:1234/path")
|
||||
want := "http://localhost:1234/path"
|
||||
if got != want {
|
||||
t.Errorf("redactURL = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactURL_Userinfo(t *testing.T) {
|
||||
got := redactURL("http://user:pass@localhost:1234/path")
|
||||
want := "http://localhost:1234/path"
|
||||
if got != want {
|
||||
t.Errorf("redactURL = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactURL_UserinfoWithQuery(t *testing.T) {
|
||||
got := redactURL("http://user:pass@localhost:1234/path?secret=token")
|
||||
want := "http://localhost:1234/path?<redacted>"
|
||||
if got != want {
|
||||
t.Errorf("redactURL = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package github
|
||||
|
||||
// AllowInsecureHTTPForTest permits sending credentials over plaintext HTTP
|
||||
// without requiring the REVIEW_BOT_ALLOW_INSECURE environment variable.
|
||||
// This is intended exclusively for test code using httptest.Server.
|
||||
//
|
||||
// Defined in a _test.go file so it is only available to test binaries.
|
||||
func AllowInsecureHTTPForTest() ClientOption {
|
||||
return func(cfg *clientConfig) {
|
||||
cfg.allowInsecureHTTP = true
|
||||
cfg.insecureIsTestBypass = true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user