AWS CDK Link Shortener 3. Bölüm: Gelişmiş Özellikler ve Güvenlik
Custom domain'ler, toplu işlemler, URL expiration ve kapsamlı güvenlik önlemlerinin implementasyonu. Gerçek production güvenlik olayları ve savunma katmanları nasıl oluşturulur.
AWS CDK Link Shortener 3. Bölüm: Gelişmiş Özellikler ve Güvenlik#
Şunu hayal et: Black Friday'den bir hafta önce, pazarlama ekibimiz kampanyaları için 50,000 ürün linkini yükledi. Her şey harika görünüyordu ta ki Pazartesi sabahı güvenlik alarmlarımız çalana kadar. Birisi bulk upload API'mizi keşfetmiş ve trafiği... diyelim ki "uygunsuz içeriğe" yönlendirmek için kullanıyordu.
Bu olay bana production bir link shortener oluşturmanın sadece kısa URL'ler yaratmak olmadığını öğretti—meşru trafiği kaldırabilirken kötü aktörleri dışarıda tutan bir kale inşa etmekti. O karmaşayı temizledikten sonra (ve uyumluluk ekibimizle bir hayli rahatsız edici konuşmalardan sonra), servisimizi uygun güvenlik katmanlarıyla yeniden inşa ettik.
1. Bölüm ve 2. Bölüm'de temel yapı ve redirect fonksiyonalitesini oluşturduk. Şimdi bir oyuncak projeden production servise ayıran gelişmiş özellikleri ve güvenlik önlemlerini ekleyelim.
Custom Short Domain'ler: Vanity URL'lerden Fazlası#
Güvenliğe dalmadan önce, custom domain'leri ele alalım. Pazarlama ekibin er ya da geç yourdomain.com/abc123
yerine acme.co/promo
gibi markalı kısa URL'ler isteyecek. İşte nasıl çalışır hale getireceğiz:
// lib/custom-domain-stack.ts
import { Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as route53 from 'aws-cdk-lib/aws-route53';
import * as acm from 'aws-cdk-lib/aws-certificatemanager';
import * as apigateway from 'aws-cdk-lib/aws-apigateway';
import * as targets from 'aws-cdk-lib/aws-route53-targets';
export class CustomDomainStack extends Stack {
public readonly customDomainName: apigateway.DomainName;
constructor(scope: Construct, id: string, props: StackProps & {
domainName: string;
hostedZoneId: string;
certificateArn: string; // Pre-created ACM certificate
restApi: apigateway.RestApi;
}) {
super(scope, id, props);
// Import existing hosted zone
const hostedZone = route53.HostedZone.fromHostedZoneAttributes(this, 'HostedZone', {
hostedZoneId: props.hostedZoneId,
zoneName: props.domainName,
});
// Import existing certificate (must be in us-east-1 for API Gateway)
const certificate = acm.Certificate.fromCertificateArn(
this,
'Certificate',
props.certificateArn
);
// Create custom domain name for API Gateway
this.customDomainName = new apigateway.DomainName(this, 'CustomDomain', {
domainName: props.domainName,
certificate: certificate,
securityPolicy: apigateway.SecurityPolicy.TLS_1_2,
endpointType: apigateway.EndpointType.EDGE,
});
// Map the custom domain to our API
this.customDomainName.addBasePathMapping(props.restApi, {
basePath: '', // Root path
});
// Create Route53 alias record
new route53.ARecord(this, 'CustomDomainAlias', {
zone: hostedZone,
target: route53.RecordTarget.fromAlias(
new targets.ApiGatewayDomain(this.customDomainName)
),
});
}
}
Sahadan ipucu: API Gateway edge-optimized endpoint'ler için ACM sertifikanı diğer kaynaklarınızın nerede olduğuna bakılmaksızın her zaman us-east-1
'de oluştur. Sertifikamın neden "mevcut değil" olduğunu debug ederken iki saatimi harcadım bu gereksinimi fark etmeden önce.
Bulk İşlemler: Scale'i Zarif Bir Şekilde Karşılamak#
Pazarlama ekipleri bulk işlemleri seviyor. İşte Lambda concurrency limitlerini patlatmayacak production-test edilmiş bir implementasyon:
// lambda/bulk-create.ts
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { SQSClient, SendMessageCommand } from '@aws-sdk/client-sqs';
import { nanoid } from 'nanoid';
const dynamodb = new DynamoDBClient({});
const sqs = new SQSClient({});
interface BulkCreateRequest {
urls: Array<{
originalUrl: string;
customSlug?: string;
expiresAt?: string;
tags?: string[];
}>;
userId: string;
}
export async function handler(
event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> {
try {
const request: BulkCreateRequest = JSON.parse(event.body || '{}');
// Validate batch size (learned this the hard way)
if (request.urls.length > 1000) {
return {
statusCode: 400,
body: JSON.stringify({
error: 'Batch boyutu 1000 URL\'yi geçemez'
}),
};
}
// For large batches, use SQS for async processing
if (request.urls.length > 100) {
const jobId = nanoid();
await sqs.send(new SendMessageCommand({
QueueUrl: process.env.BULK_PROCESSING_QUEUE_URL,
MessageBody: JSON.stringify({
jobId,
userId: request.userId,
urls: request.urls,
}),
MessageAttributes: {
jobType: {
DataType: 'String',
StringValue: 'BULK_CREATE'
}
}
}));
return {
statusCode: 202,
body: JSON.stringify({
jobId,
message: 'Bulk oluşturma job\'u kuyruğa eklendi',
estimatedCompletionTime: Math.ceil(request.urls.length / 10) + ' dakika'
}),
};
}
// Process small batches synchronously
const results = await Promise.allSettled(
request.urls.map(async (urlData) => {
const shortCode = urlData.customSlug || nanoid(8);
// Validate URL before creating
if (!isValidUrl(urlData.originalUrl)) {
throw new Error(`Geçersiz URL: ${urlData.originalUrl}`);
}
// Check for malicious content (more on this later)
await validateUrlSafety(urlData.originalUrl);
return await createShortUrl({
shortCode,
originalUrl: urlData.originalUrl,
userId: request.userId,
expiresAt: urlData.expiresAt,
tags: urlData.tags || [],
});
})
);
const successful = results
.filter(result => result.status === 'fulfilled')
.map(result => (result as PromiseFulfilledResult<any>).value);
const failed = results
.filter(result => result.status === 'rejected')
.map(result => (result as PromiseRejectedResult).reason.message);
return {
statusCode: 200,
body: JSON.stringify({
successful: successful.length,
failed: failed.length,
errors: failed,
urls: successful,
}),
};
} catch (error) {
console.error('Bulk create error:', error);
return {
statusCode: 500,
body: JSON.stringify({ error: 'Internal server error' }),
};
}
}
function isValidUrl(url: string): boolean {
try {
const parsedUrl = new URL(url);
return ['http:', 'https:'].includes(parsedUrl.protocol);
} catch {
return false;
}
}
async function validateUrlSafety(url: string): Promise<void> {
// Implementation coming up in security section
// This is where we check against malicious domains
}
URL Expiration ve Scheduling: Zamana Dayalı Özellikler#
Pazarlama kampanyalarının expiration tarihleri olması gerek. İşte pahalı cleanup job'ları çalıştırmadan URL expiration'ın nasıl implement edileceği:
// lambda/redirect-with-expiration.ts
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { DynamoDBClient, GetItemCommand, UpdateItemCommand } from '@aws-sdk/client-dynamodb';
import { marshall, unmarshall } from '@aws-sdk/util-dynamodb';
export async function handler(
event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> {
const shortCode = event.pathParameters?.shortCode;
if (!shortCode) {
return {
statusCode: 404,
body: JSON.stringify({ error: 'Kısa kod bulunamadı' }),
};
}
try {
const response = await dynamodb.send(new GetItemCommand({
TableName: process.env.URLS_TABLE_NAME,
Key: marshall({ shortCode }),
}));
if (!response.Item) {
return {
statusCode: 404,
headers: {
'Content-Type': 'text/html',
},
body: createNotFoundPage(),
};
}
const item = unmarshall(response.Item);
// Check expiration
if (item.expiresAt && new Date(item.expiresAt) < new Date()) {
// URL expired - optionally log this for analytics
await recordExpiredAccess(shortCode, item.userId);
return {
statusCode: 410, // Gone
headers: {
'Content-Type': 'text/html',
},
body: createExpiredPage(item.originalUrl),
};
}
// Check if URL is scheduled for future activation
if (item.activateAt && new Date(item.activateAt) > new Date()) {
return {
statusCode: 404, // Not yet active
headers: {
'Content-Type': 'text/html',
},
body: createNotYetActivePage(),
};
}
// Update click count asynchronously (fire and forget)
updateClickCount(shortCode, event).catch(console.error);
return {
statusCode: 302,
headers: {
Location: item.originalUrl,
'Cache-Control': 'no-cache', // Important for expired URLs
},
body: '',
};
} catch (error) {
console.error('Redirect error:', error);
return {
statusCode: 500,
headers: {
'Content-Type': 'text/html',
},
body: createErrorPage(),
};
}
}
async function recordExpiredAccess(shortCode: string, userId: string): Promise<void> {
// Record that someone tried to access an expired URL
// Useful for analytics and potential abuse detection
try {
await dynamodb.send(new UpdateItemCommand({
TableName: process.env.ANALYTICS_TABLE_NAME,
Key: marshall({
pk: `USER#${userId}`,
sk: `EXPIRED#${shortCode}#${Date.now()}`,
}),
UpdateExpression: 'SET #count = if_not_exists(#count, :zero) + :inc',
ExpressionAttributeNames: {
'#count': 'expiredAccessCount',
},
ExpressionAttributeValues: marshall({
':zero': 0,
':inc': 1,
}),
}));
} catch (error) {
console.error('Failed to record expired access:', error);
}
}
function createExpiredPage(originalUrl: string): string {
return `
<!DOCTYPE html>
<html>
<head>
<title>Link Süresi Dolmuş</title>
<style>
body { font-family: Arial, sans-serif; text-align: center; margin-top: 100px; }
.container { max-width: 500px; margin: 0 auto; }
</style>
</head>
<body>
<div class="container">
<h1>Link Süresi Dolmuş</h1>
<p>Bu linkin süresi dolmuş ve artık kullanılamıyor.</p>
<p>Orijinal hedef: <code>${originalUrl}</code></p>
<a href="/">Ana sayfaya git</a>
</div>
</body>
</html>
`;
}
Güvenlik: Defense in Depth#
Şimdi bu yazının en önemli kısmı. Güvenlik sonradan düşünülecek bir şey değil—servisinizin malware dağıtım platformu haline gelmesini engelleyen şey. İşte katmanlı güvenlik yaklaşımımız:
Katman 1: Input Validation ve URL Güvenliği#
// lambda/url-validator.ts
import { DynamoDBClient, GetItemCommand } from '@aws-sdk/client-dynamodb';
import { marshall } from '@aws-sdk/util-dynamodb';
const dynamodb = new DynamoDBClient({});
// Malicious domain blacklist (this should be regularly updated)
const MALICIOUS_DOMAINS = new Set([
// Add known malicious domains here
// In production, load this from DynamoDB or Parameter Store
]);
// URL patterns that are commonly abused
const SUSPICIOUS_PATTERNS = [
/bit\.ly/i, // Nested shorteners
/tinyurl\.com/i, // Nested shorteners
/localhost/i, // Local development
/192\.168\./i, // Private networks
/127\.0\.0\.1/i, // Localhost
/10\./i, // Private networks
/172\.16\./i, // Private networks
];
export async function validateUrlSafety(url: string): Promise<{
isValid: boolean;
reason?: string;
}> {
try {
const parsedUrl = new URL(url);
// Check protocol
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
return {
isValid: false,
reason: 'Sadece HTTP ve HTTPS URL\'lerine izin veriliyor'
};
}
// Check for private/local addresses
if (SUSPICIOUS_PATTERNS.some(pattern => pattern.test(url))) {
return {
isValid: false,
reason: 'URL şüpheli pattern\'ler içeriyor'
};
}
// Check against malicious domain blacklist
if (MALICIOUS_DOMAINS.has(parsedUrl.hostname.toLowerCase())) {
return {
isValid: false,
reason: 'Domain blacklist\'te'
};
}
// Check against dynamic blacklist in DynamoDB
const blacklistCheck = await dynamodb.send(new GetItemCommand({
TableName: process.env.BLACKLIST_TABLE_NAME,
Key: marshall({
domain: parsedUrl.hostname.toLowerCase()
}),
}));
if (blacklistCheck.Item) {
return {
isValid: false,
reason: 'Domain blacklist\'te'
};
}
// Optional: Check against external reputation services
const reputationCheck = await checkUrlReputation(url);
if (!reputationCheck.isValid) {
return reputationCheck;
}
return { isValid: true };
} catch (error) {
return {
isValid: false,
reason: 'Geçersiz URL formatı'
};
}
}
async function checkUrlReputation(url: string): Promise<{
isValid: boolean;
reason?: string;
}> {
// In production, integrate with services like:
// - Google Safe Browsing API
// - VirusTotal API
// - URLVoid API
// For now, return valid
return { isValid: true };
}
Katman 2: Kimlik Doğrulama ve Yetkilendirme#
// lambda/authorizer.ts
import { APIGatewayTokenAuthorizerEvent, APIGatewayAuthorizerResult } from 'aws-lambda';
import { verify } from 'jsonwebtoken';
import { DynamoDBClient, GetItemCommand } from '@aws-sdk/client-dynamodb';
import { marshall, unmarshall } from '@aws-sdk/util-dynamodb';
const dynamodb = new DynamoDBClient({});
interface JWTPayload {
sub: string;
email: string;
role: string;
exp: number;
}
export async function handler(
event: APIGatewayTokenAuthorizerEvent
): Promise<APIGatewayAuthorizerResult> {
try {
const token = event.authorizationToken?.replace('Bearer ', '');
if (!token) {
throw new Error('Token bulunamadı');
}
// Verify JWT token
const decoded = verify(token, process.env.JWT_SECRET!) as JWTPayload;
// Get user details from DynamoDB
const userResponse = await dynamodb.send(new GetItemCommand({
TableName: process.env.USERS_TABLE_NAME,
Key: marshall({ userId: decoded.sub }),
}));
if (!userResponse.Item) {
throw new Error('Kullanıcı bulunamadı');
}
const user = unmarshall(userResponse.Item);
// Check if user is active
if (user.status !== 'ACTIVE') {
throw new Error('Kullanıcı aktif değil');
}
// Generate policy based on user role
const policy = generatePolicy(decoded.sub, 'Allow', event.methodArn, user.role);
// Add user context to be available in Lambda functions
policy.context = {
userId: decoded.sub,
email: decoded.email,
role: user.role,
planType: user.planType || 'free',
};
return policy;
} catch (error) {
console.error('Authorization failed:', error);
throw new Error('Unauthorized');
}
}
function generatePolicy(
principalId: string,
effect: 'Allow' | 'Deny',
resource: string,
role: string
): APIGatewayAuthorizerResult {
const policyDocument = {
Version: '2012-10-17',
Statement: [
{
Action: 'execute-api:Invoke',
Effect: effect,
Resource: resource,
},
],
};
// Role-based permissions
if (role === 'admin') {
// Admins can access all endpoints
policyDocument.Statement[0].Resource = '*';
} else if (role === 'premium') {
// Premium users get access to advanced features
policyDocument.Statement.push({
Action: 'execute-api:Invoke',
Effect: 'Allow',
Resource: resource.replace('/create', '/bulk-create'),
});
}
return {
principalId,
policyDocument,
};
}
Katman 3: Rate Limiting ve Kötüye Kullanım Korunması#
// lambda/rate-limiter.ts
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { DynamoDBClient, UpdateItemCommand, GetItemCommand } from '@aws-sdk/client-dynamodb';
import { marshall, unmarshall } from '@aws-sdk/util-dynamodb';
const dynamodb = new DynamoDBClient({});
interface RateLimitConfig {
requestsPerMinute: number;
requestsPerHour: number;
requestsPerDay: number;
}
const RATE_LIMITS: Record<string, RateLimitConfig> = {
free: {
requestsPerMinute: 10,
requestsPerHour: 100,
requestsPerDay: 1000,
},
premium: {
requestsPerMinute: 100,
requestsPerHour: 1000,
requestsPerDay: 10000,
},
admin: {
requestsPerMinute: 1000,
requestsPerHour: 10000,
requestsPerDay: 100000,
},
};
export async function checkRateLimit(
userId: string,
planType: string = 'free',
clientIp?: string
): Promise<{
allowed: boolean;
resetTime?: number;
remainingRequests?: number;
}> {
const config = RATE_LIMITS[planType] || RATE_LIMITS.free;
const now = Date.now();
// Create time windows
const minuteWindow = Math.floor(now / (60 * 1000));
const hourWindow = Math.floor(now / (60 * 60 * 1000));
const dayWindow = Math.floor(now / (24 * 60 * 60 * 1000));
try {
// Check and update rate limits atomically
const updateResult = await dynamodb.send(new UpdateItemCommand({
TableName: process.env.RATE_LIMIT_TABLE_NAME,
Key: marshall({
userId,
window: 'COMBINED'
}),
UpdateExpression: `
SET
#minute = if_not_exists(#minute, :zero),
#hour = if_not_exists(#hour, :zero),
#day = if_not_exists(#day, :zero),
#minuteWindow = if_not_exists(#minuteWindow, :currentMinute),
#hourWindow = if_not_exists(#hourWindow, :currentHour),
#dayWindow = if_not_exists(#dayWindow, :currentDay)
ADD
#minute :inc,
#hour :inc,
#day :inc
`,
ConditionExpression: `
(attribute_not_exists(#minuteWindow) OR #minuteWindow = :currentMinute OR #minute < :minuteLimit) AND
(attribute_not_exists(#hourWindow) OR #hourWindow = :currentHour OR #hour < :hourLimit) AND
(attribute_not_exists(#dayWindow) OR #dayWindow = :currentDay OR #day < :dayLimit)
`,
ExpressionAttributeNames: {
'#minute': 'requestsThisMinute',
'#hour': 'requestsThisHour',
'#day': 'requestsThisDay',
'#minuteWindow': 'minuteWindow',
'#hourWindow': 'hourWindow',
'#dayWindow': 'dayWindow',
},
ExpressionAttributeValues: marshall({
':zero': 0,
':inc': 1,
':currentMinute': minuteWindow,
':currentHour': hourWindow,
':currentDay': dayWindow,
':minuteLimit': config.requestsPerMinute,
':hourLimit': config.requestsPerHour,
':dayLimit': config.requestsPerDay,
}),
ReturnValues: 'ALL_NEW',
}));
const item = unmarshall(updateResult.Attributes!);
return {
allowed: true,
remainingRequests: Math.min(
config.requestsPerMinute - item.requestsThisMinute,
config.requestsPerHour - item.requestsThisHour,
config.requestsPerDay - item.requestsThisDay
),
};
} catch (error: any) {
if (error.name === 'ConditionalCheckFailedException') {
// Rate limit exceeded
const getResult = await dynamodb.send(new GetItemCommand({
TableName: process.env.RATE_LIMIT_TABLE_NAME,
Key: marshall({ userId, window: 'COMBINED' }),
}));
if (getResult.Item) {
const item = unmarshall(getResult.Item);
// Calculate reset time based on which limit was hit
let resetTime = now + (60 * 1000); // Default to 1 minute
if (item.requestsThisDay >= config.requestsPerDay) {
resetTime = (dayWindow + 1) * 24 * 60 * 60 * 1000;
} else if (item.requestsThisHour >= config.requestsPerHour) {
resetTime = (hourWindow + 1) * 60 * 60 * 1000;
}
return {
allowed: false,
resetTime,
remainingRequests: 0,
};
}
}
throw error;
}
}
export function createRateLimitResponse(resetTime: number): APIGatewayProxyResult {
return {
statusCode: 429,
headers: {
'X-RateLimit-Reset': Math.ceil(resetTime / 1000).toString(),
'Retry-After': Math.ceil((resetTime - Date.now()) / 1000).toString(),
},
body: JSON.stringify({
error: 'Rate limit aşıldı',
message: 'Çok fazla request. Lütfen daha sonra tekrar deneyin.',
resetTime: new Date(resetTime).toISOString(),
}),
};
}
Katman 4: AWS WAF Korunması#
// lib/waf-stack.ts
import { Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as wafv2 from 'aws-cdk-lib/aws-wafv2';
import * as logs from 'aws-cdk-lib/aws-logs';
export class WAFStack extends Stack {
public readonly webAcl: wafv2.CfnWebACL;
constructor(scope: Construct, id: string, props: StackProps) {
super(scope, id, props);
// Create CloudWatch log group for WAF logs
const logGroup = new logs.LogGroup(this, 'WAFLogGroup', {
logGroupName: `/aws/wafv2/link-shortener`,
retention: logs.RetentionDays.ONE_MONTH,
});
this.webAcl = new wafv2.CfnWebACL(this, 'LinkShortenerWAF', {
scope: 'CLOUDFRONT', // Use REGIONAL for ALB/API Gateway
defaultAction: { allow: {} },
rules: [
// Rule 1: AWS Managed Rules - Core Rule Set
{
name: 'AWSManagedRulesCore',
priority: 1,
statement: {
managedRuleGroupStatement: {
vendorName: 'AWS',
name: 'AWSManagedRulesCommonRuleSet',
},
},
overrideAction: { none: {} },
visibilityConfig: {
sampledRequestsEnabled: true,
cloudWatchMetricsEnabled: true,
metricName: 'AWSManagedRulesCore',
},
},
// Rule 2: Rate limiting for URL creation
{
name: 'RateLimitCreation',
priority: 2,
statement: {
rateBasedStatement: {
limit: 1000, // requests per 5-minute window
aggregateKeyType: 'IP',
scopeDownStatement: {
byteMatchStatement: {
searchString: '/create',
fieldToMatch: { uriPath: {} },
textTransformations: [
{ priority: 0, type: 'LOWERCASE' },
],
positionalConstraint: 'CONTAINS',
},
},
},
},
action: {
block: {
customResponse: {
responseCode: 429,
customResponseBodyKey: 'RateLimitExceeded',
},
},
},
visibilityConfig: {
sampledRequestsEnabled: true,
cloudWatchMetricsEnabled: true,
metricName: 'RateLimitCreation',
},
},
// Rule 3: Block known bot networks
{
name: 'AWSManagedRulesBot',
priority: 3,
statement: {
managedRuleGroupStatement: {
vendorName: 'AWS',
name: 'AWSManagedRulesBotControlRuleSet',
},
},
overrideAction: { none: {} },
visibilityConfig: {
sampledRequestsEnabled: true,
cloudWatchMetricsEnabled: true,
metricName: 'AWSManagedRulesBot',
},
},
// Rule 4: IP reputation list
{
name: 'AWSManagedRulesIPReputation',
priority: 4,
statement: {
managedRuleGroupStatement: {
vendorName: 'AWS',
name: 'AWSManagedRulesAmazonIpReputationList',
},
},
overrideAction: { none: {} },
visibilityConfig: {
sampledRequestsEnabled: true,
cloudWatchMetricsEnabled: true,
metricName: 'AWSManagedRulesIPReputation',
},
},
// Rule 5: Custom geo-blocking (if needed)
{
name: 'GeoBlocking',
priority: 5,
statement: {
geoMatchStatement: {
// Block requests from specific countries if needed
countryCodes: [], // Add country codes to block
},
},
action: { block: {} },
visibilityConfig: {
sampledRequestsEnabled: true,
cloudWatchMetricsEnabled: true,
metricName: 'GeoBlocking',
},
},
],
customResponseBodies: {
RateLimitExceeded: {
contentType: 'APPLICATION_JSON',
content: JSON.stringify({
error: 'Rate limit aşıldı',
message: 'IP adresinden çok fazla request. Lütfen daha sonra tekrar deneyin.',
}),
},
},
visibilityConfig: {
sampledRequestsEnabled: true,
cloudWatchMetricsEnabled: true,
metricName: 'LinkShortenerWAF',
},
});
// Enable logging
new wafv2.CfnLoggingConfiguration(this, 'WAFLogging', {
resourceArn: this.webAcl.attrArn,
logDestinationConfigs: [logGroup.logGroupArn],
loggingFilter: {
defaultBehavior: 'KEEP',
filters: [
{
behavior: 'DROP',
conditions: [
{
actionCondition: {
action: 'ALLOW',
},
},
],
requirement: 'MEETS_ANY',
},
],
},
});
}
}
Gelişmiş Analytics ve Monitoring#
Güvenlik sadece kötü aktörleri engellemek değil—sisteminde neler olduğunu anlamakla ilgili:
// lambda/security-monitor.ts
import { CloudWatchClient, PutMetricDataCommand } from '@aws-sdk/client-cloudwatch';
import { SNSClient, PublishCommand } from '@aws-sdk/client-sns';
const cloudwatch = new CloudWatchClient({});
const sns = new SNSClient({});
export interface SecurityEvent {
type: 'RATE_LIMIT_EXCEEDED' | 'MALICIOUS_URL_BLOCKED' | 'SUSPICIOUS_BULK_REQUEST';
userId?: string;
clientIp: string;
userAgent?: string;
details: Record<string, any>;
timestamp: number;
}
export async function recordSecurityEvent(event: SecurityEvent): Promise<void> {
try {
// Send metric to CloudWatch
await cloudwatch.send(new PutMetricDataCommand({
Namespace: 'LinkShortener/Security',
MetricData: [
{
MetricName: event.type,
Value: 1,
Unit: 'Count',
Timestamp: new Date(event.timestamp),
Dimensions: [
{
Name: 'EventType',
Value: event.type,
},
...(event.userId ? [{
Name: 'UserId',
Value: event.userId,
}] : []),
],
},
],
}));
// For critical events, send SNS alert
if (shouldAlertOn(event)) {
await sns.send(new PublishCommand({
TopicArn: process.env.SECURITY_ALERTS_TOPIC_ARN,
Subject: `Güvenlik Alarmı: ${event.type}`,
Message: JSON.stringify({
eventType: event.type,
timestamp: new Date(event.timestamp).toISOString(),
clientIp: event.clientIp,
userId: event.userId,
details: event.details,
}, null, 2),
}));
}
console.log('Security event recorded:', {
type: event.type,
userId: event.userId,
clientIp: event.clientIp,
timestamp: event.timestamp,
});
} catch (error) {
console.error('Failed to record security event:', error);
// Don't throw - security monitoring failures shouldn't break the main flow
}
}
function shouldAlertOn(event: SecurityEvent): boolean {
// Define which events should trigger immediate alerts
const alertEvents: SecurityEvent['type'][] = [
'MALICIOUS_URL_BLOCKED',
'SUSPICIOUS_BULK_REQUEST',
];
return alertEvents.includes(event.type);
}
// Create a dashboard for security metrics
export async function createSecurityDashboard(): Promise<void> {
// This would be part of your CDK infrastructure code
// Implementation depends on your specific monitoring needs
}
Hepsini Bir Araya Getirmek: Güvenlik-First API#
İşte tüm bu güvenlik katmanlarının production endpoint'te nasıl bir araya geldiği:
// lambda/secure-create-url.ts
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { validateUrlSafety } from './url-validator';
import { checkRateLimit, createRateLimitResponse } from './rate-limiter';
import { recordSecurityEvent } from './security-monitor';
import { nanoid } from 'nanoid';
export async function handler(
event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> {
const startTime = Date.now();
try {
// Extract user context from authorizer
const userId = event.requestContext.authorizer?.userId;
const planType = event.requestContext.authorizer?.planType || 'free';
const clientIp = event.requestContext.identity?.sourceIp;
if (!userId) {
return {
statusCode: 401,
body: JSON.stringify({ error: 'Kimlik doğrulama gerekli' }),
};
}
// Check rate limits first (fail fast)
const rateLimitCheck = await checkRateLimit(userId, planType, clientIp);
if (!rateLimitCheck.allowed) {
await recordSecurityEvent({
type: 'RATE_LIMIT_EXCEEDED',
userId,
clientIp: clientIp!,
userAgent: event.headers['User-Agent'],
details: { planType, resetTime: rateLimitCheck.resetTime },
timestamp: Date.now(),
});
return createRateLimitResponse(rateLimitCheck.resetTime!);
}
// Parse and validate request
const request = JSON.parse(event.body || '{}');
if (!request.originalUrl) {
return {
statusCode: 400,
body: JSON.stringify({
error: 'originalUrl gerekli',
remainingRequests: rateLimitCheck.remainingRequests
}),
};
}
// Validate URL safety
const safetyCheck = await validateUrlSafety(request.originalUrl);
if (!safetyCheck.isValid) {
await recordSecurityEvent({
type: 'MALICIOUS_URL_BLOCKED',
userId,
clientIp: clientIp!,
userAgent: event.headers['User-Agent'],
details: {
originalUrl: request.originalUrl,
reason: safetyCheck.reason
},
timestamp: Date.now(),
});
return {
statusCode: 400,
body: JSON.stringify({
error: 'URL doğrulama başarısız',
reason: safetyCheck.reason,
remainingRequests: rateLimitCheck.remainingRequests
}),
};
}
// Create short URL
const shortCode = request.customSlug || nanoid(8);
// TODO: Save to DynamoDB (implementation from previous parts)
const shortUrl = await createShortUrl({
shortCode,
originalUrl: request.originalUrl,
userId,
expiresAt: request.expiresAt,
tags: request.tags || [],
});
const responseTime = Date.now() - startTime;
// Record successful creation
console.log(`URL başarıyla oluşturuldu: ${shortCode} -> ${request.originalUrl} (${responseTime}ms)`);
return {
statusCode: 201,
headers: {
'X-RateLimit-Remaining': rateLimitCheck.remainingRequests?.toString() || '0',
'X-Response-Time': responseTime.toString(),
},
body: JSON.stringify({
shortCode,
shortUrl: `${process.env.DOMAIN_NAME}/${shortCode}`,
originalUrl: request.originalUrl,
createdAt: new Date().toISOString(),
expiresAt: request.expiresAt,
remainingRequests: rateLimitCheck.remainingRequests,
}),
};
} catch (error) {
console.error('Error creating short URL:', error);
const responseTime = Date.now() - startTime;
return {
statusCode: 500,
headers: {
'X-Response-Time': responseTime.toString(),
},
body: JSON.stringify({
error: 'Internal server error',
requestId: event.requestContext.requestId
}),
};
}
}
Öğrenilen Dersler: Keşke Daha Önce Bilseydiklerim#
Bu servisi iki yıl production'da çalıştırdıktan sonra, işte zor yoldan öğrenilen dersler:
1. Performance değil güvenlikle başla Başlangıçta redirect'leri hızlı yapmaya odaklandık, sonra güvenliği sonradan ekledik. Büyük hataydı. Güvenlik-first düşünceyle yeniden inşa etmek bize haftalar refactoring ve birkaç utanç verici olaydan kurtarmış olurdu.
2. Rate limiting göründüğünden daha zor Basit token bucket algoritmaları serverless'la iyi çalışmıyor çünkü invocation'lar arasında state kaybediyorsun. DynamoDB atomic counter'ları time window'larla daha iyi çalışıyor, ama write capacity unit'lere dikkat et.
3. URL validation hiç tam olmuyor Kötü niyetli URL tespitinin ne kadar kapsamlı olursa olsun, saldırganlar yeni domain'ler bulacak. İlk günden mükemmel olmaya çalışmak yerine hızlıca güncellenebilen bir sistem inşa et.
4. Her şeyi monitor et, pattern'ler üzerine alarm kur Tekil güvenlik olayları genellikle ilginç değil. Pattern'ler önemli olan. Trend'leri tespit etmek için monitoring'ini inşa et: aynı IP'nin çok URL yaratması, olağandışı redirect pattern'leri, yeni hesaplardan bulk işlemler.
5. Custom domain'ler karmaşıklığa değer Pazarlama ekipleri er ya da geç markalı kısa URL'ler isteyecek. Custom domain desteğini erken inşa etmek sonradan retrofit etmekten daha kolay. SSL sertifika dansı sinir bozucu ama halledilir.
Sırada Ne Var?#
4. Bölüm'de production deployment stratejilerini, gerçekten issue'ları debug etmekte yardımcı olan monitoring'i ve ayda yüzlerce dolar tasarruf ettirebilecek maliyet optimizasyon tekniklerini ele alacağız.
Ayrıca operasyonel yönlere dalacağız: trafik spike'larını nasıl handle ederiz, veritabanı scaling pattern'leri ve link shortener'ınızın bir sonraki güvenlik olayın olmayacağını bilerek rahat uyumanızı sağlayan monitoring kurulumu.
Burada inşa ettiğimiz güvenlik temeli, günde milyonlarca redirect handle etmek için scale ettiğimizde bize iyi hizmet edecek. Ama önce deployment pipeline'ımızın ve monitoring'imizin ayak uydurabildiğinden emin olmamız gerek.
Link shortener güvenlik olayları hakkında savaş hikayeleriniz var mı? Duymak isterim. Saldırganların yaratıcılığı beni şaşırtmaktan hiç vazgeçmiyor ve bu hikayeleri paylaşmak hepimizin daha iyi savunmalar inşa etmesine yardım ediyor.
AWS CDK Link Kısaltıcı: Sıfırdan Production'a
AWS CDK, Node.js Lambda ve DynamoDB ile production-grade bir link kısaltma servisi kurulumu hakkında 5 bölümlük kapsamlı seri. Gerçek production hikayeleri, performans optimizasyonu ve maliyet yönetimi dahil.
Bu Serideki Tüm Yazılar
Yorumlar (0)
Sohbete katıl
Düşüncelerini paylaşmak ve toplulukla etkileşim kurmak için giriş yap
Henüz yorum yok
Bu yazı hakkında ilk düşüncelerini paylaşan sen ol!
Yorumlar (0)
Sohbete katıl
Düşüncelerini paylaşmak ve toplulukla etkileşim kurmak için giriş yap
Henüz yorum yok
Bu yazı hakkında ilk düşüncelerini paylaşan sen ol!