Browse Source

fix git credentials

main
Silberengel 4 weeks ago
parent
commit
b6983321b3
  1. 47
      README.md
  2. 57
      docs/GIT_CREDENTIAL_HELPER.md
  3. 233
      scripts/git-credential-nostr.js
  4. 3
      src/lib/services/nostr/nip98-auth.ts
  5. 309
      src/routes/api/git/[...path]/+server.ts

47
README.md

@ -351,7 +351,7 @@ See `docs/SECURITY.md` and `docs/SECURITY_IMPLEMENTATION.md` for detailed inform @@ -351,7 +351,7 @@ See `docs/SECURITY.md` and `docs/SECURITY_IMPLEMENTATION.md` for detailed inform
## Environment Variables
- `NOSTRGIT_SECRET_KEY`: User's nsec (bech32 or hex) for client-side git operations via credential helper (optional)
- `NOSTRGIT_SECRET_KEY`: User's Nostr private key (nsec bech32 or hex) for git command-line operations via credential helper. Required for `git clone`, `git push`, and `git pull` operations from the command line. See [Git Command Line Setup](#git-command-line-setup) above.
- `GIT_REPO_ROOT`: Path to store git repositories (default: `/repos`)
- `GIT_DOMAIN`: Domain for git repositories (default: `localhost:6543`)
- `NOSTR_RELAYS`: Comma-separated list of Nostr relays (default: `wss://theforest.nostr1.com`)
@ -446,22 +446,61 @@ The server will automatically locate `git-http-backend` in common locations. @@ -446,22 +446,61 @@ The server will automatically locate `git-http-backend` in common locations.
The server will automatically provision the repository.
### Git Command Line Setup
To use git from the command line with GitRepublic, you need to configure the credential helper. This enables automatic NIP-98 authentication for all git operations (clone, push, pull).
**Quick Setup:**
1. **Set your Nostr private key**:
```bash
export NOSTRGIT_SECRET_KEY="nsec1..."
# Or add to ~/.bashrc or ~/.zshrc for persistence
echo 'export NOSTRGIT_SECRET_KEY="nsec1..."' >> ~/.bashrc
```
2. **Configure git credential helper**:
```bash
# For a specific domain (recommended)
git config --global credential.https://your-domain.com.helper '!node /absolute/path/to/gitrepublic-web/scripts/git-credential-nostr.js'
# For localhost development
git config --global credential.http://localhost:5173.helper '!node /absolute/path/to/gitrepublic-web/scripts/git-credential-nostr.js'
```
3. **Make the script executable**:
```bash
chmod +x /absolute/path/to/gitrepublic-web/scripts/git-credential-nostr.js
```
**Important Notes:**
- The `NOSTRGIT_SECRET_KEY` must match the repository owner or you must have maintainer permissions
- The credential helper generates fresh NIP-98 tokens for each request (per-request authentication)
- Never commit your private key to version control
For complete setup instructions and troubleshooting, see [docs/GIT_CREDENTIAL_HELPER.md](./docs/GIT_CREDENTIAL_HELPER.md).
### Cloning a Repository
```bash
# Public repository
git clone https://{domain}/{npub}/{repo-name}.git
```
For private repositories, configure git with NIP-98 authentication.
# Private repository (requires credential helper setup)
git clone https://{domain}/{npub}/{repo-name}.git
```
### Pushing to a Repository
```bash
# Add remote
git remote add origin https://{domain}/{npub}/{repo-name}.git
# Push (requires credential helper setup)
git push origin main
```
Requires NIP-98 authentication. Your git client needs to support NIP-98 or you can use a custom credential helper.
The credential helper will automatically generate NIP-98 authentication tokens for push operations.
### Viewing Repositories

57
docs/GIT_CREDENTIAL_HELPER.md

@ -36,10 +36,18 @@ export NOSTRGIT_SECRET_KEY="<your-64-char-hex-private-key>" @@ -36,10 +36,18 @@ export NOSTRGIT_SECRET_KEY="<your-64-char-hex-private-key>"
### 3. Configure git to use the credential helper
**Important:** The credential helper must be called for EACH request (not just the first one), because NIP-98 requires per-request authentication tokens. Make sure it's configured BEFORE any caching credential helpers.
#### Global configuration (for all GitRepublic repositories):
```bash
# Add our helper FIRST (before any cache/store helpers)
git config --global credential.helper '!node /absolute/path/to/gitrepublic-web/scripts/git-credential-nostr.js'
# Optional: Disable credential caching to ensure our helper is always called
git config --global credential.helper cache
# Or remove cache helper if you want to ensure fresh credentials each time:
# git config --global --unset credential.helper cache
```
#### Per-domain configuration (recommended):
@ -160,11 +168,14 @@ git pull gitrepublic-web main @@ -160,11 +168,14 @@ git pull gitrepublic-web main
## How It Works
1. When git needs credentials, it calls the credential helper with the repository URL
2. The helper reads your `NOSTRGIT_SECRET_KEY` environment variable (with fallbacks for backward compatibility)
3. It creates a NIP-98 authentication event signed with your private key
4. The signed event is base64-encoded and returned as the "password"
5. Git sends this in the `Authorization: Nostr <base64-event>` header
6. The GitRepublic server verifies the NIP-98 auth event and grants access
2. The helper reads your `NOSTRGIT_SECRET_KEY` environment variable
3. It creates a NIP-98 authentication event signed with your private key for the specific URL and HTTP method
4. The signed event is base64-encoded and returned as `username=nostr` and `password=<base64-event>`
5. Git converts this to `Authorization: Basic <base64(username:password)>` header
6. The GitRepublic server detects Basic auth with username "nostr" and converts it to `Authorization: Nostr <base64-event>` format
7. The server verifies the NIP-98 auth event (signature, URL, method, timestamp) and grants access if valid
**Important:** The credential helper generates fresh credentials for each request because NIP-98 requires per-request authentication tokens. The URL and HTTP method are part of the signed event, so credentials cannot be reused.
## Troubleshooting
@ -189,13 +200,39 @@ export NOSTRGIT_SECRET_KEY="nsec1..." @@ -189,13 +200,39 @@ export NOSTRGIT_SECRET_KEY="nsec1..."
- Check that the repository URL is correct
- Ensure your key has maintainer permissions for push operations
### Push operations fail
### Push operations fail or show login dialog
If you see a login dialog when pushing, git isn't calling the credential helper for the POST request. This usually happens because:
1. **Credential helper not configured correctly**:
```bash
# Check your credential helper configuration
git config --global --get-regexp credential.helper
# Make sure the GitRepublic helper is configured for your domain
git config --global credential.http://localhost:5173.helper '!node /path/to/gitrepublic-web/scripts/git-credential-nostr.js'
```
2. **Other credential helpers interfering**: Git might be using cached credentials from another helper. Make sure the GitRepublic helper is listed FIRST:
```bash
# Remove all credential helpers
git config --global --unset-all credential.helper
# Add only the GitRepublic helper
git config --global credential.http://localhost:5173.helper '!node /path/to/gitrepublic-web/scripts/git-credential-nostr.js'
```
3. **NOSTRGIT_SECRET_KEY not set**: Make sure the environment variable is set in the shell where git runs:
```bash
export NOSTRGIT_SECRET_KEY="nsec1..."
```
Push operations require POST authentication. The credential helper automatically detects push operations (when the path contains `git-receive-pack`) and generates a POST auth event. If you still have issues:
4. **Wrong private key**: Ensure your `NOSTRGIT_SECRET_KEY` matches the repository owner or you have maintainer permissions for the repository you're pushing to.
1. Verify you have maintainer permissions for the repository
2. Check that branch protection rules allow your push
3. Ensure your NOSTRGIT_SECRET_KEY is correctly set
5. **Authorization failure (403)**: If authentication succeeds but push fails with 403, check:
- Your pubkey matches the repository owner, OR
- You have maintainer permissions for the repository
- Branch protection rules allow your push
## Security Best Practices

233
scripts/git-credential-nostr.js

@ -19,8 +19,10 @@ @@ -19,8 +19,10 @@
*/
import { createHash } from 'crypto';
import { getEventHash, signEvent, getPublicKey } from 'nostr-tools';
import { finalizeEvent, getPublicKey } from 'nostr-tools';
import { decode } from 'nostr-tools/nip19';
import { readFileSync, existsSync } from 'fs';
import { join, resolve } from 'path';
// NIP-98 auth event kind
const KIND_NIP98_AUTH = 27235;
@ -58,13 +60,102 @@ function readInput() { @@ -58,13 +60,102 @@ function readInput() {
});
}
/**
* Try to extract the git remote URL path from .git/config
* This is used as a fallback when git calls us with wwwauth[] but no path
*/
function tryGetPathFromGitRemote(host, protocol) {
try {
// Git sets GIT_DIR environment variable when calling credential helpers
// Use it if available, otherwise try to find .git directory
let gitDir = process.env.GIT_DIR;
let configPath = null;
if (gitDir) {
// GIT_DIR might point directly to .git directory or to the config file
if (existsSync(gitDir) && existsSync(join(gitDir, 'config'))) {
configPath = join(gitDir, 'config');
} else if (existsSync(gitDir) && gitDir.endsWith('config')) {
configPath = gitDir;
}
}
// If GIT_DIR didn't work, try to find .git directory starting from current working directory
if (!configPath) {
let currentDir = process.cwd();
const maxDepth = 10; // Limit search depth
let depth = 0;
while (depth < maxDepth) {
const potentialGitDir = join(currentDir, '.git');
if (existsSync(potentialGitDir) && existsSync(join(potentialGitDir, 'config'))) {
configPath = join(potentialGitDir, 'config');
break;
}
// Move up one directory
const parentDir = resolve(currentDir, '..');
if (parentDir === currentDir) {
// Reached filesystem root
break;
}
currentDir = parentDir;
depth++;
}
}
if (!configPath || !existsSync(configPath)) {
return null;
}
// Read git config
const config = readFileSync(configPath, 'utf-8');
// Find remotes that match our host
// Match: [remote "name"] ... url = http://host/path
const remoteRegex = /\[remote\s+"([^"]+)"\][\s\S]*?url\s*=\s*([^\n]+)/g;
let match;
while ((match = remoteRegex.exec(config)) !== null) {
const remoteUrl = match[2].trim();
// Check if this remote URL matches our host
try {
const url = new URL(remoteUrl);
const remoteHost = url.hostname + (url.port ? ':' + url.port : '');
if (url.host === host || remoteHost === host) {
// Extract path from remote URL
let path = url.pathname;
if (path && path.includes('git-receive-pack')) {
// Already has git-receive-pack in path
return path;
} else if (path && path.endsWith('.git')) {
// Add git-receive-pack to path
return path + '/git-receive-pack';
} else if (path) {
// Path exists but doesn't end with .git, try adding /git-receive-pack
return path + '/git-receive-pack';
}
}
} catch (e) {
// Not a valid URL, skip
continue;
}
}
} catch (err) {
// If anything fails, return null silently
}
return null;
}
/**
* Normalize URL for NIP-98 (remove trailing slashes, ensure consistent format)
* This must match the normalization used by the server in nip98-auth.ts
*/
function normalizeUrl(url) {
try {
const parsed = new URL(url);
// Remove trailing slash from pathname
// Remove trailing slash from pathname (must match server normalization)
parsed.pathname = parsed.pathname.replace(/\/$/, '');
return parsed.toString();
} catch {
@ -83,9 +174,13 @@ function calculateBodyHash(body) { @@ -83,9 +174,13 @@ function calculateBodyHash(body) {
/**
* Create and sign a NIP-98 authentication event
* @param privateKeyBytes - Private key as Uint8Array (32 bytes)
* @param url - Request URL
* @param method - HTTP method (GET, POST, etc.)
* @param bodyHash - Optional SHA256 hash of request body (for POST requests)
*/
function createNIP98AuthEvent(privateKey, url, method, bodyHash = null) {
const pubkey = getPublicKey(privateKey);
function createNIP98AuthEvent(privateKeyBytes, url, method, bodyHash = null) {
const pubkey = getPublicKey(privateKeyBytes);
const tags = [
['u', normalizeUrl(url)],
['method', method.toUpperCase()]
@ -95,7 +190,7 @@ function createNIP98AuthEvent(privateKey, url, method, bodyHash = null) { @@ -95,7 +190,7 @@ function createNIP98AuthEvent(privateKey, url, method, bodyHash = null) {
tags.push(['payload', bodyHash]);
}
const event = {
const eventTemplate = {
kind: KIND_NIP98_AUTH,
pubkey,
created_at: Math.floor(Date.now() / 1000),
@ -103,11 +198,10 @@ function createNIP98AuthEvent(privateKey, url, method, bodyHash = null) { @@ -103,11 +198,10 @@ function createNIP98AuthEvent(privateKey, url, method, bodyHash = null) {
tags
};
// Sign the event
event.id = getEventHash(event);
event.sig = signEvent(event, privateKey);
// Sign the event using finalizeEvent (which computes id and sig)
const signedEvent = finalizeEvent(eventTemplate, privateKeyBytes);
return event;
return signedEvent;
}
/**
@ -133,12 +227,14 @@ async function main() { @@ -133,12 +227,14 @@ async function main() {
}
// Parse private key (handle both nsec and hex formats)
let privateKey;
// Convert to Uint8Array for nostr-tools functions
let privateKeyBytes;
if (nsec.startsWith('nsec')) {
try {
const decoded = decode(nsec);
if (decoded.type === 'nsec') {
privateKey = decoded.data;
// decoded.data is already Uint8Array for nsec
privateKeyBytes = decoded.data;
} else {
throw new Error('Invalid nsec format - decoded type is not nsec');
}
@ -152,33 +248,121 @@ async function main() { @@ -152,33 +248,121 @@ async function main() {
console.error('Error: Hex private key must be 64 characters (32 bytes)');
process.exit(1);
}
privateKey = nsec;
// Convert hex string to Uint8Array
privateKeyBytes = new Uint8Array(32);
for (let i = 0; i < 32; i++) {
privateKeyBytes[i] = parseInt(nsec.slice(i * 2, i * 2 + 2), 16);
}
}
// Extract URL components from input
// Git credential helper protocol passes: protocol, host, path (and sometimes username, password)
// Git may provide either individual attributes (protocol, host, path) or a url attribute
// If we have a url, use it; otherwise construct from individual attributes
let url;
if (input.url) {
// Git provided a url attribute - use it directly
url = input.url;
} else {
// Construct URL from individual attributes
const protocol = input.protocol || 'https';
const host = input.host;
const path = input.path || '';
let path = input.path || '';
const wwwauth = input['wwwauth[]'] || input.wwwauth;
if (!host) {
console.error('Error: No host specified in credential request');
process.exit(1);
}
// Build full URL
const url = `${protocol}://${host}${path}`;
// If path is missing, try to extract it from git remote URL
// This happens when git calls us reactively after a 401 with wwwauth[] but no path
if (!path) {
if (wwwauth) {
// Try to get path from git remote URL
const extractedPath = tryGetPathFromGitRemote(host, protocol);
if (extractedPath) {
path = extractedPath;
} else {
// Exit without output - git should call us again with the full path when it retries
process.exit(0);
}
} else {
// Exit without output - git will call us again with the full path
process.exit(0);
}
}
// Build full URL (include query string if present)
const query = input.query || '';
const fullPath = query ? `${path}?${query}` : path;
url = `${protocol}://${host}${fullPath}`;
}
// Parse URL to extract components for method detection
let urlPath = '';
try {
const urlObj = new URL(url);
urlPath = urlObj.pathname;
} catch (err) {
// If URL parsing fails, try to extract path from the URL string
const match = url.match(/https?:\/\/[^\/]+(\/.*)/);
urlPath = match ? match[1] : '';
}
// Determine HTTP method based on git operation
// Git credential helper doesn't know the HTTP method, but we can infer it:
// - If path contains 'git-receive-pack', it's a push (POST)
// - Otherwise, it's likely a fetch/clone (GET)
// Note: For initial info/refs requests, git uses GET, so we default to GET
// For actual push operations, git will make POST requests to git-receive-pack
// The server will validate the method matches, so we need to handle this carefully
const method = path.includes('git-receive-pack') ? 'POST' : 'GET';
// - If path contains 'git-upload-pack', it's a fetch (GET)
// - For info/refs requests, check the service query parameter
let method = 'GET';
let authUrl = url; // The URL for which we generate credentials
// Parse query string from URL if present
let query = '';
try {
const urlObj = new URL(url);
query = urlObj.search.slice(1); // Remove leading '?'
} catch (err) {
// If URL parsing fails, try to extract query from the URL string
const match = url.match(/\?(.+)$/);
query = match ? match[1] : '';
}
if (urlPath.includes('git-receive-pack')) {
// Direct POST request to git-receive-pack
method = 'POST';
authUrl = url;
} else if (urlPath.includes('git-upload-pack')) {
// Direct GET request to git-upload-pack
method = 'GET';
authUrl = url;
} else if (query.includes('service=git-receive-pack')) {
// info/refs?service=git-receive-pack - this is a GET request
// However, git might not call us again for the POST request
// So we need to generate credentials for the POST request that will happen next
// Replace info/refs with git-receive-pack in the path
try {
const urlObj = new URL(url);
urlObj.pathname = urlObj.pathname.replace(/\/info\/refs$/, '/git-receive-pack');
urlObj.search = ''; // Remove query string for POST request
authUrl = urlObj.toString();
} catch (err) {
// Fallback: string replacement
authUrl = url.replace(/\/info\/refs(\?.*)?$/, '/git-receive-pack');
}
method = 'POST';
} else {
// Default: GET request (info/refs, etc.)
method = 'GET';
authUrl = url;
}
// Normalize the URL before creating the event (must match server normalization)
const normalizedAuthUrl = normalizeUrl(authUrl);
// Create and sign NIP-98 auth event
const authEvent = createNIP98AuthEvent(privateKey, url, method);
const authEvent = createNIP98AuthEvent(privateKeyBytes, normalizedAuthUrl, method);
// Encode event as base64
const eventJson = JSON.stringify(authEvent);
@ -191,8 +375,11 @@ async function main() { @@ -191,8 +375,11 @@ async function main() {
console.log(`password=${base64Event}`);
} else if (command === 'store') {
// For 'store', we don't need to do anything (credentials are generated on-demand)
// Just exit successfully
// For 'store', we don't store credentials because NIP-98 requires per-request credentials
// The URL and method are part of the signed event, so we can't reuse credentials
// However, we should NOT prevent git from storing - let other credential helpers handle it
// We just exit successfully without storing anything ourselves
// This allows git to call us again for each request
process.exit(0);
} else if (command === 'erase') {
// For 'erase', we don't need to do anything

3
src/lib/services/nostr/nip98-auth.ts

@ -40,7 +40,8 @@ export function verifyNIP98Auth( @@ -40,7 +40,8 @@ export function verifyNIP98Auth(
try {
// Decode base64 event
const base64Event = authHeader.slice(7); // Remove "Nostr " prefix
// "Nostr " is 6 characters (N-o-s-t-r-space), so we slice from index 6
const base64Event = authHeader.slice(6).trim(); // Remove "Nostr " prefix and trim whitespace
const eventJson = Buffer.from(base64Event, 'base64').toString('utf-8');
const nostrEvent: NostrEvent = JSON.parse(eventJson);

309
src/routes/api/git/[...path]/+server.ts

@ -3,7 +3,7 @@ @@ -3,7 +3,7 @@
* Handles git clone, push, pull operations via git-http-backend
*/
import { error } from '@sveltejs/kit';
import { error, json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { RepoManager } from '$lib/services/git/repo-manager.js';
import { requireNpubHex } from '$lib/utils/npub-utils.js';
@ -22,7 +22,9 @@ import logger from '$lib/services/logger.js'; @@ -22,7 +22,9 @@ import logger from '$lib/services/logger.js';
import { auditLogger } from '$lib/services/security/audit-logger.js';
import { isValidBranchName, sanitizeError } from '$lib/utils/security.js';
const repoRoot = process.env.GIT_REPO_ROOT || '/repos';
// Resolve GIT_REPO_ROOT to absolute path (handles both relative and absolute paths)
const repoRootEnv = process.env.GIT_REPO_ROOT || '/repos';
const repoRoot = resolve(repoRootEnv);
const repoManager = new RepoManager(repoRoot);
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS);
const ownershipTransferService = new OwnershipTransferService(DEFAULT_NOSTR_RELAYS);
@ -145,6 +147,56 @@ function extractCloneUrls(event: NostrEvent): string[] { @@ -145,6 +147,56 @@ function extractCloneUrls(event: NostrEvent): string[] {
return urls;
}
/**
* Normalize Authorization header from git credential helper format
* Git credential helper outputs username=nostr and password=<base64-event>
* Git HTTP backend converts this to Authorization: Basic <base64(username:password)>
* This function converts it back to Authorization: Nostr <base64-event> format
*/
function normalizeAuthHeader(authHeader: string | null): string | null {
if (!authHeader) {
return null;
}
// If already in Nostr format, return as-is
if (authHeader.startsWith('Nostr ')) {
return authHeader;
}
// If it's Basic auth, try to extract the NIP-98 event
if (authHeader.startsWith('Basic ')) {
try {
const base64Credentials = authHeader.slice(6); // Remove "Basic " prefix
const credentials = Buffer.from(base64Credentials, 'base64').toString('utf-8');
const [username, ...passwordParts] = credentials.split(':');
const password = passwordParts.join(':'); // Rejoin in case password contains colons
// If username is "nostr", the password is the base64-encoded NIP-98 event
if (username === 'nostr' && password) {
// Trim whitespace and control characters that might be added during encoding
const trimmedPassword = password.trim().replace(/[\r\n\t\0]/g, '');
// Validate the password is valid base64-encoded JSON before using it
try {
const testDecode = Buffer.from(trimmedPassword, 'base64').toString('utf-8');
JSON.parse(testDecode); // Verify it's valid JSON
return `Nostr ${trimmedPassword}`;
} catch (err) {
logger.warn({ error: err instanceof Error ? err.message : String(err) },
'Invalid base64-encoded NIP-98 event in Basic auth password');
return authHeader; // Return original header if invalid
}
}
} catch (err) {
// If decoding fails, return original header
logger.debug({ error: err }, 'Failed to decode Basic auth header');
}
}
// Return original header if we can't convert it
return authHeader;
}
export const GET: RequestHandler = async ({ params, url, request }) => {
const path = params.path || '';
@ -181,7 +233,34 @@ export const GET: RequestHandler = async ({ params, url, request }) => { @@ -181,7 +233,34 @@ export const GET: RequestHandler = async ({ params, url, request }) => {
return error(403, 'Invalid repository path');
}
if (!repoManager.repoExists(repoPath)) {
return error(404, 'Repository not found');
logger.warn({ repoPath, resolvedPath, repoRoot, resolvedRoot }, 'Repository not found at expected path');
return error(404, `Repository not found at ${resolvedPath}. Please check GIT_REPO_ROOT environment variable (currently: ${repoRoot})`);
}
// Verify it's a valid git repository
const gitDir = join(resolvedPath, 'objects');
if (!existsSync(gitDir)) {
logger.warn({ repoPath: resolvedPath }, 'Repository path exists but is not a valid git repository');
return error(500, `Repository at ${resolvedPath} is not a valid git repository`);
}
// Ensure http.receivepack is enabled for push operations
// This is required for git-http-backend to allow receive-pack service
// Even with GIT_HTTP_EXPORT_ALL=1, the repository config must allow it
if (service === 'git-receive-pack') {
try {
const { execSync } = await import('child_process');
// Set http.receivepack to true if not already set
execSync('git config http.receivepack true', {
cwd: resolvedPath,
stdio: 'ignore',
timeout: 5000
});
logger.debug({ repoPath: resolvedPath }, 'Enabled http.receivepack for repository');
} catch (err) {
// Log but don't fail - git-http-backend might still work
logger.debug({ error: err, repoPath: resolvedPath }, 'Failed to set http.receivepack (may already be set)');
}
}
// Check repository privacy for clone/fetch operations
@ -197,7 +276,9 @@ export const GET: RequestHandler = async ({ params, url, request }) => { @@ -197,7 +276,9 @@ export const GET: RequestHandler = async ({ params, url, request }) => {
const privacyInfo = await maintainerService.getPrivacyInfo(originalOwnerPubkey, repoName);
if (privacyInfo.isPrivate) {
// Private repos require authentication for clone/fetch
const authHeader = request.headers.get('Authorization');
const rawAuthHeader = request.headers.get('Authorization');
// Normalize auth header (convert Basic auth from git credential helper to Nostr format)
const authHeader = normalizeAuthHeader(rawAuthHeader);
if (!authHeader || !authHeader.startsWith('Nostr ')) {
return error(401, 'This repository is private. Authentication required.');
}
@ -241,12 +322,26 @@ export const GET: RequestHandler = async ({ params, url, request }) => { @@ -241,12 +322,26 @@ export const GET: RequestHandler = async ({ params, url, request }) => {
return error(500, 'git-http-backend not found. Please install git.');
}
// Build PATH_INFO
// Security: Since we're setting GIT_PROJECT_ROOT to the specific repo path,
// PATH_INFO should be relative to that repo (just the git operation path)
// For info/refs: /info/refs
// For other operations: /{git-path}
const pathInfo = gitPath ? `/${gitPath}` : `/info/refs`;
// Build PATH_INFO using repository-per-directory mode
// GIT_PROJECT_ROOT points to the parent directory containing repositories
// PATH_INFO includes the repository name: /repo.git/info/refs
const repoParentDir = resolve(join(repoRoot, npub));
const repoRelativePath = `${repoName}.git`;
const gitOperationPath = gitPath ? `/${gitPath}` : `/info/refs`;
const pathInfo = `/${repoRelativePath}${gitOperationPath}`;
// Debug logging for git operations
logger.debug({
npub,
repoName,
resolvedPath,
repoParentDir,
repoRelativePath,
pathInfo,
service,
gitHttpBackend,
method: request.method
}, 'Processing git HTTP request');
// Set up environment variables for git-http-backend
// Security: Whitelist only necessary environment variables
@ -257,7 +352,7 @@ export const GET: RequestHandler = async ({ params, url, request }) => { @@ -257,7 +352,7 @@ export const GET: RequestHandler = async ({ params, url, request }) => {
USER: process.env.USER || 'git',
LANG: process.env.LANG || 'C.UTF-8',
LC_ALL: process.env.LC_ALL || 'C.UTF-8',
GIT_PROJECT_ROOT: resolve(repoPath), // Use specific repo path, not repoRoot
GIT_PROJECT_ROOT: repoParentDir, // Parent directory containing repositories
GIT_HTTP_EXPORT_ALL: '1',
REQUEST_METHOD: request.method,
PATH_INFO: pathInfo,
@ -267,6 +362,14 @@ export const GET: RequestHandler = async ({ params, url, request }) => { @@ -267,6 +362,14 @@ export const GET: RequestHandler = async ({ params, url, request }) => {
HTTP_USER_AGENT: request.headers.get('User-Agent') || '',
};
// Debug: Log environment variables (sanitized)
logger.debug({
GIT_PROJECT_ROOT: repoParentDir,
PATH_INFO: pathInfo,
QUERY_STRING: url.searchParams.toString(),
REQUEST_METHOD: request.method
}, 'git-http-backend environment');
// Add TZ if set (for consistent timestamps)
if (process.env.TZ) {
envVars.TZ = process.env.TZ;
@ -349,30 +452,98 @@ export const GET: RequestHandler = async ({ params, url, request }) => { @@ -349,30 +452,98 @@ export const GET: RequestHandler = async ({ params, url, request }) => {
);
}
// Debug: Log git-http-backend output
let body = Buffer.concat(chunks);
logger.debug({
code,
bodyLength: body.length,
bodyPreview: body.slice(0, 200).toString('utf-8'),
errorOutput: errorOutput.slice(0, 500),
pathInfo,
service
}, 'git-http-backend response');
if (code !== 0 && chunks.length === 0) {
const sanitizedError = sanitizeError(errorOutput || 'Unknown error');
resolve(error(500, `git-http-backend error: ${sanitizedError}`));
return;
}
const body = Buffer.concat(chunks);
// Determine content type based on service
let contentType = 'application/x-git-upload-pack-result';
if (service === 'git-receive-pack' || gitPath === 'git-receive-pack') {
// For info/refs requests, git-http-backend includes HTTP headers in the body
// We need to strip them and only send the git protocol data
// The format is: HTTP headers + blank line (\r\n\r\n) + git protocol data
if (pathInfo.includes('info/refs')) {
const bodyStr = body.toString('binary');
const headerEnd = bodyStr.indexOf('\r\n\r\n');
if (headerEnd !== -1) {
// Extract only the git protocol data (after the blank line)
body = Buffer.from(bodyStr.slice(headerEnd + 4), 'binary');
logger.debug({
originalLength: Buffer.concat(chunks).length,
protocolDataLength: body.length,
headerEnd
}, 'Stripped HTTP headers from info/refs response');
}
}
// Determine content type based on request type
// For info/refs requests with service parameter, use the appropriate advertisement content type
let contentType = 'text/plain; charset=utf-8';
if (pathInfo.includes('info/refs')) {
if (service === 'git-receive-pack') {
// info/refs?service=git-receive-pack returns application/x-git-receive-pack-advertisement
contentType = 'application/x-git-receive-pack-advertisement';
} else if (service === 'git-upload-pack') {
// info/refs?service=git-upload-pack returns application/x-git-upload-pack-advertisement
contentType = 'application/x-git-upload-pack-advertisement';
} else {
// info/refs without service parameter is text/plain
contentType = 'text/plain; charset=utf-8';
}
} else if (service === 'git-receive-pack' || gitPath === 'git-receive-pack') {
// POST requests to git-receive-pack (push)
contentType = 'application/x-git-receive-pack-result';
} else if (service === 'git-upload-pack' || gitPath === 'git-upload-pack') {
// POST requests to git-upload-pack (fetch)
contentType = 'application/x-git-upload-pack-result';
} else if (pathInfo.includes('info/refs')) {
contentType = 'text/plain; charset=utf-8';
}
resolve(new Response(body, {
// Debug: Log response details
logger.debug({
status: code === 0 ? 200 : 500,
contentType,
bodyLength: body.length,
bodyHex: body.slice(0, 100).toString('hex'),
headers: {
'Content-Type': contentType,
'Content-Length': body.length.toString(),
}
}, 'Sending git HTTP response');
// Build response headers
// Git expects specific headers for info/refs responses
const headers: HeadersInit = {
'Content-Type': contentType,
'Content-Length': body.length.toString(),
};
// For info/refs with service parameter, add Cache-Control header
if (pathInfo.includes('info/refs') && service) {
headers['Cache-Control'] = 'no-cache';
}
// Debug: Log response details
logger.debug({
status: code === 0 ? 200 : 500,
contentType,
bodyLength: body.length,
bodyPreview: body.slice(0, 200).toString('utf-8'),
headers
}, 'Sending git HTTP response');
resolve(new Response(body, {
status: code === 0 ? 200 : 500,
headers
}));
});
@ -423,7 +594,34 @@ export const POST: RequestHandler = async ({ params, url, request }) => { @@ -423,7 +594,34 @@ export const POST: RequestHandler = async ({ params, url, request }) => {
return error(403, 'Invalid repository path');
}
if (!repoManager.repoExists(repoPath)) {
return error(404, 'Repository not found');
logger.warn({ repoPath, resolvedPath, repoRoot, resolvedRoot }, 'Repository not found at expected path');
return error(404, `Repository not found at ${resolvedPath}. Please check GIT_REPO_ROOT environment variable (currently: ${repoRoot})`);
}
// Verify it's a valid git repository
const gitDir = join(resolvedPath, 'objects');
if (!existsSync(gitDir)) {
logger.warn({ repoPath: resolvedPath }, 'Repository path exists but is not a valid git repository');
return error(500, `Repository at ${resolvedPath} is not a valid git repository`);
}
// Ensure http.receivepack is enabled for push operations
// This is required for git-http-backend to allow receive-pack service
// Even with GIT_HTTP_EXPORT_ALL=1, the repository config must allow it
if (gitPath === 'git-receive-pack' || path.includes('git-receive-pack')) {
try {
const { execSync } = await import('child_process');
// Set http.receivepack to true if not already set
execSync('git config http.receivepack true', {
cwd: resolvedPath,
stdio: 'ignore',
timeout: 5000
});
logger.debug({ repoPath: resolvedPath }, 'Enabled http.receivepack for repository');
} catch (err) {
// Log but don't fail - git-http-backend might still work
logger.debug({ error: err, repoPath: resolvedPath }, 'Failed to set http.receivepack (may already be set)');
}
}
// Get current owner (may be different if ownership was transferred)
@ -440,18 +638,59 @@ export const POST: RequestHandler = async ({ params, url, request }) => { @@ -440,18 +638,59 @@ export const POST: RequestHandler = async ({ params, url, request }) => {
// For push operations (git-receive-pack), require NIP-98 authentication
if (gitPath === 'git-receive-pack' || path.includes('git-receive-pack')) {
const rawAuthHeader = request.headers.get('Authorization');
// Always return 401 with WWW-Authenticate if no Authorization header
// This ensures git calls the credential helper proactively
// Git requires WWW-Authenticate header on ALL 401 responses, otherwise it won't retry
if (!rawAuthHeader) {
return new Response('Authentication required. Please configure the git credential helper. See docs/GIT_CREDENTIAL_HELPER.md for setup instructions.', {
status: 401,
headers: {
'WWW-Authenticate': 'Basic realm="GitRepublic"',
'Content-Type': 'text/plain'
}
});
}
// Normalize auth header (convert Basic auth from git credential helper to Nostr format)
const authHeader = normalizeAuthHeader(rawAuthHeader);
// Verify NIP-98 authentication
const authResult = verifyNIP98Auth(
request.headers.get('Authorization'),
authHeader,
requestUrl,
request.method,
bodyBuffer.length > 0 ? bodyBuffer : undefined
);
if (!authResult.valid) {
return error(401, authResult.error || 'Authentication required');
logger.warn({
error: authResult.error,
requestUrl,
requestMethod: request.method
}, 'NIP-98 authentication failed for push');
// Always return 401 with WWW-Authenticate header, even if Authorization was present
// This ensures git retries with the credential helper
// Git requires WWW-Authenticate on ALL 401 responses, otherwise it won't retry
const errorMessage = authResult.error || 'Authentication required';
return new Response(errorMessage, {
status: 401,
headers: {
'WWW-Authenticate': 'Basic realm="GitRepublic"',
'Content-Type': 'text/plain'
}
});
}
logger.debug({
pubkey: authResult.pubkey,
requestUrl,
requestMethod: request.method
}, 'NIP-98 authentication successful for push');
// Verify pubkey is current repo owner or maintainer
const isMaintainer = await maintainerService.isMaintainer(
authResult.pubkey || '',
@ -504,10 +743,11 @@ export const POST: RequestHandler = async ({ params, url, request }) => { @@ -504,10 +743,11 @@ export const POST: RequestHandler = async ({ params, url, request }) => {
return error(500, 'git-http-backend not found. Please install git.');
}
// Build PATH_INFO
// Security: Since we're setting GIT_PROJECT_ROOT to the specific repo path,
// PATH_INFO should be relative to that repo (just the git operation path)
const pathInfo = gitPath ? `/${gitPath}` : `/`;
// Build PATH_INFO using repository-per-directory mode (same as GET handler)
const repoParentDir = resolve(join(repoRoot, npub));
const repoRelativePath = `${repoName}.git`;
const gitOperationPath = gitPath ? `/${gitPath}` : `/`;
const pathInfo = `/${repoRelativePath}${gitOperationPath}`;
// Set up environment variables for git-http-backend
// Security: Whitelist only necessary environment variables
@ -518,7 +758,7 @@ export const POST: RequestHandler = async ({ params, url, request }) => { @@ -518,7 +758,7 @@ export const POST: RequestHandler = async ({ params, url, request }) => {
USER: process.env.USER || 'git',
LANG: process.env.LANG || 'C.UTF-8',
LC_ALL: process.env.LC_ALL || 'C.UTF-8',
GIT_PROJECT_ROOT: resolve(repoPath), // Use specific repo path, not repoRoot
GIT_PROJECT_ROOT: repoParentDir, // Parent directory containing repositories
GIT_HTTP_EXPORT_ALL: '1',
REQUEST_METHOD: request.method,
PATH_INFO: pathInfo,
@ -528,6 +768,21 @@ export const POST: RequestHandler = async ({ params, url, request }) => { @@ -528,6 +768,21 @@ export const POST: RequestHandler = async ({ params, url, request }) => {
HTTP_USER_AGENT: request.headers.get('User-Agent') || '',
};
// Pass Authorization header to git-http-backend (if present)
// Git-http-backend uses HTTP_AUTHORIZATION environment variable
const authHeader = request.headers.get('Authorization');
if (authHeader) {
envVars.HTTP_AUTHORIZATION = authHeader;
}
// Debug: Log environment variables (sanitized)
logger.debug({
GIT_PROJECT_ROOT: repoParentDir,
PATH_INFO: pathInfo,
QUERY_STRING: url.searchParams.toString(),
REQUEST_METHOD: request.method
}, 'git-http-backend environment (POST)');
// Add TZ if set (for consistent timestamps)
if (process.env.TZ) {
envVars.TZ = process.env.TZ;

Loading…
Cancel
Save